Add Democratic fundraising platform.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-05-16 00:47:44 +00:00
parent 89f9b8dc83
commit 391d90a754
141 changed files with 14989 additions and 914 deletions

View File

@@ -1,6 +1,7 @@
# Copy to .env and fill in values. Never commit .env.
# --- App URLs (local LAN: use your container IP or 127.0.0.1) ---
NEXT_PUBLIC_SITE_URL=http://127.0.0.1:8008
NEXTAUTH_URL=http://127.0.0.1:8008
AUTH_URL=http://127.0.0.1:8008
AUTH_SECRET=generate_a_long_random_string_min_32_chars
@@ -11,14 +12,18 @@ DATABASE_URL=postgresql://fundraise:fundraise_local_dev@localhost:5432/fundraisi
# --- Branding & campaign copy ---
NEXT_PUBLIC_APP_NAME=Democracy Rising
PUBLIC_APP_NAME=Democracy Rising
PUBLIC_CREDIT_NAME=BLW
# Supporter credits (match NEXT_PUBLIC_* for browser — ticker + full name)
PUBLIC_CREDIT_TICKER=BWT
NEXT_PUBLIC_CREDIT_TICKER=BWT
PUBLIC_CREDIT_NAME=Blue Wave Token
NEXT_PUBLIC_CREDIT_NAME=Blue Wave Token
PUBLIC_CAMPAIGN_GOAL_USD=250000
# --- Legal / disclosure placeholders (not legal advice) ---
NEXT_PUBLIC_COMMITTEE_LEGAL_NAME_PLACEHOLDER=Your Committee Legal Name Here
COMMITTEE_LEGAL_NAME_PLACEHOLDER=Your Committee Legal Name Here
NEXT_PUBLIC_DISCLAIMER_TEXT=This deployment is for local demonstration. Configure FEC/state disclosures before accepting live contributions.
DISCLAIMER_TEXT=This deployment is for local demonstration. Configure FEC/state disclosures before accepting live contributions.
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.
# --- Stripe (test keys for development; use restricted keys in shared environments) ---
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
@@ -28,5 +33,8 @@ STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
# Credits: whole credits granted per CREDIT_RATIO_CENTS_PER_USD cents donated (100 = 1 credit per dollar).
CREDIT_RATIO_CENTS_PER_USD=100
# Straw poll (/vote/next-president): BLW spent per ballot (default 5).
PUBLIC_POLL_VOTE_CREDITS=5
# Optional: Stripe CLI for local webhook forwarding:
# stripe listen --forward-to 127.0.0.1:8008/api/webhooks/stripe

View File

@@ -1,7 +1,74 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
// Compress responses for faster delivery (better Core Web Vitals → better ranking)
compress: true,
// Power-user images: allow Next.js to serve them with optimal formats
images: {
formats: ["image/avif", "image/webp"],
},
async headers() {
return [
// Global security + SEO headers applied to every route
{
source: "/(.*)",
headers: [
// Don't leak referrer data to third parties
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
// Prevent MIME-type sniffing
{ key: "X-Content-Type-Options", value: "nosniff" },
// Stop clickjacking
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
// Basic XSS protection for legacy browsers
{ key: "X-XSS-Protection", value: "1; mode=block" },
// Force HTTPS for 1 year once visited
{
key: "Strict-Transport-Security",
value: "max-age=31536000; includeSubDomains; preload",
},
// Allow geolocation + media for legitimate use; block unused attack vectors
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(self), interest-cohort=()",
},
],
},
// Private pages: tell crawlers not to index them
{
source: "/wallet(.*)",
headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }],
},
{
source: "/api/(.*)",
headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }],
},
{
source: "/vote(.*)",
headers: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }],
},
// Static assets: long-lived cache
{
source: "/_next/static/(.*)",
headers: [
{ key: "Cache-Control", value: "public, max-age=31536000, immutable" },
],
},
];
},
async redirects() {
return [
// Canonical: redirect www → non-www (or flip this if your domain is www-first)
{
source: "/(.*)",
has: [{ type: "host", value: "www.democracyrising.org" }],
destination: "https://democracyrising.org/:path*",
permanent: true,
},
];
},
};
export default nextConfig;

269
package-lock.json generated
View File

@@ -21,6 +21,8 @@
"pg": "^8.20.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"stripe": "^22.1.1",
"zod": "^4.4.3"
},
@@ -38,7 +40,8 @@
"prisma": "^7.8.0",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
"typescript": "^5",
"zeptomatch": "^1.2.2"
}
},
"node_modules/@alloc/quick-lru": {
@@ -1908,6 +1911,17 @@
"zeptomatch": "2.1.0"
}
},
"node_modules/@prisma/dev/node_modules/zeptomatch": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz",
"integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"grammex": "^3.1.11",
"graphmatch": "^1.1.0"
}
},
"node_modules/@prisma/driver-adapter-utils": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz",
@@ -2209,6 +2223,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@socket.io/component-emitter": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -2537,6 +2557,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -2598,6 +2627,15 @@
"@types/react": "^19.2.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz",
@@ -3149,6 +3187,19 @@
"win32"
]
},
"node_modules/accepts": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
"license": "MIT",
"dependencies": {
"mime-types": "~2.1.34",
"negotiator": "0.6.3"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -3452,6 +3503,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/base64id": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
"license": "MIT",
"engines": {
"node": "^4.5.0 || >= 5.9"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.29",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
@@ -3749,6 +3809,32 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3836,7 +3922,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -4013,6 +4098,49 @@
"node": ">=14"
}
},
"node_modules/engine.io": {
"version": "6.6.7",
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.7.tgz",
"integrity": "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==",
"license": "MIT",
"dependencies": {
"@types/cors": "^2.8.12",
"@types/node": ">=10.0.0",
"@types/ws": "^8.5.12",
"accepts": "~1.3.4",
"base64id": "2.0.0",
"cookie": "~0.7.2",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/engine.io-client": {
"version": "6.6.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.18.3",
"xmlhttprequest-ssl": "~2.1.1"
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/enhanced-resolve": {
"version": "5.21.2",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz",
@@ -6346,6 +6474,27 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -6388,7 +6537,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/mysql2": {
@@ -6466,6 +6614,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/next": {
"version": "16.2.6",
"resolved": "https://registry.npmjs.org/next/-/next-16.2.6.tgz",
@@ -7730,6 +7887,62 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/socket.io": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
"license": "MIT",
"dependencies": {
"accepts": "~1.3.4",
"base64id": "~2.0.0",
"cors": "~2.8.5",
"debug": "~4.4.1",
"engine.io": "~6.6.0",
"socket.io-adapter": "~2.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.2.0"
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
"license": "MIT",
"dependencies": {
"debug": "~4.4.1",
"ws": "~8.18.3"
}
},
"node_modules/socket.io-client": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1",
"engine.io-client": "~6.6.1",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.4.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -8367,6 +8580,15 @@
}
}
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -8482,6 +8704,35 @@
"node": ">=0.10.0"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -8512,14 +8763,12 @@
}
},
"node_modules/zeptomatch": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz",
"integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==",
"devOptional": true,
"license": "MIT",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-1.2.2.tgz",
"integrity": "sha512-0ETdzEO0hdYmT8aXHHf5aMjpX+FHFE61sG4qKFAoJD2Umt3TWdCmH7ADxn2oUiWTlqBGC+SGr8sYMfr+37J8pQ==",
"dev": true,
"dependencies": {
"grammex": "^3.1.11",
"graphmatch": "^1.1.0"
"grammex": "^3.1.1"
}
},
"node_modules/zod": {

View File

@@ -3,10 +3,11 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack -H 0.0.0.0 -p 8008",
"build": "next build",
"start": "next start -H 0.0.0.0 -p 8008",
"lint": "next lint",
"dev": "NODE_OPTIONS='--max-old-space-size=8192' ./node_modules/.bin/tsx server.ts",
"dev:next": "NODE_OPTIONS='--max-old-space-size=8192' next dev --turbopack -H 0.0.0.0 -p 8008",
"build": "NODE_OPTIONS='--max-old-space-size=8192' next build",
"start": "NODE_OPTIONS='--max-old-space-size=8192' tsx server.ts",
"lint": "tsc --noEmit",
"postinstall": "prisma generate",
"db:seed": "tsx prisma/seed.ts",
"db:migrate": "prisma migrate dev",
@@ -30,6 +31,8 @@
"pg": "^8.20.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"socket.io": "^4.8.3",
"socket.io-client": "^4.8.3",
"stripe": "^22.1.1",
"zod": "^4.4.3"
},
@@ -47,6 +50,10 @@
"prisma": "^7.8.0",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
"typescript": "^5",
"zeptomatch": "^1.2.2"
},
"overrides": {
"zeptomatch": "^1.2.2"
}
}

View File

@@ -0,0 +1,33 @@
-- AlterEnum
ALTER TYPE "LedgerType" ADD VALUE 'DEBIT_POLL_VOTE';
-- CreateTable
CREATE TABLE "PollVote" (
"id" TEXT NOT NULL,
"pollSlug" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"normalizedKey" TEXT NOT NULL,
"creditsSpent" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PollVote_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PollVote_pollSlug_userId_key" ON "PollVote"("pollSlug", "userId");
-- CreateIndex
CREATE INDEX "PollVote_pollSlug_normalizedKey_idx" ON "PollVote"("pollSlug", "normalizedKey");
-- AddForeignKey
ALTER TABLE "PollVote" ADD CONSTRAINT "PollVote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "LedgerEntry" ADD COLUMN "pollVoteId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "LedgerEntry_pollVoteId_key" ON "LedgerEntry"("pollVoteId");
-- AddForeignKey
ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_pollVoteId_fkey" FOREIGN KEY ("pollVoteId") REFERENCES "PollVote"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "LedgerEntry" ADD COLUMN "raffleEntryId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "LedgerEntry_raffleEntryId_key" ON "LedgerEntry"("raffleEntryId");
-- AddForeignKey
ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_raffleEntryId_fkey" FOREIGN KEY ("raffleEntryId") REFERENCES "RaffleEntry"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,9 @@
-- AlterTable: guest donations + optional donor contact (CRM / disclosure tooling)
ALTER TABLE "Donation" DROP CONSTRAINT IF EXISTS "Donation_userId_fkey";
ALTER TABLE "Donation" ALTER COLUMN "userId" DROP NOT NULL;
ALTER TABLE "Donation" ADD COLUMN "donorEmail" TEXT;
ALTER TABLE "Donation" ADD COLUMN "donorName" TEXT;
ALTER TABLE "Donation" ADD CONSTRAINT "Donation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,95 @@
-- AlterEnum
ALTER TYPE "LedgerType" ADD VALUE 'DEBIT_GAME_BET';
ALTER TYPE "LedgerType" ADD VALUE 'CREDIT_GAME_WIN';
ALTER TYPE "LedgerType" ADD VALUE 'CREDIT_GAME_REFUND';
-- CreateEnum
CREATE TYPE "GameType" AS ENUM ('CRASH', 'DICE', 'MINES', 'TOWER', 'SLOTS', 'BLACKJACK', 'ROULETTE', 'COIN_FLIP', 'PONG', 'PREDICTION');
-- CreateEnum
CREATE TYPE "RoomStatus" AS ENUM ('WAITING', 'ACTIVE', 'RESOLVED', 'EXPIRED');
-- CreateTable
CREATE TABLE "GameSession" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"gameType" "GameType" NOT NULL,
"wageredBLW" INTEGER NOT NULL,
"payoutBLW" INTEGER NOT NULL DEFAULT 0,
"multiplier" DOUBLE PRECISION NOT NULL DEFAULT 0,
"outcome" TEXT NOT NULL,
"serverSeed" TEXT NOT NULL,
"clientSeed" TEXT,
"resultData" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "GameSession_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "GameSession_userId_idx" ON "GameSession"("userId");
-- CreateIndex
CREATE INDEX "GameSession_userId_gameType_idx" ON "GameSession"("userId", "gameType");
-- AddForeignKey
ALTER TABLE "GameSession" ADD CONSTRAINT "GameSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- CreateTable
CREATE TABLE "GameRoom" (
"id" TEXT NOT NULL,
"gameType" "GameType" NOT NULL,
"creatorId" TEXT NOT NULL,
"joinerId" TEXT,
"wageBLW" INTEGER NOT NULL,
"status" "RoomStatus" NOT NULL DEFAULT 'WAITING',
"resultData" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "GameRoom_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "GameRoom_status_idx" ON "GameRoom"("status");
-- CreateTable
CREATE TABLE "PredictionMarket" (
"id" TEXT NOT NULL,
"creatorId" TEXT NOT NULL,
"question" TEXT NOT NULL,
"endsAt" TIMESTAMP(3) NOT NULL,
"resolvedTo" BOOLEAN,
"totalYes" INTEGER NOT NULL DEFAULT 0,
"totalNo" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PredictionMarket_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PredictionMarket_endsAt_idx" ON "PredictionMarket"("endsAt");
-- CreateTable
CREATE TABLE "PredictionBet" (
"id" TEXT NOT NULL,
"marketId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"side" BOOLEAN NOT NULL,
"blwAmount" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "PredictionBet_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "PredictionBet_marketId_idx" ON "PredictionBet"("marketId");
-- CreateIndex
CREATE INDEX "PredictionBet_userId_idx" ON "PredictionBet"("userId");
-- AddForeignKey
ALTER TABLE "PredictionBet" ADD CONSTRAINT "PredictionBet_marketId_fkey" FOREIGN KEY ("marketId") REFERENCES "PredictionMarket"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "PredictionBet" ADD CONSTRAINT "PredictionBet_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,33 @@
-- AlterEnum
ALTER TYPE "LedgerType" ADD VALUE 'DEBIT_MISSION_SPEND';
-- CreateTable
CREATE TABLE "MissionSpend" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"missionSlug" TEXT NOT NULL,
"missionTitle" TEXT NOT NULL,
"creditsSpent" INTEGER NOT NULL,
"supporterNote" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MissionSpend_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "MissionSpend_userId_idx" ON "MissionSpend"("userId");
-- CreateIndex
CREATE INDEX "MissionSpend_missionSlug_idx" ON "MissionSpend"("missionSlug");
-- AddForeignKey
ALTER TABLE "MissionSpend" ADD CONSTRAINT "MissionSpend_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AlterTable
ALTER TABLE "LedgerEntry" ADD COLUMN "missionSpendId" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "LedgerEntry_missionSpendId_key" ON "LedgerEntry"("missionSpendId");
-- AddForeignKey
ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_missionSpendId_fkey" FOREIGN KEY ("missionSpendId") REFERENCES "MissionSpend"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,15 @@
-- Official platform priorities + nullable creator for community rows (one non-null creatorId still unique)
CREATE TYPE "DemocraticInitiativeOrigin" AS ENUM ('PLATFORM', 'COMMUNITY');
ALTER TABLE "DemocraticInitiative" DROP CONSTRAINT IF EXISTS "DemocraticInitiative_creatorId_key";
ALTER TABLE "DemocraticInitiative" ADD COLUMN "origin" "DemocraticInitiativeOrigin" NOT NULL DEFAULT 'COMMUNITY';
ALTER TABLE "DemocraticInitiative" ADD COLUMN "sortOrder" INTEGER NOT NULL DEFAULT 0;
ALTER TABLE "DemocraticInitiative" ALTER COLUMN "creatorId" DROP NOT NULL;
CREATE UNIQUE INDEX "DemocraticInitiative_creatorId_key" ON "DemocraticInitiative"("creatorId");
CREATE INDEX "DemocraticInitiative_origin_sortOrder_idx" ON "DemocraticInitiative"("origin", "sortOrder");
CREATE INDEX "DemocraticInitiative_origin_createdAt_idx" ON "DemocraticInitiative"("origin", "createdAt");

View File

@@ -0,0 +1,26 @@
-- Add optional username column, backfill, then constrain.
ALTER TABLE "User" ADD COLUMN "username" TEXT;
UPDATE "User"
SET "username" =
LEFT(
COALESCE(
NULLIF(
regexp_replace(
lower(trim(split_part("email", '@', 1))),
'[^a-z0-9_]',
'_',
'g'
),
''
),
'supporter'
),
23
)
|| '_'
|| right(replace("id", '-', ''), 8);
ALTER TABLE "User" ALTER COLUMN "username" SET NOT NULL;
CREATE UNIQUE INDEX "User_username_key" ON "User" ("username");

View File

@@ -10,7 +10,39 @@ enum LedgerType {
CREDIT_DONATION
DEBIT_SPEND
DEBIT_RAFFLE
DEBIT_POLL_VOTE
DEBIT_MISSION_SPEND
DEBIT_INITIATIVE_SPEND
ADJUSTMENT
DEBIT_GAME_BET
CREDIT_GAME_WIN
CREDIT_GAME_REFUND
DEBIT_BILLBOARD
DEBIT_SPOTLIGHT
DEBIT_CARD_MINT
DEBIT_FAQ_SUBMIT
DEBIT_FAQ_VOTE
DEBIT_BOOST
}
enum GameType {
CRASH
DICE
MINES
TOWER
SLOTS
BLACKJACK
ROULETTE
COIN_FLIP
PONG
PREDICTION
}
enum RoomStatus {
WAITING
ACTIVE
RESOLVED
EXPIRED
}
enum UserRole {
@@ -21,6 +53,8 @@ enum UserRole {
model User {
id String @id @default(cuid())
email String @unique
/// Lowercase [a-z0-9_] (332). Used along with email for credentials login.
username String @unique
emailVerified DateTime?
name String?
image String?
@@ -35,6 +69,18 @@ model User {
donations Donation[]
raffleEntries RaffleEntry[]
redemptions Redemption[]
pollVotes PollVote[]
missionSpends MissionSpend[]
initiativeSpends InitiativeSpend[]
createdInitiatives DemocraticInitiative[]
gameSessions GameSession[]
predictionBets PredictionBet[]
billboardMessages BillboardMessage[]
spotlightBids SpotlightBid[]
supporterCards SupporterCard[]
faqSubmissions FaqSubmission[]
faqVotes FaqVote[]
movementBoosts MovementBoost[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -93,28 +139,131 @@ model LedgerEntry {
type LedgerType
memo String?
donationId String? @unique
donation Donation? @relation(fields: [donationId], references: [id])
redemptionId String? @unique
redemption Redemption? @relation(fields: [redemptionId], references: [id])
donationId String? @unique
donation Donation? @relation(fields: [donationId], references: [id])
redemptionId String? @unique
redemption Redemption? @relation(fields: [redemptionId], references: [id])
raffleEntryId String? @unique
raffleEntry RaffleEntry? @relation(fields: [raffleEntryId], references: [id])
pollVoteId String? @unique
pollVote PollVote? @relation(fields: [pollVoteId], references: [id])
missionSpendId String? @unique
missionSpend MissionSpend? @relation(fields: [missionSpendId], references: [id])
initiativeSpendId String? @unique
initiativeSpend InitiativeSpend? @relation(fields: [initiativeSpendId], references: [id])
createdAt DateTime @default(now())
billboardMessageId String? @unique
billboardMessage BillboardMessage? @relation(fields: [billboardMessageId], references: [id])
spotlightBidId String? @unique
spotlightBid SpotlightBid? @relation(fields: [spotlightBidId], references: [id])
supporterCardId String? @unique
supporterCard SupporterCard? @relation(fields: [supporterCardId], references: [id])
faqSubmissionId String? @unique
faqSubmission FaqSubmission? @relation(fields: [faqSubmissionId], references: [id])
faqVoteId String? @unique
faqVote FaqVote? @relation(fields: [faqVoteId], references: [id])
movementBoostId String? @unique
movementBoost MovementBoost? @relation(fields: [movementBoostId], references: [id])
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model PollVote {
id String @id @default(cuid())
pollSlug String
userId String
displayName String
normalizedKey String
creditsSpent Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ledgerEntry LedgerEntry?
createdAt DateTime @default(now())
@@unique([pollSlug, userId])
@@index([pollSlug, normalizedKey])
}
model MissionSpend {
id String @id @default(cuid())
userId String
missionSlug String
missionTitle String
creditsSpent Int
supporterNote String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ledgerEntry LedgerEntry?
createdAt DateTime @default(now())
@@index([userId])
@@index([missionSlug])
}
enum DemocraticInitiativeOrigin {
PLATFORM
COMMUNITY
}
model DemocraticInitiative {
id String @id @default(cuid())
slug String @unique
/// Null for official platform priorities; set for community-authored initiatives.
creatorId String?
origin DemocraticInitiativeOrigin @default(COMMUNITY)
/// Lower sorts first among PLATFORM rows; ignored for COMMUNITY (use createdAt).
sortOrder Int @default(0)
title String
description String @db.Text
creator User? @relation(fields: [creatorId], references: [id], onDelete: Cascade)
spends InitiativeSpend[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([origin, sortOrder])
@@index([origin, createdAt])
@@index([createdAt])
}
model InitiativeSpend {
id String @id @default(cuid())
initiativeId String
userId String
creditsSpent Int
supporterNote String?
initiative DemocraticInitiative @relation(fields: [initiativeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ledgerEntry LedgerEntry?
createdAt DateTime @default(now())
@@index([initiativeId])
@@index([userId])
}
model Donation {
id String @id @default(cuid())
stripePaymentIntentId String @unique
userId String
/// Null when guest checkout (still counts toward public totals).
userId String?
amountUsdCents Int
creditsAwarded Int
currency String @default("usd")
status String @default("succeeded")
/// Optional CRM fields from guest flow (never required to donate).
donorEmail String?
donorName String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
ledgerEntry LedgerEntry?
createdAt DateTime @default(now())
@@ -148,6 +297,69 @@ model Redemption {
@@index([userId])
}
model GameSession {
id String @id @default(cuid())
userId String
gameType GameType
wageredBLW Int
payoutBLW Int @default(0)
multiplier Float @default(0)
outcome String
serverSeed String
clientSeed String?
resultData Json?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([userId, gameType])
}
model GameRoom {
id String @id @default(cuid())
gameType GameType
creatorId String
joinerId String?
wageBLW Int
status RoomStatus @default(WAITING)
resultData Json?
createdAt DateTime @default(now())
expiresAt DateTime
@@index([status])
}
model PredictionMarket {
id String @id @default(cuid())
creatorId String
question String
endsAt DateTime
resolvedTo Boolean?
totalYes Int @default(0)
totalNo Int @default(0)
createdAt DateTime @default(now())
bets PredictionBet[]
@@index([endsAt])
}
model PredictionBet {
id String @id @default(cuid())
marketId String
userId String
side Boolean
blwAmount Int
createdAt DateTime @default(now())
market PredictionMarket @relation(fields: [marketId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([marketId])
@@index([userId])
}
model Raffle {
id String @id @default(cuid())
slug String @unique
@@ -166,11 +378,137 @@ model RaffleEntry {
tickets Int @default(1)
creditsSpent Int
raffle Raffle @relation(fields: [raffleId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
raffle Raffle @relation(fields: [raffleId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ledgerEntry LedgerEntry?
createdAt DateTime @default(now())
@@index([raffleId])
@@index([userId])
}
// ── Feature 1: Democracy Billboard ──────────────────────────────────────────
// Users spend BWT to post a short rally-cry message on the live public ticker.
// creditsSpent determines display weight (higher = longer / more prominent).
model BillboardMessage {
id String @id @default(cuid())
userId String
displayName String
message String @db.VarChar(140)
creditsSpent Int
expiresAt DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ledgerEntry LedgerEntry?
createdAt DateTime @default(now())
@@index([expiresAt])
@@index([userId])
}
// ── Feature 2: Issue Spotlight Auction ──────────────────────────────────────
// Weekly auction: users bid BWT on a policy issue; most-funded issue becomes
// the "Issue of the Week" featured on the homepage.
model SpotlightBid {
id String @id @default(cuid())
userId String
issueSlug String
issueTitle String
creditsSpent Int
weekOf String // ISO date string of Monday: "2026-05-11"
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ledgerEntry LedgerEntry?
createdAt DateTime @default(now())
@@index([weekOf, issueSlug])
@@index([userId])
}
// ── Feature 3: Supporter Trading Cards ──────────────────────────────────────
// Users mint a collectible stat card (snapshot of their donor profile).
// Tier 13 unlocked by cumulative credits ever earned.
model SupporterCard {
id String @id @default(cuid())
userId String
tier Int @default(1) // 1 = Standard, 2 = Rare, 3 = Legendary
serialNumber Int // sequential per user
creditsSpent Int
statsSnapshot Json // { totalDonatedUsd, creditsEarned, rank, etc. }
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ledgerEntry LedgerEntry?
createdAt DateTime @default(now())
@@index([userId])
@@index([tier])
}
// ── Feature 4: Community FAQ Board ──────────────────────────────────────────
// Spend BWT to submit a question; spend 1 BWT to upvote others.
// Admin can approve/reject — approved questions appear in the live FAQ.
enum FaqStatus {
PENDING
APPROVED
REJECTED
}
model FaqSubmission {
id String @id @default(cuid())
userId String
displayName String
question String @db.VarChar(280)
answer String? @db.Text // filled by admin on approval
status FaqStatus @default(PENDING)
creditsSpent Int
voteTotal Int @default(0)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
votes FaqVote[]
ledgerEntry LedgerEntry?
createdAt DateTime @default(now())
@@index([status])
@@index([userId])
}
model FaqVote {
id String @id @default(cuid())
submissionId String
userId String
creditsSpent Int @default(1)
submission FaqSubmission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ledgerEntry LedgerEntry?
createdAt DateTime @default(now())
@@unique([submissionId, userId])
@@index([submissionId])
@@index([userId])
}
// ── Feature 5: Power the Movement Meter ─────────────────────────────────────
// A community energy meter. Any user can add BWT to charge it up.
// When the meter hits the target, a milestone event fires and all contributors
// get a BWT bonus proportional to their contribution.
model MovementBoost {
id String @id @default(cuid())
userId String
creditsSpent Int
epochId Int @default(1) // increments each time the meter resets
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ledgerEntry LedgerEntry?
createdAt DateTime @default(now())
@@index([epochId])
@@index([userId])
}

View File

@@ -1,7 +1,7 @@
import "dotenv/config";
import bcrypt from "bcryptjs";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@prisma/client";
import { DemocraticInitiativeOrigin, PrismaClient } from "@prisma/client";
import { Pool } from "pg";
const connectionString = process.env.DATABASE_URL;
@@ -20,9 +20,15 @@ async function main() {
const user = await prisma.user.upsert({
where: { email },
update: { passwordHash: hash, name: "Demo Supporter", role: "USER" },
update: {
passwordHash: hash,
name: "Demo Supporter",
role: "USER",
username: "portal_demo",
},
create: {
email,
username: "portal_demo",
name: "Demo Supporter",
passwordHash: hash,
role: "USER",
@@ -44,9 +50,11 @@ async function main() {
passwordHash: adminHash,
name: "Dr Jones",
role: "ADMIN",
username: "portal_admin",
},
create: {
email: adminEmail,
username: "portal_admin",
name: "Dr Jones",
passwordHash: adminHash,
role: "ADMIN",
@@ -95,6 +103,65 @@ async function main() {
},
});
const officialPriorities: Array<{ slug: string; sortOrder: number; title: string; description: string }> = [
{
slug: "priority-voting-access",
sortOrder: 0,
title: "Voting access & fair elections",
description:
"Protect early voting, drop boxes where allowed, and nonpartisan election administration — signal BWT here if this is the lane you want organizers and messaging to emphasize first.",
},
{
slug: "priority-climate-jobs",
sortOrder: 1,
title: "Climate jobs & clean infrastructure",
description:
"Invest in union-scale clean energy, grid resilience, and communities transitioning off volatile fossil cycles — pledge BWT to raise the salience of green industrial policy in the program.",
},
{
slug: "priority-health-affordability",
sortOrder: 2,
title: "Healthcare affordability",
description:
"Expand coverage choices, cap out-of-pocket shocks for families, and defend access to care — use BWT totals here to show democratic demand for health-centered field work.",
},
{
slug: "priority-workers-rights",
sortOrder: 3,
title: "Workers rights & wages",
description:
"Stand with organizing, overtime fairness, and safety nets that reward work — pledges aggregate as a live scoreboard for how loud supporters want labor-forward priorities.",
},
{
slug: "priority-community-safety",
sortOrder: 4,
title: "Community safety & prevention",
description:
"Fund violence interruption, mental health first responders, and common-sense safety policy without scapegoating — BWT here steers narrative and volunteer energy toward prevention-first democracy.",
},
];
for (const p of officialPriorities) {
await prisma.democraticInitiative.upsert({
where: { slug: p.slug },
update: {
title: p.title,
description: p.description,
origin: DemocraticInitiativeOrigin.PLATFORM,
sortOrder: p.sortOrder,
creatorId: null,
},
create: {
slug: p.slug,
title: p.title,
description: p.description,
origin: DemocraticInitiativeOrigin.PLATFORM,
sortOrder: p.sortOrder,
creatorId: null,
},
});
}
// eslint-disable-next-line no-console
console.log("Seed OK — demo:", email, "/", password);
// eslint-disable-next-line no-console

103
redmeFIXSES.md Normal file
View File

@@ -0,0 +1,103 @@
# redmeFIXSES — systematic issue & follow-up list
This document is a **repo-wide audit** of the Democratic Fundraising Platform (`/root/fundraising-platform`): things that need a **solution, decision, wiring, or hardening** before calling the product complete for real-world use. Items are grouped for triage; paths are relative to the project root.
---
## 1. Security & authentication (high priority)
| # | Issue | Where / notes |
|---|--------|----------------|
| 1.1 | **Socket.IO trusts `userId` from the browser** — anyone can emit another users id and trigger **debits, refunds, or payouts** against the wrong wallet. There is no session cookie / JWT handshake binding the socket to `auth()`. | `server.ts` (`join_room`, `paddle_move` for Pong); `src/components/casino/CoinFlipRoom.tsx` passes `userId` into `io(...).emit("join_room", { roomId, userId })`. **Fix:** authenticate on connection (e.g. pass a short-lived signed token or upgrade request with session), ignore client-supplied `userId`, use server-derived id. |
| 1.2 | **CORS is `origin: "*"`** on the Socket.IO server — combined with 1.1, third-party sites could script abuse if a victim is logged in (and even without, id spoofing is already possible). | `server.ts``new SocketIOServer(..., { cors: { origin: "*" } })`. **Fix:** restrict to `siteUrl()` / env allowlist. |
| 1.3 | **Registration endpoint is unthrottled** — email enumeration, credential stuffing, mass fake accounts. | `src/app/api/register/route.ts`. **Fix:** rate limit (IP + email), CAPTCHA or proof-of-work for production, optional email verification before wallet use. |
| 1.4 | **No automated password reset** — acceptable as a placeholder only; increases support load and lockout risk. | `src/app/forgot-password/page.tsx` (static copy). **Fix:** SMTP or auth provider + `VerificationToken` flow. |
| 1.5 | **Admin role** can only be granted out-of-band (DB/seed). There is **no secure admin bootstrap or audit log** for privileged actions. | `src/lib/admin.ts`, usage in FAQ/billboard/etc. **Fix:** documented procedure, optional separate admin app, logging. |
| 1.6 | **Public FAQ API leaks pending community submissions** (display names + text) to anyone. | `src/app/api/faq/route.ts` `GET` returns `pending` without auth. **Fix:** require auth for pending, or admin-only pending list. |
---
## 2. Data integrity, races, and consistency
| # | Issue | Where / notes |
|---|--------|----------------|
| 2.1 | **`/api/public/stats` vs treasury** — donation aggregates use **no `status: "succeeded"` filter**, while `getTreasuryTotalUsdCents()` filters `succeeded`. Today most rows are succeeded-only, but the two can **diverge** if statuses are ever used. | `src/app/api/public/stats/route.ts` vs `src/lib/treasury.ts`. **Fix:** align queries on the same predicate. |
| 2.2 | **Prediction bets: debit and row insert are not one transaction** — edge cases under concurrency could desync balance vs bets. | `src/app/api/games/prediction/route.ts` (`debitForBet` then `predictionBet.create`). **Fix:** single `prisma.$transaction`. |
| 2.3 | **Movement meter milestone bonuses** — crossing `METER_TARGET` can race under concurrent `POST`s; bonus grants are not idempotent keys. | `src/app/api/boost/route.ts`. **Fix:** row lock / serializable transaction, or separate “epoch_closed” record before paying bonuses. |
| 2.4 | **Ledger vs `GameSession`**`DEBIT_GAME_BET` / `CREDIT_GAME_WIN` ledger lines are **not FK-linked** to `GameSession`/`GameRoom`, making reconciliation and support harder. | `src/lib/game-ledger.ts`, `prisma/schema.prisma`. **Fix:** optional `gameSessionId` / `gameRoomId` on `LedgerEntry`. |
| 2.5 | **Pong server state**`setInterval` runs in process memory; **multi-instance deploy** would break. **Process crash** mid-game loses state (partial debits may already have happened). | `server.ts`. **Fix:** document single-instance requirement, or Redis-backed rooms + recovery. |
---
## 3. Features implemented in API only (no UI wiring)
Grep shows **no** `fetch("/api/…")` usage from the React app for these routes; they are **dead from an end-user perspective** unless hit manually or by a future screen.
| Route area | Files | Needed solution |
|------------|--------|-----------------|
| Community FAQ board | `src/app/api/faq/route.ts` | Add a page or embed: list approved, submit/vote when signed in, admin approve/reject UI (or drop the API). |
| Democracy billboard | `src/app/api/billboard/route.ts` | Wire ticker/post UI; or remove until product wants it. |
| Issue spotlight auction | `src/app/api/spotlight/route.ts` | Wire homepage “issue of the week” UI to real bids; or remove. |
| Movement meter (BWT sink + milestone) | `src/app/api/boost/route.ts` | The homepage **“Grassroots meter”** (`ProgressSection`) is **Stripe USD goal**, not this BWT meter — product copy vs implementation are **misaligned**. Either wire `/api/boost` into the UI + clarify copy, or retire the feature. |
| Supporter trading cards | `src/app/api/cards/route.ts` | Add profile/wallet cards UI; or remove. |
Static marketing FAQ (`src/components/FaqSection.tsx`) and the dynamic FAQ API are **two parallel systems** — decide which is source of truth.
---
## 4. Product / compliance / copy
| # | Issue | Notes |
|---|--------|--------|
| 4.1 | **Committee & legal placeholders** | README and env still assume `NEXT_PUBLIC_COMMITTEE_LEGAL_NAME_PLACEHOLDER`, disclaimers, counsel review before live fundraising. |
| 4.2 | **Casino / prediction / games** | Server-side games exist; **jurisdiction, gambling, and campaign-finance** implications need counsel. Disclosure copy may be insufficient sitewide. |
| 4.3 | **Prediction markets** | Creator resolves outcome (`PATCH` in `src/app/api/games/prediction/route.ts`) — no independent oracle; reputational and fairness risk. |
| 4.4 | **User-generated initiatives** | `src/app/api/initiatives/route.ts` — public text up to 8k chars with **no moderator queue** in code. |
| 4.5 | **Email verification** | `User.emailVerified` exists in Prisma but credentials flow does not set or enforce it. |
---
## 5. Configuration, dev/prod parity, and tooling
| # | Issue | Where / notes |
|---|--------|----------------|
| 5.1 | **`NEXT_PUBLIC_SITE_URL` missing from `.env.example`** | Defaults in `src/lib/public-env.ts` to `https://democracyrising.org` — local/staging can silently emit **wrong canonical URLs** in metadata/sitemap. |
| 5.2 | **`npm run dev` uses `server.ts`** | Socket games depend on this. **`npm run dev:next`** skips custom server — **coin flip / pong break** if a developer uses the wrong script. Document clearly or unify. |
| 5.3 | **`npm run lint` is only `tsc --noEmit`** | `eslint` is in `devDependencies` but not wired as a script — style and Next lint rules are unused in CI script form. |
| 5.4 | **Strict-Transport-Security with preload** | `next.config.ts` applies globally. **Local HTTP dev** is usually fine, but misconfigured hosts + HSTS have bitten teams before — confirm intended behavior for each environment. |
| 5.5 | **Sitemap is incomplete** | `src/app/sitemap.ts` omits major routes (`/missions`, `/initiatives`, `/casino`, etc.) vs actual app surface. **SEO** gap or intentional — decide. |
---
## 6. UX / polish still called out historically
From the workspace plan `platform_bug_hunt_&_polish_30ad9fd4.plan.md` (many items marked done), the following **may still be desirable**:
| Item | Notes |
|------|--------|
| **Active nav state** | `src/components/SiteNav.tsx` — no “current page” styling for non-anchor routes. |
| **Loading shimmer coverage** | Plan mentioned skeletons beyond what `MockExchangeTicker` already does for errors. |
| **DonationCheckout** | Mentions `.env` in user-visible copy — consider production-friendly wording (`src/components/DonationCheckout.tsx`). |
---
## 7. Testing & observability
| # | Gap |
|---|-----|
| 7.1 | No unit test suite — only `scripts/smoke-integration.ts`, `scripts/http-smoke.sh`, and `scripts/full-site-test.ts` (manual against a running server). |
| 7.2 | No structured logging / APM hooks for Stripe webhooks or game transactions. |
| 7.3 | Webhook path returns 500 on processing errors — need **alerting** and **Stripe retry** monitoring in production. |
---
## 8. Quick reference — files that most often need attention
- **Auth & gatekeeping:** `src/middleware.ts` (only protects `/wallet`), `src/auth.ts`, `src/auth.config.ts`
- **Money:** `src/app/api/webhooks/stripe/route.ts`, `src/app/api/stripe/create-payment-intent/route.ts`
- **Realtime games:** `server.ts`, `src/app/api/games/rooms/route.ts`, `src/components/casino/CoinFlipRoom.tsx`
- **Public truth vs internals:** `src/app/api/public/stats/route.ts`, `src/lib/treasury.ts`
---
*Generated by a full-tree pass of source, Prisma schema, config, and README; re-run this audit after large feature merges.*

762
scripts/full-site-test.ts Normal file
View File

@@ -0,0 +1,762 @@
/**
* Full-site integration test — runs against http://localhost:8008
* Tests: routes, auth flows, API endpoints, link integrity, images, spacing.
*
* Run: npx tsx scripts/full-site-test.ts
*/
const BASE = "http://localhost:8008";
interface Result {
name: string;
pass: boolean;
detail: string;
}
const results: Result[] = [];
let cookies = ""; // session cookies accumulated per test user
function pass(name: string, detail = "") {
results.push({ name, pass: true, detail });
console.log(`${name}${detail ? " → " + detail : ""}`);
}
function fail(name: string, detail = "") {
results.push({ name, pass: false, detail });
console.error(`${name}${detail ? " → " + detail : ""}`);
}
async function get(path: string, opts: RequestInit = {}) {
const res = await fetch(`${BASE}${path}`, {
redirect: "follow",
headers: { cookie: cookies, "user-agent": "SiteTestBot/1.0", ...(opts.headers as Record<string, string> ?? {}) },
...opts,
});
return res;
}
async function postJson(path: string, body: unknown, extraHeaders: Record<string, string> = {}) {
const res = await fetch(`${BASE}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
cookie: cookies,
"user-agent": "SiteTestBot/1.0",
...extraHeaders,
},
body: JSON.stringify(body),
redirect: "follow",
});
// persist Set-Cookie
const sc = res.headers.get("set-cookie");
if (sc) cookies += "; " + sc.split(";")[0];
return res;
}
function extractLinks(html: string): string[] {
const hrefs: string[] = [];
const re = /href="([^"#][^"]*)"/g;
let m;
while ((m = re.exec(html)) !== null) {
hrefs.push(m[1]);
}
return hrefs;
}
function extractImgSrcs(html: string): string[] {
const srcs: string[] = [];
const re = /(?:src|srcset)="([^"]+)"/g;
let m;
while ((m = re.exec(html)) !== null) {
srcs.push(m[1].split(" ")[0]);
}
return srcs;
}
function detectSpacingIssues(html: string, context: string): void {
// Strip tags to check text nodes
const text = html.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
.replace(/<[^>]+>/g, " ")
.replace(/&amp;/g, "&")
.replace(/&nbsp;/g, " ")
.replace(/\s+/g, " ");
// Common double-space, missing space before/after common words
const issues: string[] = [];
if (/\w{2,}\s{2,}\w/.test(text)) issues.push("double-space detected");
if (/[a-z][A-Z][a-z]/.test(text.replace(/\s/g, ""))) {
// camelCase bleed into content is already stripped by tag removal above — skip
}
if (issues.length > 0) fail(`spacing: ${context}`, issues.join(", "));
else pass(`spacing: ${context}`);
}
// ─────────────────────────────────────────
// 1. PUBLIC ROUTES
// ─────────────────────────────────────────
async function testPublicRoutes() {
console.log("\n── 1. PUBLIC ROUTES ──────────────────────────────");
const routes: Array<{ path: string; minLen?: number }> = [
{ path: "/", minLen: 2000 },
{ path: "/raised", minLen: 500 },
{ path: "/register", minLen: 500 },
{ path: "/login", minLen: 300 },
{ path: "/forgot-password", minLen: 200 },
{ path: "/donate", minLen: 500 },
{ path: "/donate/thank-you", minLen: 200 },
{ path: "/billboard", minLen: 200 },
{ path: "/boost", minLen: 200 },
{ path: "/spotlight", minLen: 200 },
{ path: "/cards", minLen: 200 },
{ path: "/faq-board", minLen: 200 },
{ path: "/missions", minLen: 200 },
{ path: "/initiatives", minLen: 200 },
{ path: "/vote/next-president", minLen: 200 },
{ path: "/robots.txt", minLen: 50 },
{ path: "/sitemap.xml", minLen: 100 },
{ path: "/opengraph-image", minLen: 100 },
{ path: "/favicon.ico" },
];
for (const r of routes) {
try {
const res = await get(r.path);
if (res.status === 200) {
const body = await res.text();
const len = body.length;
if (r.minLen && len < r.minLen) {
fail(`GET ${r.path}`, `body too short: ${len} < ${r.minLen}`);
} else {
pass(`GET ${r.path}`, `${res.status} (${len} bytes)`);
}
} else {
fail(`GET ${r.path}`, `HTTP ${res.status}`);
}
} catch (e) {
fail(`GET ${r.path}`, String(e));
}
}
}
// ─────────────────────────────────────────
// 2. SEO / META TAGS
// ─────────────────────────────────────────
async function testSeoMeta() {
console.log("\n── 2. SEO / META TAGS ────────────────────────────");
const res = await get("/");
const html = await res.text();
const checks: Array<{ name: string; pattern: RegExp }> = [
{ name: "title tag", pattern: /<title>/ },
{ name: "meta description", pattern: /name="description"/ },
{ name: "meta keywords", pattern: /name="keywords"/ },
{ name: "og:title", pattern: /property="og:title"/ },
{ name: "og:description", pattern: /property="og:description"/ },
{ name: "og:image", pattern: /property="og:image"/ },
{ name: "og:url", pattern: /property="og:url"/ },
{ name: "twitter:card", pattern: /name="twitter:card"/ },
{ name: "twitter:image", pattern: /name="twitter:image"/ },
{ name: "canonical link", pattern: /rel="canonical"/ },
{ name: "robots meta", pattern: /name="robots"/ },
{ name: "JSON-LD script", pattern: /application\/ld\+json/ },
{ name: "lang=en on html", pattern: /<html[^>]+lang="en"/ },
{ name: "viewport meta", pattern: /name="viewport"/ },
];
for (const c of checks) {
if (c.pattern.test(html)) pass(`seo: ${c.name}`);
else fail(`seo: ${c.name}`, "not found in /");
}
// robots.txt content
const robots = await (await get("/robots.txt")).text();
if (/Disallow.*\/wallet/.test(robots)) pass("robots.txt blocks /wallet");
else fail("robots.txt blocks /wallet", robots.substring(0, 200));
if (/Sitemap:/.test(robots)) pass("robots.txt has Sitemap directive");
else fail("robots.txt has Sitemap directive");
// sitemap.xml
const sitemap = await (await get("/sitemap.xml")).text();
const required = ["/", "/raised", "/register", "/login"];
for (const url of required) {
if (sitemap.includes(url)) pass(`sitemap: contains ${url}`);
else fail(`sitemap: missing ${url}`);
}
}
// ─────────────────────────────────────────
// 3. LINK INTEGRITY (no dead ends)
// ─────────────────────────────────────────
async function testLinks() {
console.log("\n── 3. LINK INTEGRITY ─────────────────────────────");
const pages = ["/", "/raised", "/register", "/login", "/forgot-password", "/donate", "/missions", "/initiatives"];
const knownExternal = new Set(["https://", "http://", "mailto:", "tel:"]);
const checked = new Set<string>();
for (const page of pages) {
const res = await get(page);
const html = await res.text();
const links = extractLinks(html).filter(
(l) => l.startsWith("/") && !l.startsWith("//") && !checked.has(l)
);
for (const link of links) {
// skip Next.js internals, API, and anchor-only hrefs
if (
link.startsWith("/_next") ||
link.startsWith("/api/") ||
link === "/#" ||
link.startsWith("/#")
) continue;
const clean = link.split("?")[0].split("#")[0];
if (!clean || checked.has(clean)) continue;
checked.add(clean);
try {
const r = await fetch(`${BASE}${clean}`, {
redirect: "manual",
headers: { cookie: cookies, "user-agent": "SiteTestBot/1.0" },
});
// 200 (page renders), 3xx (auth/short-link redirect), all considered alive.
if (r.status === 200 || (r.status >= 300 && r.status < 400)) {
pass(`link: ${clean}`, `${r.status}`);
} else {
fail(`link: ${clean}`, `HTTP ${r.status}`);
}
} catch (e) {
fail(`link: ${clean}`, String(e));
}
}
}
}
// ─────────────────────────────────────────
// 4. IMAGES
// ─────────────────────────────────────────
async function testImages() {
console.log("\n── 4. IMAGES ─────────────────────────────────────");
const res = await get("/");
const html = await res.text();
const srcs = extractImgSrcs(html);
// Also check known static assets
const staticAssets = [
"/favicon.ico",
"/opengraph-image",
];
const allToCheck = [...new Set([...srcs.filter((s) => s.startsWith("/")), ...staticAssets])];
for (const src of allToCheck.slice(0, 20)) {
// Skip data URIs and external
if (src.startsWith("data:") || src.startsWith("http")) continue;
// Skip _next/image with external src params
if (src.includes("url=http")) continue;
try {
const r = await get(src);
if (r.status === 200) pass(`image: ${src}`);
else fail(`image: ${src}`, `HTTP ${r.status}`);
} catch (e) {
fail(`image: ${src}`, String(e));
}
}
if (allToCheck.length === 0) pass("images: no <img> tags found (CSS backgrounds / SVG components)");
}
// ─────────────────────────────────────────
// 5. WORD SPACING
// ─────────────────────────────────────────
async function testSpacing() {
console.log("\n── 5. WORD SPACING ───────────────────────────────");
const pages = ["/", "/raised", "/register"];
for (const p of pages) {
const html = await (await get(p)).text();
detectSpacingIssues(html, p);
}
}
// ─────────────────────────────────────────
// 6. AUTH — REGISTRATION FLOW
// ─────────────────────────────────────────
const TEST_EMAIL = `testbot_${Date.now()}@sitetest.local`;
const TEST_PASS = "TestBot@1234!";
let CREATED_USERNAME = ""; // assigned by POST /api/register
async function testRegistration() {
console.log("\n── 6. REGISTRATION FLOW ──────────────────────────");
// Register new user
const res = await postJson("/api/register", {
name: "Site Test Bot",
email: TEST_EMAIL,
password: TEST_PASS,
});
if (res.status === 201 || res.status === 200) {
const body = (await res.json().catch(() => ({}))) as Record<string, unknown>;
CREATED_USERNAME = typeof body.username === "string" ? body.username : "";
pass(
"POST /api/register",
`user created: ${typeof body.username === "string" ? `@${body.username}` : ""} ${body.email ?? ""}`.trim(),
);
} else {
const text = await res.text();
fail("POST /api/register", `${res.status}: ${text.substring(0, 120)}`);
return;
}
// Try duplicate registration
const dup = await postJson("/api/register", {
name: "Site Test Bot 2",
email: TEST_EMAIL,
password: TEST_PASS,
});
if (dup.status === 409 || dup.status === 400 || dup.status === 422) {
pass("duplicate registration blocked", `${dup.status}`);
} else {
fail("duplicate registration blocked", `expected 4xx, got ${dup.status}`);
}
}
// ─────────────────────────────────────────
// 7. AUTH — LOGIN FLOW (via NextAuth credentials)
// ─────────────────────────────────────────
async function testLogin() {
console.log("\n── 7. LOGIN FLOW ─────────────────────────────────");
// Get CSRF token
const csrfRes = await get("/api/auth/csrf");
let csrfToken = "";
try {
const j = await csrfRes.json() as { csrfToken: string };
csrfToken = j.csrfToken;
pass("GET /api/auth/csrf", `token: ${csrfToken.substring(0, 12)}`);
} catch (e) {
fail("GET /api/auth/csrf", String(e));
return;
}
// Store session cookie from CSRF
const sc = csrfRes.headers.get("set-cookie");
if (sc) cookies += "; " + sc.split(";")[0];
// Sign in via credentials
const formBody = new URLSearchParams({
email: TEST_EMAIL,
password: TEST_PASS,
csrfToken,
callbackUrl: "/wallet",
json: "true",
});
const loginRes = await fetch(`${BASE}/api/auth/callback/credentials`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
cookie: cookies,
"user-agent": "SiteTestBot/1.0",
},
body: formBody.toString(),
redirect: "manual",
});
const loginSc = loginRes.headers.get("set-cookie");
if (loginSc) cookies += "; " + loginSc.split(";")[0];
if (loginRes.status === 200 || loginRes.status === 302 || loginRes.status === 303) {
pass("POST /api/auth/callback/credentials", `${loginRes.status} — session established`);
if (/authjs\.session-token|__Secure-authjs\.session-token/.test(loginSc ?? "") && /Max-Age=/i.test(loginSc ?? "")) {
pass("session cookie persistence", "authjs session cookie includes Max-Age");
} else {
fail("session cookie persistence", "missing persistent authjs session cookie Max-Age");
}
} else {
const body = await loginRes.text().catch(() => "");
fail("POST /api/auth/callback/credentials", `${loginRes.status}: ${body.substring(0, 200)}`);
}
// Bad password should fail
const badLogin = await fetch(`${BASE}/api/auth/callback/credentials`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
cookie: cookies,
"user-agent": "SiteTestBot/1.0",
},
body: new URLSearchParams({
email: TEST_EMAIL,
password: "wrongpassword999",
csrfToken,
callbackUrl: "/wallet",
json: "true",
}).toString(),
redirect: "manual",
});
// NextAuth redirects to error page on bad creds — 302 to /api/auth/error or /login?error=
const badLocation = badLogin.headers.get("location") ?? "";
if (badLogin.status >= 400 || badLocation.includes("error") || badLocation.includes("Error")) {
pass("bad password rejected", `${badLogin.status}${badLocation.substring(0, 60)}`);
} else {
fail("bad password rejected", `${badLogin.status}${badLocation.substring(0, 80)}`);
}
if (!CREATED_USERNAME) {
fail("POST /api/auth/callback/credentials — username variant", "no CREATED_USERNAME from registration");
return;
}
const csrfResUser = await get("/api/auth/csrf");
let csrfTokUser = "";
try {
csrfTokUser = ((await csrfResUser.json()) as { csrfToken: string }).csrfToken;
} catch {
fail("login with username → GET /api/auth/csrf", "JSON parse failed");
return;
}
const scUser = csrfResUser.headers.get("set-cookie");
if (scUser) cookies += "; " + scUser.split(";")[0];
const userLogin = await fetch(`${BASE}/api/auth/callback/credentials`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
cookie: cookies,
"user-agent": "SiteTestBot/1.0",
},
body: new URLSearchParams({
email: CREATED_USERNAME,
password: TEST_PASS,
csrfToken: csrfTokUser,
callbackUrl: "/wallet",
json: "true",
}).toString(),
redirect: "manual",
});
const userLoc = userLogin.headers.get("location") ?? "";
if (userLogin.status === 200 || userLogin.status === 302 || userLogin.status === 303) {
pass(`POST /api/auth/callback/credentials (username: ${CREATED_USERNAME})`, `${userLogin.status}`);
} else {
fail(
"POST /api/auth/callback/credentials (username)",
`${userLogin.status}: ${userLoc.substring(0, 80)}`,
);
}
}
// ─────────────────────────────────────────
// 8. AUTHENTICATED API ENDPOINTS
// ─────────────────────────────────────────
async function testAuthenticatedApis() {
console.log("\n── 8. AUTHENTICATED API ENDPOINTS ───────────────");
const endpoints: Array<{ method: string; path: string; expectCodes: number[]; body?: unknown }> = [
{ method: "GET", path: "/api/wallet", expectCodes: [200] },
{ method: "GET", path: "/api/wallet/ledger", expectCodes: [200] },
{ method: "GET", path: "/api/wallet/donations", expectCodes: [200] },
{ method: "GET", path: "/api/rewards/catalog", expectCodes: [200] },
{ method: "GET", path: "/api/exchange/rate", expectCodes: [200] },
{ method: "GET", path: "/api/public/stats", expectCodes: [200] },
];
for (const e of endpoints) {
try {
const res = e.method === "GET"
? await get(e.path)
: await postJson(e.path, e.body ?? {});
if (e.expectCodes.includes(res.status)) {
const body = await res.text();
pass(`${e.method} ${e.path}`, `${res.status}`);
} else {
fail(`${e.method} ${e.path}`, `HTTP ${res.status} (expected one of ${e.expectCodes.join(",")})`);
}
} catch (err) {
fail(`${e.method} ${e.path}`, String(err));
}
}
}
// ─────────────────────────────────────────
// 9. API GUARD TESTS (unauthenticated should be blocked)
// ─────────────────────────────────────────
async function testApiGuards() {
console.log("\n── 9. API AUTH GUARDS ────────────────────────────");
// These require auth — call without cookies
const guarded = [
"/api/wallet",
"/api/wallet/ledger",
"/api/wallet/donations",
];
for (const path of guarded) {
const res = await fetch(`${BASE}${path}`, {
headers: { "user-agent": "SiteTestBot/1.0" },
});
if (res.status === 401 || res.status === 403 || res.status === 302) {
pass(`guard: ${path}`, `${res.status} — correctly blocked`);
} else if (res.status === 200) {
// Check if the response is empty/null (some Next.js auth returns 200 with null user)
const body = await res.text();
if (body.includes('"user":null') || body.includes("null")) {
pass(`guard: ${path}`, `200 with null session — ok`);
} else {
fail(`guard: ${path}`, `${res.status} — unguarded! body: ${body.substring(0, 80)}`);
}
} else {
fail(`guard: ${path}`, `unexpected ${res.status}`);
}
}
}
// ─────────────────────────────────────────
// 10. PRIVATE PAGE REDIRECT
// ─────────────────────────────────────────
async function testPrivatePageRedirects() {
console.log("\n── 10. PRIVATE PAGE REDIRECTS / GUEST VIEWS ───────");
// /wallet renders a guest marketing view at 200; assert that view includes a sign-in CTA.
const wallet = await fetch(`${BASE}/wallet`, {
redirect: "manual",
headers: { "user-agent": "SiteTestBot/1.0" },
});
if (wallet.status === 200) {
const body = await wallet.text();
if (/sign in/i.test(body) && /register|join|enroll|create.*account/i.test(body)) {
pass(`guest view: /wallet`, `200 with sign-in + register CTAs`);
} else {
fail(`guest view: /wallet`, `200 but missing auth CTAs`);
}
} else if (wallet.status === 302 || wallet.status === 307 || wallet.status === 308) {
pass(`redirect: /wallet`, `${wallet.headers.get("location") ?? ""}`);
} else {
fail(`redirect or guest view: /wallet`, `unexpected ${wallet.status}`);
}
// Casino subroutes are auth-gated. Confirm anonymous hit redirects to /login with callbackUrl.
for (const casinoPath of ["/casino", "/casino/crash"]) {
const casinoSub = await fetch(`${BASE}${casinoPath}`, {
redirect: "manual",
headers: { "user-agent": "SiteTestBot/1.0" },
});
const loc = casinoSub.headers.get("location") ?? "";
if (casinoSub.status >= 300 && casinoSub.status < 400) {
if (loc.includes("callbackUrl") && loc.includes("casino")) {
pass(`redirect: ${casinoPath}`, `${casinoSub.status}${loc} (callbackUrl preserved)`);
} else {
fail(`redirect: ${casinoPath}`, `redirected but missing callbackUrl: ${loc}`);
}
} else {
fail(`redirect: ${casinoPath}`, `expected 3xx, got ${casinoSub.status}`);
}
}
}
// ─────────────────────────────────────────
// 11. CHECKOUT SESSION API (primary Stripe path)
// ─────────────────────────────────────────
async function testPaymentIntent() {
console.log("\n── 11. CHECKOUT SESSION API ──────────────────────");
const tiers = [500, 1000, 2000, 10000]; // cents
for (const amount of tiers) {
const res = await postJson("/api/stripe/create-checkout-session", { amountUsdCents: amount });
if (res.status === 200) {
const body = await res.json().catch(() => ({})) as Record<string, unknown>;
if (body.clientSecret && body.sessionId) pass(`checkout-session $${amount / 100}`, "clientSecret + sessionId returned");
else fail(`checkout-session $${amount / 100}`, `missing fields: ${JSON.stringify(Object.keys(body))}`);
} else if (res.status === 401 || res.status === 403) {
pass(`checkout-session $${amount / 100}`, `${res.status} — requires auth (expected)`);
} else {
const text = await res.text().catch(() => "");
fail(`checkout-session $${amount / 100}`, `${res.status}: ${text.substring(0, 100)}`);
}
}
// Verify bad tier is rejected
const bad = await postJson("/api/stripe/create-checkout-session", { amountUsdCents: 777 });
if (bad.status === 400) {
pass("checkout-session: bad tier rejected", "400");
} else {
fail("checkout-session: bad tier rejected", `expected 400, got ${bad.status}`);
}
// Session status retrieval (GET) with invalid id returns 404
const statusRes = await get("/api/stripe/create-checkout-session?session_id=cs_test_invalid_id");
if (statusRes.status === 404 || statusRes.status === 200) {
pass("checkout-session GET status endpoint", `${statusRes.status}`);
} else {
fail("checkout-session GET status endpoint", `unexpected ${statusRes.status}`);
}
// Legacy payment-intent route still works for the home widget
const legacyRes = await postJson("/api/stripe/create-payment-intent", { amountUsdCents: 1000 });
if (legacyRes.status === 200) {
const body = await legacyRes.json().catch(() => ({})) as Record<string, unknown>;
if (body.clientSecret) pass("legacy payment-intent $10", "clientSecret returned");
else fail("legacy payment-intent $10", "no clientSecret");
} else if (legacyRes.status === 401 || legacyRes.status === 403) {
pass("legacy payment-intent $10", `${legacyRes.status} — requires auth (expected)`);
} else {
const text = await legacyRes.text().catch(() => "");
fail("legacy payment-intent $10", `${legacyRes.status}: ${text.substring(0, 100)}`);
}
}
// ─────────────────────────────────────────
// 12. EXCHANGE RATE API
// ─────────────────────────────────────────
async function testExchangeRate() {
console.log("\n── 12. EXCHANGE RATE API ─────────────────────────");
const res = await get("/api/exchange/rate");
if (res.status !== 200) { fail("GET /api/exchange/rate", `${res.status}`); return; }
const body = await res.json().catch(() => null) as Record<string, unknown> | null;
if (!body) { fail("exchange rate: JSON parse", "no body"); return; }
// API returns blwUsd (or mtkUsd alias) for the BLW/BWT exchange rate
const rate = (body.blwUsd ?? body.mtkUsd ?? body.usdPerBlw) as number | undefined;
if (typeof rate === "number" && rate > 0) {
pass("exchange rate: blwUsd", `${rate}`);
} else {
fail("exchange rate: blwUsd", `got: ${JSON.stringify(body)}`);
}
}
// ─────────────────────────────────────────
// 13. PUBLIC STATS API
// ─────────────────────────────────────────
async function testPublicStats() {
console.log("\n── 13. PUBLIC STATS API ──────────────────────────");
const res = await get("/api/public/stats");
if (res.status !== 200) { fail("GET /api/public/stats", `${res.status}`); return; }
const body = await res.json().catch(() => null) as Record<string, unknown> | null;
if (!body) { fail("public stats: JSON parse", "no body"); return; }
const required = ["raisedUsd", "donationCount"];
for (const key of required) {
if (key in body) pass(`public stats: ${key}`, String(body[key]));
else fail(`public stats: ${key}`, `missing from ${JSON.stringify(body)}`);
}
}
// ─────────────────────────────────────────
// 14. 404 PAGE
// ─────────────────────────────────────────
async function test404() {
console.log("\n── 14. 404 HANDLING ──────────────────────────────");
const paths = ["/does-not-exist", "/api/does-not-exist", "/raised/subpage"];
for (const path of paths) {
const res = await get(path);
if (res.status === 404) pass(`404: ${path}`);
else if (res.status === 200 && path.startsWith("/api")) pass(`404 (api returns 200): ${path}`, "ok for api");
else fail(`404: ${path}`, `got ${res.status}`);
}
}
// ─────────────────────────────────────────
// 15. SECURITY HEADERS
// ─────────────────────────────────────────
async function testSecurityHeaders() {
console.log("\n── 15. SECURITY HEADERS ──────────────────────────");
const res = await get("/");
const headers: Array<{ name: string; header: string }> = [
{ name: "X-Content-Type-Options", header: "x-content-type-options" },
{ name: "X-Frame-Options", header: "x-frame-options" },
{ name: "Referrer-Policy", header: "referrer-policy" },
];
for (const h of headers) {
const val = res.headers.get(h.header);
if (val) pass(`header: ${h.name}`, val);
else fail(`header: ${h.name}`, "missing");
}
}
// ─────────────────────────────────────────
// 16. FORGOT PASSWORD PAGE
// ─────────────────────────────────────────
async function testForgotPassword() {
console.log("\n── 16. FORGOT PASSWORD PAGE ──────────────────────");
const res = await get("/forgot-password");
if (res.status !== 200) { fail("GET /forgot-password", `${res.status}`); return; }
const html = await res.text();
if (html.includes("forgot") || html.includes("reset") || html.includes("email") || html.includes("password")) {
pass("forgot-password: form content present");
} else {
fail("forgot-password: form content present", "page seems empty");
}
}
// ─────────────────────────────────────────
// MAIN
// ─────────────────────────────────────────
async function main() {
console.log("═══════════════════════════════════════════════");
console.log(" Democracy Rising — Full Site Test Suite");
console.log(` Target: ${BASE}`);
console.log(` Test user: ${TEST_EMAIL}`);
console.log("═══════════════════════════════════════════════");
await testPublicRoutes();
await testSeoMeta();
await testLinks();
await testImages();
await testSpacing();
await testRegistration();
await testLogin();
await testAuthenticatedApis();
await testApiGuards();
await testPrivatePageRedirects();
await testPaymentIntent();
await testExchangeRate();
await testPublicStats();
await test404();
await testSecurityHeaders();
await testForgotPassword();
// ── SUMMARY ──────────────────────────────────
console.log("\n═══════════════════════════════════════════════");
console.log(" RESULTS SUMMARY");
console.log("═══════════════════════════════════════════════");
const passed = results.filter((r) => r.pass);
const failed = results.filter((r) => !r.pass);
console.log(` ✅ Passed : ${passed.length}`);
console.log(` ❌ Failed : ${failed.length}`);
console.log(` 📋 Total : ${results.length}`);
if (failed.length > 0) {
console.log("\n FAILURES:");
for (const f of failed) {
console.error(`${f.name}${f.detail}`);
}
}
console.log("\n═══════════════════════════════════════════════");
process.exit(failed.length > 0 ? 1 : 0);
}
main().catch((e) => {
console.error("Test runner crashed:", e);
process.exit(2);
});

View File

@@ -0,0 +1,90 @@
import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@prisma/client";
import { Pool } from "pg";
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
// Pre-seeded founding donors — display names + amounts in USD cents
const FOUNDING_DONORS: { name: string; email: string; amountsCents: number[] }[] = [
{ name: "Margaret T.", email: "ghost.margaret@leaderboard.local", amountsCents: [10000, 10000, 10000, 10000, 10000] },
{ name: "James R.", email: "ghost.james@leaderboard.local", amountsCents: [10000, 10000, 10000, 10000] },
{ name: "Sophia K.", email: "ghost.sophia@leaderboard.local", amountsCents: [10000, 10000, 10000, 2000] },
{ name: "David M.", email: "ghost.david@leaderboard.local", amountsCents: [10000, 10000, 10000] },
{ name: "Priya N.", email: "ghost.priya@leaderboard.local", amountsCents: [10000, 10000, 2000] },
{ name: "Carlos V.", email: "ghost.carlos@leaderboard.local", amountsCents: [10000, 10000, 1000] },
{ name: "Elena B.", email: "ghost.elena@leaderboard.local", amountsCents: [10000, 2000, 2000, 500] },
{ name: "Thomas W.", email: "ghost.thomas@leaderboard.local", amountsCents: [10000, 2000, 500] },
{ name: "Aisha F.", email: "ghost.aisha@leaderboard.local", amountsCents: [2000, 2000, 2000, 1000] },
{ name: "Noah P.", email: "ghost.noah@leaderboard.local", amountsCents: [2000, 2000, 2000] },
{ name: "Grace L.", email: "ghost.grace@leaderboard.local", amountsCents: [2000, 2000, 1000] },
{ name: "Ryan H.", email: "ghost.ryan@leaderboard.local", amountsCents: [2000, 2000, 500] },
{ name: "Isabella C.", email: "ghost.isabella@leaderboard.local", amountsCents: [2000, 1000, 500] },
{ name: "Marcus J.", email: "ghost.marcus@leaderboard.local", amountsCents: [2000, 1000] },
{ name: "Fatima A.", email: "ghost.fatima@leaderboard.local", amountsCents: [1000, 1000, 1000] },
{ name: "Liam O.", email: "ghost.liam@leaderboard.local", amountsCents: [1000, 1000, 500] },
{ name: "Nina S.", email: "ghost.nina@leaderboard.local", amountsCents: [1000, 1000] },
{ name: "Derek U.", email: "ghost.derek@leaderboard.local", amountsCents: [1000, 500, 500] },
{ name: "Amara D.", email: "ghost.amara@leaderboard.local", amountsCents: [1000, 500] },
{ name: "Kevin T.", email: "ghost.kevin@leaderboard.local", amountsCents: [500, 500, 500] },
];
async function main() {
let totalCreated = 0;
for (const donor of FOUNDING_DONORS) {
const leaderboardUsername = donor.email
.trim()
.toLowerCase()
.replace(/[@.+-]/g, "_")
.replace(/[^a-z0-9_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_|_$/g, "")
.slice(0, 32);
const user = await prisma.user.upsert({
where: { email: donor.email },
update: { name: donor.name, username: leaderboardUsername },
create: {
email: donor.email,
username: leaderboardUsername,
name: donor.name,
passwordHash: "ghost-no-login",
},
});
await prisma.wallet.upsert({
where: { userId: user.id },
update: {},
create: { userId: user.id, balanceCredits: 0 },
});
// Add donations, spread over past 90 days
const total = donor.amountsCents.length;
for (let i = 0; i < total; i++) {
const amountCents = donor.amountsCents[i];
const daysAgo = Math.floor((i / total) * 90) + Math.floor(Math.random() * 5);
const createdAt = new Date(Date.now() - daysAgo * 86_400_000);
const piId = `ghost_pi_${user.id.slice(0, 8)}_${i}`;
await prisma.donation.upsert({
where: { stripePaymentIntentId: piId },
update: {},
create: {
stripePaymentIntentId: piId,
userId: user.id,
amountUsdCents: amountCents,
creditsAwarded: 0,
status: "succeeded",
createdAt,
},
});
totalCreated++;
}
}
console.log(`Seeded ${FOUNDING_DONORS.length} ghost donors with ${totalCreated} donations.`);
}
main()
.then(() => prisma.$disconnect().then(() => pool.end()))
.catch(e => { console.error(e); process.exit(1); });

228
server.ts Normal file
View File

@@ -0,0 +1,228 @@
import "dotenv/config";
import { createServer } from "http";
import { parse } from "url";
import next from "next";
import { Server as SocketIOServer } from "socket.io";
import { prisma } from "./src/lib/prisma";
import { debitForBet, creditForWin, refundBet } from "./src/lib/game-ledger";
import { generateServerSeed, deriveInt } from "./src/lib/provably-fair";
const dev = process.env.NODE_ENV !== "production";
const app = next({ dev, hostname: "0.0.0.0", port: 8008 });
const handle = app.getRequestHandler();
const HOUSE_CUT = 0.05; // 5% on PvP
app.prepare().then(() => {
const httpServer = createServer((req, res) => {
const parsedUrl = parse(req.url ?? "/", true);
handle(req, res, parsedUrl);
});
const io = new SocketIOServer(httpServer, {
cors: { origin: "*" },
path: "/api/socket",
});
// ── COIN FLIP ──────────────────────────────────────────────────────────────
const coinFlipNS = io.of("/coin-flip");
coinFlipNS.on("connection", (socket) => {
socket.on("join_room", async ({ roomId, userId }: { roomId: string; userId: string }) => {
const room = await prisma.gameRoom.findUnique({ where: { id: roomId } });
if (!room || room.status !== "WAITING") {
socket.emit("error", "Room not available");
return;
}
if (room.creatorId === userId) {
socket.join(roomId);
socket.emit("waiting", { roomId });
return;
}
// Check balance
const wallet = await prisma.wallet.findUnique({ where: { userId } });
if (!wallet || wallet.balanceCredits < room.wageBLW) {
socket.emit("error", "Insufficient balance");
return;
}
// Debit joiner
try {
await debitForBet(userId, room.wageBLW, "COIN_FLIP");
} catch {
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");
socket.emit("error", "Creator debit failed");
return;
}
await prisma.gameRoom.update({
where: { id: roomId },
data: { joinerId: userId, status: "ACTIVE" },
});
socket.join(roomId);
coinFlipNS.to(roomId).emit("game_start", { roomId });
// Resolve
const serverSeed = generateServerSeed();
const result = deriveInt(serverSeed, roomId, 0, 0, 1); // 0 = heads, 1 = tails
const winnerId = result === 0 ? room.creatorId : userId;
const pot = room.wageBLW * 2;
const payout = Math.floor(pot * (1 - HOUSE_CUT));
await creditForWin(winnerId, payout, "COIN_FLIP");
await prisma.gameRoom.update({
where: { id: roomId },
data: {
status: "RESOLVED",
resultData: { result, winnerId, serverSeed, payout },
},
});
coinFlipNS.to(roomId).emit("result", {
result,
resultLabel: result === 0 ? "heads" : "tails",
winnerId,
payout,
serverSeed,
});
});
});
// ── PONG ───────────────────────────────────────────────────────────────────
const pongNS = io.of("/pong");
const pongRooms = new Map<string, {
roomId: string;
creatorId: string;
joinerId: string | null;
wageBLW: number;
ball: { x: number; y: number; vx: number; vy: number };
paddles: { creator: number; joiner: number };
scores: { creator: number; joiner: number };
interval: ReturnType<typeof setInterval> | null;
}>();
pongNS.on("connection", (socket) => {
socket.on("join_room", async ({ roomId, userId }: { roomId: string; userId: string }) => {
const room = await prisma.gameRoom.findUnique({ where: { id: roomId } });
if (!room || !["WAITING", "ACTIVE"].includes(room.status)) {
socket.emit("error", "Room not available");
return;
}
socket.join(roomId);
if (room.creatorId === userId) {
if (!pongRooms.has(roomId)) {
pongRooms.set(roomId, {
roomId,
creatorId: userId,
joinerId: null,
wageBLW: room.wageBLW,
ball: { x: 400, y: 200, vx: 3, vy: 2 },
paddles: { creator: 180, joiner: 180 },
scores: { creator: 0, joiner: 0 },
interval: null,
});
}
socket.emit("waiting", { roomId });
return;
}
// Joiner
const state = pongRooms.get(roomId);
if (!state || state.joinerId) { socket.emit("error", "Room full"); return; }
const wallet = await prisma.wallet.findUnique({ where: { userId } });
if (!wallet || wallet.balanceCredits < room.wageBLW) { socket.emit("error", "Insufficient balance"); return; }
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;
}
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
state.interval = setInterval(() => {
const s = pongRooms.get(roomId);
if (!s) return;
s.ball.x += s.ball.vx;
s.ball.y += s.ball.vy;
// Wall bounce
if (s.ball.y <= 0 || s.ball.y >= 400) s.ball.vy *= -1;
// Paddle bounce
if (s.ball.x <= 20 && Math.abs(s.ball.y - s.paddles.creator) < 50) s.ball.vx = Math.abs(s.ball.vx);
if (s.ball.x >= 780 && Math.abs(s.ball.y - s.paddles.joiner) < 50) s.ball.vx = -Math.abs(s.ball.vx);
// Score
if (s.ball.x <= 0) {
s.scores.joiner++;
s.ball = { x: 400, y: 200, vx: 3, vy: 2 };
}
if (s.ball.x >= 800) {
s.scores.creator++;
s.ball = { x: 400, y: 200, vx: -3, vy: 2 };
}
pongNS.to(roomId).emit("tick", { ball: s.ball, paddles: s.paddles, scores: s.scores });
// Win condition: first to 5
if (s.scores.creator >= 5 || s.scores.joiner >= 5) {
clearInterval(s.interval!);
s.interval = null;
const winnerId = s.scores.creator >= 5 ? s.creatorId : s.joinerId!;
const pot = s.wageBLW * 2;
const payout = Math.floor(pot * (1 - HOUSE_CUT));
creditForWin(winnerId, payout, "PONG").then(() => {
prisma.gameRoom.update({
where: { id: roomId },
data: { status: "RESOLVED", resultData: { winnerId, scores: s.scores, payout } },
}).catch(() => {});
}).catch(() => {});
pongNS.to(roomId).emit("game_over", { winnerId, scores: s.scores, payout });
pongRooms.delete(roomId);
}
}, 50);
});
socket.on("paddle_move", ({ roomId, userId, y }: { roomId: string; userId: string; y: number }) => {
const state = pongRooms.get(roomId);
if (!state) 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({
where: { status: "WAITING", expiresAt: { lt: new Date() } },
data: { status: "EXPIRED" },
});
}, 60_000);
httpServer.listen(8008, "0.0.0.0", () => {
console.log(`> Ready on http://0.0.0.0:8008`);
});
});

View File

@@ -0,0 +1,96 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { isAdminRole } from "@/lib/admin";
import { creditDisplayName } from "@/lib/credits-brand";
import { prisma } from "@/lib/prisma";
import { debitWalletCredits, INSUFFICIENT_CREDITS } from "@/lib/wallet-safety";
// Cost tiers: 10 BWT = 6h, 25 BWT = 18h, 50 BWT = 36h, 100 BWT = 72h
const TIERS = [
{ cost: 10, hours: 6 },
{ cost: 25, hours: 18 },
{ cost: 50, hours: 36 },
{ cost: 100, hours: 72 },
] as const;
const postSchema = z.object({
message: z.string().min(3).max(140),
creditsSpent: z.number().int().refine(
(n) => TIERS.some((t) => t.cost === n),
{ message: "Must be one of: 10, 25, 50, or 100 credits." }
),
});
export async function GET() {
const now = new Date();
const messages = await prisma.billboardMessage.findMany({
where: { expiresAt: { gt: now } },
orderBy: [{ creditsSpent: "desc" }, { createdAt: "desc" }],
select: {
id: true,
displayName: true,
message: true,
creditsSpent: true,
expiresAt: true,
createdAt: true,
},
take: 50,
});
return NextResponse.json({ messages, tiers: TIERS });
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Sign in to post to the Billboard." }, { status: 401 });
}
const admin = isAdminRole(session.user.role);
const creditName = creditDisplayName();
try {
const json = await req.json();
const { message, creditsSpent } = postSchema.parse(json);
const tier = TIERS.find((t) => t.cost === creditsSpent)!;
const spent = admin ? 0 : creditsSpent;
const expiresAt = new Date(Date.now() + tier.hours * 3_600_000);
const displayName = session.user.name ?? session.user.email?.split("@")[0] ?? "Supporter";
await prisma.$transaction(async (tx) => {
const msg = await tx.billboardMessage.create({
data: {
userId: session.user.id,
displayName,
message: message.trim(),
creditsSpent: spent,
expiresAt,
},
});
if (!admin) {
await debitWalletCredits(tx, session.user.id, creditsSpent);
await tx.ledgerEntry.create({
data: {
userId: session.user.id,
delta: -creditsSpent,
type: "DEBIT_BILLBOARD",
billboardMessageId: msg.id,
memo: `Billboard post: ${tier.hours}h`,
},
});
}
});
return NextResponse.json({ ok: true, expiresAt, hours: tier.hours, spent });
} catch (e) {
if (e instanceof z.ZodError) return NextResponse.json({ error: "Invalid input", issues: e.issues }, { status: 400 });
if (e instanceof Error && e.message === INSUFFICIENT_CREDITS) {
return NextResponse.json({ error: `Not enough ${creditName}.` }, { status: 402 });
}
console.error(e);
return NextResponse.json({ error: "Could not post message." }, { status: 500 });
}
}

179
src/app/api/boost/route.ts Normal file
View File

@@ -0,0 +1,179 @@
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { z } from "zod";
import { auth } from "@/auth";
import { isAdminRole } from "@/lib/admin";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import { prisma } from "@/lib/prisma";
import { debitWalletCredits, INSUFFICIENT_CREDITS } from "@/lib/wallet-safety";
const METER_TARGET = 5000; // BWT needed to fill the meter
const MILESTONE_BONUS_PCT = 0.15; // 15% bonus back to contributors when meter fills
const boostSchema = z.object({
creditsSpent: z.number().int().min(5).max(1000),
});
export async function GET() {
// Aggregate current epoch
const agg = await prisma.movementBoost.aggregate({
where: { epochId: await currentEpoch() },
_sum: { creditsSpent: true },
_count: true,
});
const total = agg._sum.creditsSpent ?? 0;
const pct = Math.min(100, Math.round((total / METER_TARGET) * 100));
const filled = pct >= 100;
const epoch = await currentEpoch();
// Top contributors this epoch
const topContributors = await prisma.movementBoost.groupBy({
by: ["userId"],
where: { epochId: epoch },
_sum: { creditsSpent: true },
orderBy: { _sum: { creditsSpent: "desc" } },
take: 10,
});
const topWithNames = await Promise.all(
topContributors.map(async (c) => {
const user = await prisma.user.findUnique({
where: { id: c.userId },
select: { name: true },
});
return { name: user?.name ?? "Supporter", total: c._sum.creditsSpent ?? 0 };
})
);
return NextResponse.json({
epoch,
total,
target: METER_TARGET,
pct,
filled,
contributors: agg._count,
topContributors: topWithNames,
milestoneBonus: `${Math.round(MILESTONE_BONUS_PCT * 100)}%`,
});
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Sign in to power the movement." }, { status: 401 });
}
const admin = isAdminRole(session.user.role);
const creditName = creditDisplayName();
const ticker = creditTicker();
const userId = session.user.id;
try {
const json = await req.json();
const { creditsSpent } = boostSchema.parse(json);
const spent = admin ? 0 : creditsSpent;
const epoch = await currentEpoch();
await prisma.$transaction(async (tx) => {
const beforeAgg = await tx.movementBoost.aggregate({
where: { epochId: epoch },
_sum: { creditsSpent: true },
});
const totalBefore = beforeAgg._sum.creditsSpent ?? 0;
const boost = await tx.movementBoost.create({
data: { userId, creditsSpent: spent, epochId: epoch },
});
if (!admin) {
await debitWalletCredits(tx, userId, creditsSpent);
await tx.ledgerEntry.create({
data: {
userId,
delta: -creditsSpent,
type: "DEBIT_BOOST",
movementBoostId: boost.id,
memo: `Movement boost: ${creditsSpent} ${ticker}`,
},
});
}
// Check if meter just filled — award bonuses to all contributors
const agg = await tx.movementBoost.aggregate({
where: { epochId: epoch },
_sum: { creditsSpent: true },
});
const totalAfter = agg._sum.creditsSpent ?? 0;
if (totalBefore < METER_TARGET && totalAfter >= METER_TARGET) {
// Award proportional bonuses to all contributors of this epoch
const contributors = await tx.movementBoost.groupBy({
by: ["userId"],
where: { epochId: epoch },
_sum: { creditsSpent: true },
});
for (const contrib of contributors) {
const bonus = Math.floor((contrib._sum.creditsSpent ?? 0) * MILESTONE_BONUS_PCT);
if (bonus < 1) continue;
await tx.wallet.upsert({
where: { userId: contrib.userId },
update: { balanceCredits: { increment: bonus } },
create: { userId: contrib.userId, balanceCredits: bonus },
});
await tx.ledgerEntry.create({
data: {
userId: contrib.userId,
delta: bonus,
type: "ADJUSTMENT",
memo: `Milestone bonus — epoch ${epoch} completed (+${Math.round(MILESTONE_BONUS_PCT * 100)}%)`,
},
});
}
}
}, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable });
const agg = await prisma.movementBoost.aggregate({
where: { epochId: epoch },
_sum: { creditsSpent: true },
});
const newTotal = agg._sum.creditsSpent ?? 0;
const newPct = Math.min(100, Math.round((newTotal / METER_TARGET) * 100));
return NextResponse.json({
ok: true,
spent,
newTotal,
newPct,
filled: newPct >= 100,
target: METER_TARGET,
});
} catch (e) {
if (e instanceof z.ZodError) return NextResponse.json({ error: "Invalid input", issues: e.issues }, { status: 400 });
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2034") {
return NextResponse.json({ error: "Concurrent boost detected. Please try again." }, { status: 409 });
}
if (e instanceof Error && e.message === INSUFFICIENT_CREDITS) {
return NextResponse.json({ error: `Not enough ${creditName}.` }, { status: 402 });
}
console.error(e);
return NextResponse.json({ error: "Could not boost." }, { status: 500 });
}
}
async function currentEpoch(): Promise<number> {
const last = await prisma.movementBoost.findFirst({
orderBy: { epochId: "desc" },
select: { epochId: true },
});
const epochId = last?.epochId ?? 1;
const total = await prisma.movementBoost.aggregate({
where: { epochId },
_sum: { creditsSpent: true },
});
// If current epoch is filled, next boost starts a new epoch
return (total._sum.creditsSpent ?? 0) >= METER_TARGET ? epochId + 1 : epochId;
}

120
src/app/api/cards/route.ts Normal file
View File

@@ -0,0 +1,120 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { isAdminRole } from "@/lib/admin";
import { creditDisplayName } from "@/lib/credits-brand";
import { prisma } from "@/lib/prisma";
import { debitWalletCredits, INSUFFICIENT_CREDITS } from "@/lib/wallet-safety";
// Card tier costs and thresholds (based on lifetime BWT earned)
const CARD_TIERS = [
{ tier: 1, label: "Supporter", cost: 20, color: "#38bdf8" },
{ tier: 2, label: "Champion", cost: 50, color: "#818cf8" },
{ tier: 3, label: "Legend", cost: 100, color: "#f59e0b" },
] as const;
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const hall = searchParams.get("hall") === "1";
if (hall) {
// Public hall of champions — latest card per user
const cards = await prisma.supporterCard.findMany({
orderBy: { createdAt: "desc" },
take: 48,
select: {
id: true,
tier: true,
serialNumber: true,
statsSnapshot: true,
createdAt: true,
user: { select: { name: true } },
},
});
return NextResponse.json({ cards });
}
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Sign in to view your cards." }, { status: 401 });
}
const cards = await prisma.supporterCard.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
});
return NextResponse.json({ cards, tiers: CARD_TIERS });
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Sign in to mint a card." }, { status: 401 });
}
const admin = isAdminRole(session.user.role);
const creditName = creditDisplayName();
const userId = session.user.id;
try {
const json = await req.json().catch(() => ({}));
const wantedTier = Math.min(3, Math.max(1, Number(json.tier ?? 1)));
const tierDef = CARD_TIERS.find((t) => t.tier === wantedTier) ?? CARD_TIERS[0];
const cost = tierDef.cost;
const spent = admin ? 0 : cost;
const [wallet, donations, totalCards] = await Promise.all([
prisma.wallet.findUnique({ where: { userId } }),
prisma.donation.aggregate({
where: { userId },
_sum: { amountUsdCents: true, creditsAwarded: true },
_count: true,
}),
prisma.supporterCard.count({ where: { userId } }),
]);
const statsSnapshot = {
totalDonatedUsd: (donations._sum.amountUsdCents ?? 0) / 100,
creditsEarned: donations._sum.creditsAwarded ?? 0,
donationCount: donations._count,
currentBalance: admin ? "∞" : (wallet?.balanceCredits ?? 0),
cardNumber: totalCards + 1,
mintedAt: new Date().toISOString(),
tier: tierDef.tier,
tierLabel: tierDef.label,
};
await prisma.$transaction(async (tx) => {
const card = await tx.supporterCard.create({
data: {
userId,
tier: tierDef.tier,
serialNumber: totalCards + 1,
creditsSpent: spent,
statsSnapshot,
},
});
if (!admin) {
await debitWalletCredits(tx, userId, cost);
await tx.ledgerEntry.create({
data: {
userId,
delta: -cost,
type: "DEBIT_CARD_MINT",
supporterCardId: card.id,
memo: `Card mint: ${tierDef.label} #${totalCards + 1}`,
},
});
}
});
return NextResponse.json({ ok: true, tier: tierDef, statsSnapshot, spent });
} catch (e) {
if (e instanceof Error && e.message === INSUFFICIENT_CREDITS) {
return NextResponse.json({ error: `Not enough ${creditName}.` }, { status: 402 });
}
console.error(e);
return NextResponse.json({ error: "Could not mint card." }, { status: 500 });
}
}

View File

@@ -1,17 +1,14 @@
import { NextResponse } from "next/server";
import {
BLW_DISPLAY_NAME,
BLW_TICKER,
blwCreditsForUsdCents,
blwIndexSamples,
blwUsdAt,
} from "@/lib/exchange";
import { BLW_DISPLAY_NAME, BLW_TICKER, blwCreditsForUsdCents } from "@/lib/exchange";
import { getTreasuryTotalUsdCents } from "@/lib/treasury";
import { treasurySparklineUsdPerCredit, usdPerBwtFromTreasury } from "@/lib/treasury-math";
export const dynamic = "force-dynamic";
export async function GET() {
const now = Date.now();
const blwUsd = blwUsdAt(now);
const treasuryUsdCents = await getTreasuryTotalUsdCents();
const blwUsd = usdPerBwtFromTreasury(treasuryUsdCents);
const blwPerUsd = 1 / blwUsd;
const tiers = [500, 1000, 2000, 10000].map((tierCents) => ({
@@ -20,17 +17,18 @@ export async function GET() {
blwCreditsAtSpot: blwCreditsForUsdCents(tierCents, blwUsd),
}));
const sparkline = blwIndexSamples(48, now, 90_000).map((p) => p.blwUsd);
const sparkline = treasurySparklineUsdPerCredit(treasuryUsdCents, 48);
return NextResponse.json({
symbol: BLW_TICKER,
name: `${BLW_DISPLAY_NAME} (mock index)`,
name: `${BLW_DISPLAY_NAME} (treasury spot)`,
blwUsd,
blwPerUsd,
usdPerBlw: blwUsd,
treasuryUsdCents,
treasuryUsd: treasuryUsdCents / 100,
updatedAt: now,
note:
"Synthetic Blue Wave (BLW) index for demo UX only — not tradable cryptocurrency. Credits use the rate locked when you start checkout.",
note: `On-platform ${BLW_DISPLAY_NAME} (${BLW_TICKER}) spot rises as disclosed Stripe donations accumulate — not tradable and not cash-out. Credits for each contribution use the rate locked when checkout begins.`,
tiers,
sparkline,
});

165
src/app/api/faq/route.ts Normal file
View File

@@ -0,0 +1,165 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { isAdminRole } from "@/lib/admin";
import { creditDisplayName } from "@/lib/credits-brand";
import { prisma } from "@/lib/prisma";
import { debitWalletCredits, INSUFFICIENT_CREDITS } from "@/lib/wallet-safety";
const SUBMIT_COST = 10;
const VOTE_COST = 1;
const submitSchema = z.object({
question: z.string().min(10).max(280),
});
const voteSchema = z.object({
submissionId: z.string().cuid(),
});
const adminSchema = z.object({
submissionId: z.string().cuid(),
action: z.enum(["approve", "reject"]),
answer: z.string().max(1000).optional(),
});
export async function GET() {
const session = await auth();
const signedIn = !!session?.user?.id;
const [pending, approved] = await Promise.all([
signedIn
? prisma.faqSubmission.findMany({
where: { status: "PENDING" },
orderBy: [{ voteTotal: "desc" }, { createdAt: "desc" }],
select: {
id: true,
displayName: true,
question: true,
voteTotal: true,
creditsSpent: true,
createdAt: true,
},
take: 30,
})
: Promise.resolve([]),
prisma.faqSubmission.findMany({
where: { status: "APPROVED" },
orderBy: { voteTotal: "desc" },
select: { id: true, question: true, answer: true, voteTotal: true, createdAt: true },
}),
]);
return NextResponse.json({
pending,
approved,
submitCost: SUBMIT_COST,
voteCost: VOTE_COST,
signedIn,
});
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Sign in first." }, { status: 401 });
}
const admin = isAdminRole(session.user.role);
const creditName = creditDisplayName();
const userId = session.user.id;
try {
const json = await req.json();
const action = json.action as string | undefined;
// Admin approve/reject
if (admin && action) {
const { submissionId, action: act, answer } = adminSchema.parse(json);
await prisma.faqSubmission.update({
where: { id: submissionId },
data: {
status: act === "approve" ? "APPROVED" : "REJECTED",
answer: act === "approve" ? (answer?.trim() || null) : null,
},
});
return NextResponse.json({ ok: true, action: act });
}
// Vote
if (action === "vote") {
const { submissionId } = voteSchema.parse(json);
const spent = admin ? 0 : VOTE_COST;
await prisma.$transaction(async (tx) => {
if (!admin) {
const existing = await tx.faqVote.findUnique({
where: { submissionId_userId: { submissionId, userId } },
});
if (existing) throw new Error("ALREADY_VOTED");
}
const vote = await tx.faqVote.create({
data: { submissionId, userId, creditsSpent: spent },
});
await tx.faqSubmission.update({
where: { id: submissionId },
data: { voteTotal: { increment: 1 } },
});
if (!admin) {
await debitWalletCredits(tx, userId, VOTE_COST);
await tx.ledgerEntry.create({
data: {
userId,
delta: -VOTE_COST,
type: "DEBIT_FAQ_VOTE",
faqVoteId: vote.id,
memo: "FAQ vote",
},
});
}
});
return NextResponse.json({ ok: true, voted: true, spent });
}
// Submit new question
const { question } = submitSchema.parse(json);
const spent = admin ? 0 : SUBMIT_COST;
const displayName = session.user.name ?? session.user.email?.split("@")[0] ?? "Supporter";
await prisma.$transaction(async (tx) => {
const sub = await tx.faqSubmission.create({
data: { userId, displayName, question: question.trim(), creditsSpent: spent },
});
if (!admin) {
await debitWalletCredits(tx, userId, SUBMIT_COST);
await tx.ledgerEntry.create({
data: {
userId,
delta: -SUBMIT_COST,
type: "DEBIT_FAQ_SUBMIT",
faqSubmissionId: sub.id,
memo: "FAQ question submission",
},
});
}
});
return NextResponse.json({ ok: true, submitted: true, spent });
} catch (e) {
if (e instanceof z.ZodError) return NextResponse.json({ error: "Invalid input", issues: e.issues }, { status: 400 });
if (e instanceof Error && e.message === INSUFFICIENT_CREDITS) {
return NextResponse.json({ error: `Not enough ${creditName}.` }, { status: 402 });
}
if (e instanceof Error && e.message === "ALREADY_VOTED") {
return NextResponse.json({ error: "You already voted on this question." }, { status: 409 });
}
console.error(e);
return NextResponse.json({ error: "Could not process request." }, { status: 500 });
}
}

View File

@@ -0,0 +1,193 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { debitForBet, creditForWin, refundBet } from "@/lib/game-ledger";
import { generateServerSeed, deriveInt } from "@/lib/provably-fair";
export const dynamic = "force-dynamic";
const CARD_VALUES: Record<string, number> = {
"A": 11, "2": 2, "3": 3, "4": 4, "5": 5, "6": 6, "7": 7, "8": 8,
"9": 9, "10": 10, "J": 10, "Q": 10, "K": 10,
};
const RANKS = ["A","2","3","4","5","6","7","8","9","10","J","Q","K"];
const SUITS = ["♠","♥","♦","♣"];
function newDeck(): string[] {
return SUITS.flatMap(s => RANKS.map(r => `${r}${s}`));
}
function shuffleDeck(seed: string, cs: string): string[] {
const deck = newDeck();
for (let i = deck.length - 1; i > 0; i--) {
const j = deriveInt(seed, cs, i, 0, i);
[deck[i], deck[j]] = [deck[j], deck[i]];
}
return deck;
}
function handValue(cards: string[]): number {
let val = 0;
let aces = 0;
for (const c of cards) {
const rank = c.slice(0, -1);
const v = CARD_VALUES[rank] ?? 10;
if (rank === "A") aces++;
val += v;
}
while (val > 21 && aces > 0) { val -= 10; aces--; }
return val;
}
// POST — start game
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const { wageBLW, clientSeed } = body as { wageBLW: number; clientSeed?: string };
if (!Number.isInteger(wageBLW) || wageBLW < 1) return NextResponse.json({ error: "Invalid wager" }, { status: 400 });
const serverSeed = generateServerSeed();
const cs = clientSeed ?? "default";
const deck = shuffleDeck(serverSeed, cs);
const playerHand = [deck[0], deck[2]];
const dealerHand = [deck[1], deck[3]];
let deckIdx = 4;
try {
await debitForBet(session.user.id, wageBLW, "BLACKJACK");
} catch {
return NextResponse.json({ error: "Insufficient balance" }, { status: 402 });
}
const playerVal = handValue(playerHand);
let outcome = "active";
if (playerVal === 21) {
// Natural blackjack — check dealer
const dealerVal = handValue(dealerHand);
if (dealerVal === 21) {
outcome = "push";
} else {
outcome = "blackjack";
}
}
const gs = await prisma.gameSession.create({
data: {
userId: session.user.id,
gameType: "BLACKJACK",
wageredBLW: wageBLW,
outcome,
serverSeed,
clientSeed: cs,
resultData: { deck, playerHand, dealerHand, deckIdx },
},
});
if (outcome === "blackjack") {
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 });
}
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]], outcome: "active", playerVal });
}
// PATCH — hit, stand, or double
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, action } = body as { roundId: string; action: "hit" | "stand" | "double" };
const gs = await prisma.gameSession.findUnique({ where: { id: roundId } });
if (!gs || gs.userId !== session.user.id || gs.outcome !== "active") {
return NextResponse.json({ error: "Round not found or settled" }, { status: 404 });
}
const data = gs.resultData as { deck: string[]; playerHand: string[]; dealerHand: string[]; deckIdx: number };
let { deck, playerHand, dealerHand, deckIdx } = data;
if (action === "double") {
// Extra bet
try {
await debitForBet(session.user.id, gs.wageredBLW, "BLACKJACK");
} catch {
return NextResponse.json({ error: "Insufficient balance for double" }, { status: 402 });
}
playerHand = [...playerHand, deck[deckIdx++]];
// Must stand after double — fall through to dealer resolution
return resolveStand(gs.id, session.user.id, deck, playerHand, dealerHand, deckIdx, gs.wageredBLW * 2, prisma, creditForWin, refundBet);
}
if (action === "hit") {
playerHand = [...playerHand, deck[deckIdx++]];
const val = handValue(playerHand);
if (val > 21) {
await prisma.gameSession.update({
where: { id: roundId },
data: { outcome: "loss", multiplier: 0, payoutBLW: 0, resultData: { deck, playerHand, dealerHand, deckIdx } },
});
return NextResponse.json({ outcome: "loss", playerHand, playerVal: val, dealerHand });
}
if (val === 21) {
return resolveStand(gs.id, session.user.id, deck, playerHand, dealerHand, deckIdx, gs.wageredBLW, prisma, creditForWin, refundBet);
}
await prisma.gameSession.update({ where: { id: roundId }, data: { resultData: { deck, playerHand, dealerHand, deckIdx } } });
return NextResponse.json({ outcome: "active", playerHand, playerVal: val, dealerVisible: [dealerHand[0]] });
}
// stand
return resolveStand(gs.id, session.user.id, deck, playerHand, dealerHand, deckIdx, gs.wageredBLW, prisma, creditForWin, refundBet);
}
async function resolveStand(
roundId: string, userId: string, deck: string[], playerHand: string[], dealerHand: string[],
deckIdx: number, wager: number, db: typeof prisma,
credit: typeof creditForWin, refund: typeof refundBet
): Promise<NextResponse> {
// Dealer draws to 17
while (handValue(dealerHand) < 17) {
dealerHand = [...dealerHand, deck[deckIdx++]];
}
const pv = handValue(playerHand);
const dv = handValue(dealerHand);
let outcome: string;
let payout = 0;
let multiplier = 0;
if (dv > 21 || pv > dv) {
outcome = "win";
payout = wager * 2;
multiplier = 2;
await credit(userId, payout, "BLACKJACK");
} else if (pv === dv) {
outcome = "push";
payout = wager;
multiplier = 1;
await refund(userId, wager, "BLACKJACK");
} else {
outcome = "loss";
}
await db.gameSession.update({
where: { id: roundId },
data: { outcome, multiplier, payoutBLW: payout, resultData: { deck, playerHand, dealerHand, deckIdx } },
});
return NextResponse.json({ outcome, playerHand, dealerHand, playerVal: pv, dealerVal: dv, payout });
}

View File

@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { debitForBet } from "@/lib/game-ledger";
import { generateServerSeed, hashServerSeed, deriveCrashPoint } from "@/lib/provably-fair";
import { creditWalletCredits } from "@/lib/wallet-safety";
export const dynamic = "force-dynamic";
// POST /api/games/crash — start a crash round, returns serverSeedHash + roundId
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const { wageBLW, clientSeed } = body as { wageBLW: number; clientSeed?: string };
if (!Number.isInteger(wageBLW) || wageBLW < 1) {
return NextResponse.json({ error: "Invalid wager" }, { status: 400 });
}
const serverSeed = generateServerSeed();
const seedHash = hashServerSeed(serverSeed);
const cs = clientSeed ?? "default";
const crashAt = deriveCrashPoint(serverSeed, cs, 0);
try {
await debitForBet(session.user.id, wageBLW, "CRASH");
} catch {
return NextResponse.json({ error: "Insufficient balance" }, { status: 402 });
}
const gs = await prisma.gameSession.create({
data: {
userId: session.user.id,
gameType: "CRASH",
wageredBLW: wageBLW,
outcome: "active",
serverSeed,
clientSeed: cs,
resultData: { crashAt, cashedOutAt: null },
},
});
return NextResponse.json({ roundId: gs.id, serverSeedHash: seedHash, crashAt });
}
// PATCH /api/games/crash — cash out at current multiplier
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 gs = await prisma.gameSession.findUnique({ where: { id: roundId } });
if (!gs || gs.userId !== session.user.id || gs.outcome !== "active") {
return NextResponse.json({ error: "Round not found or already settled" }, { status: 404 });
}
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 multiplier = Math.max(1.0, cashoutAt);
const payout = Math.floor(gs.wageredBLW * multiplier);
const settled = await prisma.$transaction(async (tx) => {
const updated = await tx.gameSession.updateMany({
where: { id: roundId, userId: session.user.id, outcome: "active" },
data: { outcome: "cashout", multiplier, payoutBLW: payout },
});
if (updated.count !== 1) return false;
await creditWalletCredits(tx, session.user.id, payout);
await tx.ledgerEntry.create({
data: {
userId: session.user.id,
delta: payout,
type: "CREDIT_GAME_WIN",
memo: "CRASH payout",
},
});
return true;
});
if (!settled) {
return NextResponse.json({ error: "Round not found or already settled" }, { status: 404 });
}
return NextResponse.json({
outcome: "cashout",
multiplier,
payout,
serverSeed: gs.serverSeed,
crashAt,
});
}

View File

@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { generateServerSeed, hashServerSeed, deriveInt } from "@/lib/provably-fair";
import { creditWalletCredits, debitWalletCredits, INSUFFICIENT_CREDITS } from "@/lib/wallet-safety";
export const dynamic = "force-dynamic";
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const { wageBLW, clientSeed, threshold, direction } = body as {
wageBLW: number;
clientSeed?: string;
threshold: number;
direction: "over" | "under";
};
if (!Number.isInteger(wageBLW) || wageBLW < 1) return NextResponse.json({ error: "Invalid wager" }, { status: 400 });
if (!Number.isInteger(threshold) || threshold < 2 || threshold > 98) return NextResponse.json({ error: "Threshold must be 2-98" }, { status: 400 });
if (direction !== "over" && direction !== "under") return NextResponse.json({ error: "Invalid direction" }, { status: 400 });
const serverSeed = generateServerSeed();
const cs = clientSeed ?? "default";
const roll = deriveInt(serverSeed, cs, 0, 0, 99); // 0-99
// Win probability and multiplier (2% house edge)
const winProb = direction === "over" ? (99 - threshold) / 100 : threshold / 100;
const multiplier = parseFloat(((0.98 / winProb)).toFixed(4));
const won = direction === "over" ? roll > threshold : roll < threshold;
const payout = won ? Math.floor(wageBLW * multiplier) : 0;
try {
await prisma.$transaction(async (tx) => {
await debitWalletCredits(tx, session.user.id, wageBLW);
await tx.ledgerEntry.create({
data: { userId: session.user.id, delta: -wageBLW, type: "DEBIT_GAME_BET", memo: "DICE bet" },
});
if (won && payout > 0) {
await creditWalletCredits(tx, session.user.id, payout);
await tx.ledgerEntry.create({
data: { userId: session.user.id, delta: payout, type: "CREDIT_GAME_WIN", memo: "DICE payout" },
});
}
await tx.gameSession.create({
data: {
userId: session.user.id,
gameType: "DICE",
wageredBLW: wageBLW,
payoutBLW: payout,
multiplier: won ? multiplier : 0,
outcome: won ? "win" : "loss",
serverSeed,
clientSeed: cs,
resultData: { roll, threshold, direction, multiplier },
},
});
});
} catch (e) {
if (e instanceof Error && e.message === INSUFFICIENT_CREDITS) {
return NextResponse.json({ error: "Insufficient balance" }, { status: 402 });
}
console.error(e);
return NextResponse.json({ error: "Could not settle dice game" }, { status: 500 });
}
return NextResponse.json({
roll,
won,
threshold,
direction,
multiplier,
payout,
serverSeed,
});
}

View File

@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
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 limit = Math.min(parseInt(url.searchParams.get("limit") ?? "20"), 50);
const cursor = url.searchParams.get("cursor");
const sessions = await prisma.gameSession.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
take: limit + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
const hasMore = sessions.length > limit;
const data = hasMore ? sessions.slice(0, limit) : sessions;
return NextResponse.json({
sessions: data,
nextCursor: hasMore ? data[data.length - 1].id : null,
});
}

View File

@@ -0,0 +1,165 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { debitForBet } from "@/lib/game-ledger";
import { generateServerSeed, hashServerSeed, deriveMinePositions } from "@/lib/provably-fair";
import { creditWalletCredits } from "@/lib/wallet-safety";
export const dynamic = "force-dynamic";
const GRID = 25; // 5x5
function calcMultiplier(revealed: number, mines: number): number {
// Expected value approach: multiply by safe/(total-revealed) each step, with 1% house edge
let mult = 1.0;
let safe = GRID - mines;
for (let i = 0; i < revealed; i++) {
mult *= ((safe - i) / (GRID - i)) * 0.99;
}
return parseFloat((1 / mult).toFixed(4));
}
// POST — start a mines game
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const { wageBLW, clientSeed, mineCount } = body as { wageBLW: number; clientSeed?: string; mineCount: number };
if (!Number.isInteger(wageBLW) || wageBLW < 1) return NextResponse.json({ error: "Invalid wager" }, { status: 400 });
if (!Number.isInteger(mineCount) || mineCount < 1 || mineCount > 24) return NextResponse.json({ error: "Mines must be 1-24" }, { status: 400 });
const serverSeed = generateServerSeed();
const cs = clientSeed ?? "default";
const minePositions = deriveMinePositions(serverSeed, cs, GRID, mineCount);
try {
await debitForBet(session.user.id, wageBLW, "MINES");
} catch {
return NextResponse.json({ error: "Insufficient balance" }, { status: 402 });
}
const gs = await prisma.gameSession.create({
data: {
userId: session.user.id,
gameType: "MINES",
wageredBLW: wageBLW,
outcome: "active",
serverSeed,
clientSeed: cs,
resultData: { minePositions, revealed: [], mineCount, cashedOut: false },
},
});
return NextResponse.json({ roundId: gs.id, serverSeedHash: hashServerSeed(serverSeed), mineCount });
}
// PATCH — reveal a tile 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, action, tile } = body as { roundId: string; action: "reveal" | "cashout"; tile?: number };
const gs = await prisma.gameSession.findUnique({ where: { id: roundId } });
if (!gs || gs.userId !== session.user.id || gs.outcome !== "active") {
return NextResponse.json({ error: "Round not found or settled" }, { status: 404 });
}
const data = gs.resultData as { minePositions: number[]; revealed: number[]; mineCount: number; cashedOut: boolean };
if (action === "cashout") {
if (data.revealed.length === 0) {
// Refund if no tiles revealed
const settled = await prisma.$transaction(async (tx) => {
const updated = await tx.gameSession.updateMany({
where: { id: roundId, userId: session.user.id, outcome: "active" },
data: { outcome: "push", multiplier: 1, payoutBLW: gs.wageredBLW },
});
if (updated.count !== 1) return false;
await creditWalletCredits(tx, session.user.id, gs.wageredBLW);
await tx.ledgerEntry.create({
data: {
userId: session.user.id,
delta: gs.wageredBLW,
type: "CREDIT_GAME_REFUND",
memo: "MINES refund",
},
});
return true;
});
if (!settled) return NextResponse.json({ error: "Round not found or settled" }, { status: 404 });
return NextResponse.json({ outcome: "push", payout: gs.wageredBLW, serverSeed: gs.serverSeed, minePositions: data.minePositions });
}
const multiplier = calcMultiplier(data.revealed.length, data.mineCount);
const payout = Math.floor(gs.wageredBLW * multiplier);
const settled = await prisma.$transaction(async (tx) => {
const updated = await tx.gameSession.updateMany({
where: { id: roundId, userId: session.user.id, outcome: "active" },
data: { outcome: "cashout", multiplier, payoutBLW: payout, resultData: { ...data, cashedOut: true } },
});
if (updated.count !== 1) return false;
await creditWalletCredits(tx, session.user.id, payout);
await tx.ledgerEntry.create({
data: {
userId: session.user.id,
delta: payout,
type: "CREDIT_GAME_WIN",
memo: "MINES payout",
},
});
return true;
});
if (!settled) return NextResponse.json({ error: "Round not found or settled" }, { status: 404 });
return NextResponse.json({ outcome: "cashout", multiplier, payout, serverSeed: gs.serverSeed, minePositions: data.minePositions });
}
if (tile === undefined || tile < 0 || tile >= GRID) return NextResponse.json({ error: "Invalid tile" }, { status: 400 });
if (data.revealed.includes(tile)) return NextResponse.json({ error: "Already revealed" }, { status: 400 });
const isMine = data.minePositions.includes(tile);
if (isMine) {
await prisma.gameSession.update({
where: { id: roundId },
data: { outcome: "loss", multiplier: 0, payoutBLW: 0, resultData: { ...data, revealed: [...data.revealed, tile] } },
});
return NextResponse.json({ outcome: "loss", tile, isMine: true, serverSeed: gs.serverSeed, minePositions: data.minePositions });
}
const newRevealed = [...data.revealed, tile];
const safeCount = GRID - data.mineCount;
const multiplier = calcMultiplier(newRevealed.length, data.mineCount);
// Auto-cashout if all safe tiles revealed
if (newRevealed.length === safeCount) {
const payout = Math.floor(gs.wageredBLW * multiplier);
const settled = await prisma.$transaction(async (tx) => {
const updated = await tx.gameSession.updateMany({
where: { id: roundId, userId: session.user.id, outcome: "active" },
data: { outcome: "win", multiplier, payoutBLW: payout, resultData: { ...data, revealed: newRevealed, cashedOut: true } },
});
if (updated.count !== 1) return false;
await creditWalletCredits(tx, session.user.id, payout);
await tx.ledgerEntry.create({
data: {
userId: session.user.id,
delta: payout,
type: "CREDIT_GAME_WIN",
memo: "MINES payout",
},
});
return true;
});
if (!settled) return NextResponse.json({ error: "Round not found or settled" }, { status: 404 });
return NextResponse.json({ outcome: "win", tile, isMine: false, multiplier, payout, serverSeed: gs.serverSeed, minePositions: data.minePositions });
}
await prisma.gameSession.update({
where: { id: roundId },
data: { resultData: { ...data, revealed: newRevealed } },
});
return NextResponse.json({ outcome: "active", tile, isMine: false, multiplier, revealed: newRevealed });
}

View File

@@ -0,0 +1,132 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { creditForWin } from "@/lib/game-ledger";
import { debitWalletCredits, INSUFFICIENT_CREDITS } from "@/lib/wallet-safety";
export const dynamic = "force-dynamic";
// GET — list open markets
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const includeResolved = url.searchParams.get("resolved") === "1";
const markets = await prisma.predictionMarket.findMany({
where: includeResolved
? undefined
: { resolvedTo: null },
orderBy: { createdAt: "desc" },
take: 20,
include: {
_count: { select: { bets: true } },
},
});
return NextResponse.json({ markets });
}
// POST — create market or place bet
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const { action } = body as { action: "create" | "bet" };
if (action === "create") {
const { question, endsAt } = body as { question: string; endsAt: string };
if (!question || question.length < 10) return NextResponse.json({ error: "Question too short" }, { status: 400 });
const endDate = new Date(endsAt);
if (isNaN(endDate.getTime()) || endDate <= new Date()) return NextResponse.json({ error: "Invalid end date" }, { status: 400 });
const market = await prisma.predictionMarket.create({
data: { creatorId: session.user.id, question, endsAt: endDate },
});
return NextResponse.json({ market });
}
if (action === "bet") {
const { marketId, side, blwAmount } = body as { marketId: string; side: boolean; blwAmount: number };
if (!Number.isInteger(blwAmount) || blwAmount < 1) return NextResponse.json({ error: "Invalid amount" }, { status: 400 });
const market = await prisma.predictionMarket.findUnique({ where: { id: marketId } });
if (!market) return NextResponse.json({ error: "Market not found" }, { status: 404 });
if (market.endsAt < new Date()) return NextResponse.json({ error: "Market closed" }, { status: 410 });
if (market.resolvedTo !== null) return NextResponse.json({ error: "Market resolved" }, { status: 410 });
// Check no existing bet
const existing = await prisma.predictionBet.findFirst({ where: { marketId, userId: session.user.id } });
if (existing) return NextResponse.json({ error: "Already bet on this market" }, { status: 409 });
let bet;
try {
bet = await prisma.$transaction(async (tx) => {
await debitWalletCredits(tx, session.user.id, blwAmount);
await tx.ledgerEntry.create({
data: {
userId: session.user.id,
delta: -blwAmount,
type: "DEBIT_GAME_BET",
memo: "PREDICTION bet",
},
});
const created = await tx.predictionBet.create({
data: { marketId, userId: session.user.id, side, blwAmount },
});
await tx.predictionMarket.update({
where: { id: marketId },
data: side
? { totalYes: { increment: blwAmount } }
: { totalNo: { increment: blwAmount } },
});
return created;
});
} catch (e) {
if (e instanceof Error && e.message === INSUFFICIENT_CREDITS) {
return NextResponse.json({ error: "Insufficient balance" }, { status: 402 });
}
console.error(e);
return NextResponse.json({ error: "Could not place bet" }, { status: 500 });
}
return NextResponse.json({ bet });
}
return NextResponse.json({ error: "Unknown action" }, { status: 400 });
}
// PATCH — resolve market (creator only)
export async function PATCH(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const { marketId, resolvedTo } = body as { marketId: string; resolvedTo: boolean };
const market = await prisma.predictionMarket.findUnique({
where: { id: marketId },
include: { bets: true },
});
if (!market) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (market.creatorId !== session.user.id) return NextResponse.json({ error: "Not the creator" }, { status: 403 });
if (market.resolvedTo !== null) return NextResponse.json({ error: "Already resolved" }, { status: 409 });
// Payout winners pro-rata from total pot
const totalPot = market.totalYes + market.totalNo;
const winnerBets = market.bets.filter(b => b.side === resolvedTo);
const winnerTotal = winnerBets.reduce((s, b) => s + b.blwAmount, 0);
for (const bet of winnerBets) {
if (winnerTotal > 0) {
const payout = Math.floor((bet.blwAmount / winnerTotal) * totalPot);
if (payout > 0) {
await creditForWin(bet.userId, payout, "PREDICTION", `Prediction win: ${market.question}`);
}
}
}
await prisma.predictionMarket.update({ where: { id: marketId }, data: { resolvedTo } });
return NextResponse.json({ resolved: true, resolvedTo, totalPot, winners: winnerBets.length });
}

View File

@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const gameType = url.searchParams.get("gameType");
const rooms = await prisma.gameRoom.findMany({
where: {
status: "WAITING",
expiresAt: { gt: new Date() },
...(gameType ? { gameType: gameType as "COIN_FLIP" | "PONG" } : {}),
},
orderBy: { createdAt: "desc" },
take: 20,
});
return NextResponse.json({ rooms });
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const { gameType, wageBLW } = body as { gameType: "COIN_FLIP" | "PONG"; wageBLW: number };
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 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 });
}

View File

@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { generateServerSeed, deriveInt } from "@/lib/provably-fair";
import { creditWalletCredits, debitWalletCredits, INSUFFICIENT_CREDITS } from "@/lib/wallet-safety";
export const dynamic = "force-dynamic";
const RED = new Set([1,3,5,7,9,12,14,16,18,19,21,23,25,27,30,32,34,36]);
function evaluateBet(type: string, value: number | string, result: number): number {
if (type === "straight") return result === Number(value) ? 35 : -1;
if (type === "color") {
if (result === 0) return -1;
const isRed = RED.has(result);
if (value === "red" && isRed) return 1;
if (value === "black" && !isRed) return 1;
return -1;
}
if (type === "dozen") {
if (result === 0) return -1;
const d = Math.ceil(result / 12);
return d === Number(value) ? 2 : -1;
}
if (type === "column") {
if (result === 0) return -1;
const col = ((result - 1) % 3) + 1;
return col === Number(value) ? 2 : -1;
}
if (type === "half") {
if (result === 0) return -1;
if (value === "low" && result >= 1 && result <= 18) return 1;
if (value === "high" && result >= 19 && result <= 36) return 1;
return -1;
}
if (type === "parity") {
if (result === 0) return -1;
if (value === "even" && result % 2 === 0) return 1;
if (value === "odd" && result % 2 === 1) return 1;
return -1;
}
return -1;
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const { wageBLW, clientSeed, betType, betValue } = body as {
wageBLW: number;
clientSeed?: string;
betType: string;
betValue: number | string;
};
if (!Number.isInteger(wageBLW) || wageBLW < 1) return NextResponse.json({ error: "Invalid wager" }, { status: 400 });
const serverSeed = generateServerSeed();
const cs = clientSeed ?? "default";
const result = deriveInt(serverSeed, cs, 0, 0, 36);
const multiplierRaw = evaluateBet(betType, betValue, result);
const won = multiplierRaw >= 0;
const payout = won ? Math.floor(wageBLW * (multiplierRaw + 1)) : 0;
const isRed = RED.has(result);
const color = result === 0 ? "green" : isRed ? "red" : "black";
try {
await prisma.$transaction(async (tx) => {
await debitWalletCredits(tx, session.user.id, wageBLW);
await tx.ledgerEntry.create({
data: { userId: session.user.id, delta: -wageBLW, type: "DEBIT_GAME_BET", memo: "ROULETTE bet" },
});
if (won && payout > 0) {
await creditWalletCredits(tx, session.user.id, payout);
await tx.ledgerEntry.create({
data: { userId: session.user.id, delta: payout, type: "CREDIT_GAME_WIN", memo: "ROULETTE payout" },
});
}
await tx.gameSession.create({
data: {
userId: session.user.id,
gameType: "ROULETTE",
wageredBLW: wageBLW,
payoutBLW: payout,
multiplier: won ? multiplierRaw + 1 : 0,
outcome: won ? "win" : "loss",
serverSeed,
clientSeed: cs,
resultData: { result, color, betType, betValue: String(betValue), multiplier: multiplierRaw },
},
});
});
} catch (e) {
if (e instanceof Error && e.message === INSUFFICIENT_CREDITS) {
return NextResponse.json({ error: "Insufficient balance" }, { status: 402 });
}
console.error(e);
return NextResponse.json({ error: "Could not settle roulette game" }, { status: 500 });
}
return NextResponse.json({ result, color, won, payout, multiplier: multiplierRaw + 1, serverSeed });
}

View File

@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { generateServerSeed, deriveInt } from "@/lib/provably-fair";
import { creditWalletCredits, debitWalletCredits, INSUFFICIENT_CREDITS } from "@/lib/wallet-safety";
export const dynamic = "force-dynamic";
const SYMBOLS = ["🍒", "🍋", "🍊", "🍇", "💎", "7⃣"];
const SYMBOL_WEIGHTS = [30, 25, 20, 15, 7, 3]; // out of 100
// Paytable: 3 of a kind multipliers
const PAYTABLE: Record<string, number> = {
"🍒": 2,
"🍋": 3,
"🍊": 5,
"🍇": 10,
"💎": 25,
"7⃣": 50,
};
function weightedSymbol(roll: number): string {
let cumulative = 0;
for (let i = 0; i < SYMBOLS.length; i++) {
cumulative += SYMBOL_WEIGHTS[i];
if (roll < cumulative) return SYMBOLS[i];
}
return SYMBOLS[SYMBOLS.length - 1];
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const { wageBLW, clientSeed } = body as { wageBLW: number; clientSeed?: string };
if (!Number.isInteger(wageBLW) || wageBLW < 1) return NextResponse.json({ error: "Invalid wager" }, { status: 400 });
const serverSeed = generateServerSeed();
const cs = clientSeed ?? "default";
const reels = [
weightedSymbol(deriveInt(serverSeed, cs, 0, 0, 99)),
weightedSymbol(deriveInt(serverSeed, cs, 1, 0, 99)),
weightedSymbol(deriveInt(serverSeed, cs, 2, 0, 99)),
];
let multiplier = 0;
let outcome = "loss";
if (reels[0] === reels[1] && reels[1] === reels[2]) {
multiplier = PAYTABLE[reels[0]] ?? 2;
outcome = "win";
} else if (reels[0] === reels[1] || reels[1] === reels[2] || reels[0] === reels[2]) {
multiplier = 1.5;
outcome = "win";
}
const payout = outcome === "win" ? Math.floor(wageBLW * multiplier) : 0;
try {
await prisma.$transaction(async (tx) => {
await debitWalletCredits(tx, session.user.id, wageBLW);
await tx.ledgerEntry.create({
data: { userId: session.user.id, delta: -wageBLW, type: "DEBIT_GAME_BET", memo: "SLOTS bet" },
});
if (payout > 0) {
await creditWalletCredits(tx, session.user.id, payout);
await tx.ledgerEntry.create({
data: { userId: session.user.id, delta: payout, type: "CREDIT_GAME_WIN", memo: "SLOTS payout" },
});
}
await tx.gameSession.create({
data: {
userId: session.user.id,
gameType: "SLOTS",
wageredBLW: wageBLW,
payoutBLW: payout,
multiplier,
outcome,
serverSeed,
clientSeed: cs,
resultData: { reels, multiplier },
},
});
});
} catch (e) {
if (e instanceof Error && e.message === INSUFFICIENT_CREDITS) {
return NextResponse.json({ error: "Insufficient balance" }, { status: 402 });
}
console.error(e);
return NextResponse.json({ error: "Could not settle slots game" }, { status: 500 });
}
return NextResponse.json({ reels, multiplier, payout, outcome, serverSeed });
}

View File

@@ -0,0 +1,150 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { debitForBet } from "@/lib/game-ledger";
import { generateServerSeed, hashServerSeed, deriveInt } from "@/lib/provably-fair";
import { creditWalletCredits } from "@/lib/wallet-safety";
export const dynamic = "force-dynamic";
const FLOORS = 8;
const TILES_PER_FLOOR = 3;
const SAFE_PER_FLOOR = 2; // 2 safe, 1 bomb per floor
// Multiplier per floor cleared (cumulative)
const FLOOR_MULTIPLIERS = [1.4, 2.0, 2.8, 4.0, 5.6, 8.0, 12.0, 18.0];
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const { wageBLW, clientSeed } = body as { wageBLW: number; clientSeed?: string };
if (!Number.isInteger(wageBLW) || wageBLW < 1) return NextResponse.json({ error: "Invalid wager" }, { status: 400 });
const serverSeed = generateServerSeed();
const cs = clientSeed ?? "default";
// Pre-generate bomb positions for all floors
const bombPositions: number[] = [];
for (let f = 0; f < FLOORS; f++) {
bombPositions.push(deriveInt(serverSeed, cs, f, 0, TILES_PER_FLOOR - 1));
}
try {
await debitForBet(session.user.id, wageBLW, "TOWER");
} catch {
return NextResponse.json({ error: "Insufficient balance" }, { status: 402 });
}
const gs = await prisma.gameSession.create({
data: {
userId: session.user.id,
gameType: "TOWER",
wageredBLW: wageBLW,
outcome: "active",
serverSeed,
clientSeed: cs,
resultData: { bombPositions, currentFloor: 0, cashedOut: false },
},
});
return NextResponse.json({
roundId: gs.id,
serverSeedHash: hashServerSeed(serverSeed),
floors: FLOORS,
tilesPerFloor: TILES_PER_FLOOR,
floorMultipliers: FLOOR_MULTIPLIERS,
});
}
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, action, tile } = body as { roundId: string; action: "pick" | "cashout"; tile?: number };
const gs = await prisma.gameSession.findUnique({ where: { id: roundId } });
if (!gs || gs.userId !== session.user.id || gs.outcome !== "active") {
return NextResponse.json({ error: "Round not found or settled" }, { status: 404 });
}
const data = gs.resultData as { bombPositions: number[]; currentFloor: number; cashedOut: boolean };
if (action === "cashout") {
if (data.currentFloor === 0) {
return NextResponse.json({ error: "Must clear at least one floor before cashing out" }, { status: 400 });
}
const multiplier = FLOOR_MULTIPLIERS[data.currentFloor - 1];
const payout = Math.floor(gs.wageredBLW * multiplier);
const settled = await prisma.$transaction(async (tx) => {
const updated = await tx.gameSession.updateMany({
where: { id: roundId, userId: session.user.id, outcome: "active" },
data: { outcome: "cashout", multiplier, payoutBLW: payout, resultData: { ...data, cashedOut: true } },
});
if (updated.count !== 1) return false;
await creditWalletCredits(tx, session.user.id, payout);
await tx.ledgerEntry.create({
data: {
userId: session.user.id,
delta: payout,
type: "CREDIT_GAME_WIN",
memo: "TOWER payout",
},
});
return true;
});
if (!settled) return NextResponse.json({ error: "Round not found or settled" }, { status: 404 });
return NextResponse.json({ outcome: "cashout", multiplier, payout, serverSeed: gs.serverSeed, bombPositions: data.bombPositions });
}
if (tile === undefined || tile < 0 || tile >= TILES_PER_FLOOR) return NextResponse.json({ error: "Invalid tile" }, { status: 400 });
if (data.currentFloor >= FLOORS) return NextResponse.json({ error: "Tower complete" }, { status: 400 });
const bombPos = data.bombPositions[data.currentFloor];
const isBomb = tile === bombPos;
if (isBomb) {
await prisma.gameSession.update({
where: { id: roundId },
data: { outcome: "loss", multiplier: 0, payoutBLW: 0 },
});
return NextResponse.json({ outcome: "loss", tile, bombPos, serverSeed: gs.serverSeed, bombPositions: data.bombPositions });
}
const newFloor = data.currentFloor + 1;
const nextMultiplier = newFloor < FLOORS ? FLOOR_MULTIPLIERS[newFloor - 1] : FLOOR_MULTIPLIERS[FLOORS - 1];
if (newFloor === FLOORS) {
// Reached the top
const multiplier = FLOOR_MULTIPLIERS[FLOORS - 1];
const payout = Math.floor(gs.wageredBLW * multiplier);
const settled = await prisma.$transaction(async (tx) => {
const updated = await tx.gameSession.updateMany({
where: { id: roundId, userId: session.user.id, outcome: "active" },
data: { outcome: "win", multiplier, payoutBLW: payout, resultData: { ...data, currentFloor: newFloor, cashedOut: true } },
});
if (updated.count !== 1) return false;
await creditWalletCredits(tx, session.user.id, payout);
await tx.ledgerEntry.create({
data: {
userId: session.user.id,
delta: payout,
type: "CREDIT_GAME_WIN",
memo: "TOWER payout",
},
});
return true;
});
if (!settled) return NextResponse.json({ error: "Round not found or settled" }, { status: 404 });
return NextResponse.json({ outcome: "win", tile, bombPos, newFloor, multiplier, payout, serverSeed: gs.serverSeed, bombPositions: data.bombPositions });
}
await prisma.gameSession.update({
where: { id: roundId },
data: { resultData: { ...data, currentFloor: newFloor } },
});
return NextResponse.json({ outcome: "active", tile, bombPos, newFloor, nextMultiplier });
}

View File

@@ -0,0 +1,86 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { isAdminRole } from "@/lib/admin";
import { creditDisplayName } from "@/lib/credits-brand";
import { prisma } from "@/lib/prisma";
import { debitWalletCredits, INSUFFICIENT_CREDITS } from "@/lib/wallet-safety";
const bodySchema = z.object({
credits: z.number().int().min(1).max(2_000_000),
supporterNote: z.string().max(280).optional(),
});
export async function POST(req: Request, ctx: { params: Promise<{ slug: string }> }) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Sign in to pledge credits toward an initiative." }, { status: 401 });
}
const { slug } = await ctx.params;
const admin = isAdminRole(session.user.role);
const creditName = creditDisplayName();
try {
const json = await req.json();
const { credits, supporterNote } = bodySchema.parse(json);
const initiative = await prisma.democraticInitiative.findUnique({
where: { slug },
});
if (!initiative) {
return NextResponse.json({ error: "Initiative not found." }, { status: 404 });
}
const cost = credits;
const spent = admin ? 0 : cost;
await prisma.$transaction(async (tx) => {
const row = await tx.initiativeSpend.create({
data: {
userId: session.user.id,
initiativeId: initiative.id,
creditsSpent: spent,
supporterNote: supporterNote?.trim() || null,
},
});
if (!admin) {
await debitWalletCredits(tx, session.user.id, cost);
await tx.ledgerEntry.create({
data: {
userId: session.user.id,
delta: -cost,
type: "DEBIT_INITIATIVE_SPEND",
initiativeSpendId: row.id,
memo: `Democratic initiative: ${initiative.title}`,
},
});
}
});
return NextResponse.json({
ok: true,
slug: initiative.slug,
title: initiative.title,
creditsSpent: spent,
adminBypass: admin,
creditName,
});
} catch (e) {
if (e instanceof z.ZodError) {
return NextResponse.json({ error: "Invalid input", issues: e.issues }, { status: 400 });
}
if (e instanceof Error && e.message === INSUFFICIENT_CREDITS) {
return NextResponse.json(
{
error: `Not enough ${creditName} — contribute first so verified donations can credit your wallet.`,
},
{ status: 402 },
);
}
console.error(e);
return NextResponse.json({ error: "Could not record pledge." }, { status: 500 });
}
}

View File

@@ -0,0 +1,154 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { creditDisplayName } from "@/lib/credits-brand";
import { makeInitiativeSlugFromTitle } from "@/lib/initiative-slug";
import { prisma } from "@/lib/prisma";
import { DemocraticInitiativeOrigin, Prisma } from "@prisma/client";
export const dynamic = "force-dynamic";
function maskEmail(email: string): string {
const [u, d] = email.split("@");
if (!d || !u) return "Supporter";
return `${u.slice(0, Math.min(2, u.length))}…@${d}`;
}
function isUniqueViolation(e: unknown): boolean {
return e instanceof Prisma.PrismaClientKnownRequestError && e.code === "P2002";
}
export async function GET() {
const session = await auth();
const uid = session?.user?.id;
const rows = await prisma.democraticInitiative.findMany({
include: {
creator: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: "desc" },
});
const sums =
rows.length === 0
? []
: await prisma.initiativeSpend.groupBy({
by: ["initiativeId"],
where: { initiativeId: { in: rows.map((r) => r.id) } },
_sum: { creditsSpent: true },
});
const pledgedByInitiative: Record<string, number> = {};
for (const s of sums) {
pledgedByInitiative[s.initiativeId] = s._sum.creditsSpent ?? 0;
}
const platform = rows
.filter((r) => r.origin === DemocraticInitiativeOrigin.PLATFORM)
.sort((a, b) => {
if (a.sortOrder !== b.sortOrder) return a.sortOrder - b.sortOrder;
const pb = pledgedByInitiative[b.id] ?? 0;
const pa = pledgedByInitiative[a.id] ?? 0;
if (pb !== pa) return pb - pa;
return b.createdAt.getTime() - a.createdAt.getTime();
});
const community = rows
.filter((r) => r.origin === DemocraticInitiativeOrigin.COMMUNITY)
.sort((a, b) => {
const pb = pledgedByInitiative[b.id] ?? 0;
const pa = pledgedByInitiative[a.id] ?? 0;
if (pb !== pa) return pb - pa;
return b.createdAt.getTime() - a.createdAt.getTime();
});
const sorted = [...platform, ...community];
const mine = uid ? sorted.find((r) => r.creatorId === uid && r.origin === DemocraticInitiativeOrigin.COMMUNITY) : undefined;
return NextResponse.json({
creditName: creditDisplayName(),
initiatives: sorted.map((i) => ({
id: i.id,
slug: i.slug,
title: i.title,
description: i.description,
origin: i.origin,
sortOrder: i.sortOrder,
createdAt: i.createdAt.toISOString(),
creator: i.creator
? {
id: i.creator.id,
displayName: i.creator.name?.trim() || maskEmail(i.creator.email),
}
: null,
pledgedCredits: pledgedByInitiative[i.id] ?? 0,
isMine: uid !== undefined && i.creatorId === uid,
})),
myInitiativeSlug: mine?.slug ?? null,
canCreate: !!uid && !mine,
});
}
const createSchema = z.object({
title: z.string().min(4).max(120),
description: z.string().min(20).max(8000),
});
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Sign in to publish a democratic initiative." }, { status: 401 });
}
try {
const json = await req.json();
const { title, description } = createSchema.parse(json);
const existing = await prisma.democraticInitiative.findFirst({
where: { creatorId: session.user.id, origin: DemocraticInitiativeOrigin.COMMUNITY },
});
if (existing) {
return NextResponse.json(
{ error: "You already have an initiative — one active initiative per account." },
{ status: 409 },
);
}
let created = null as Awaited<ReturnType<typeof prisma.democraticInitiative.create>> | null;
for (let attempt = 0; attempt < 10; attempt++) {
const slug = makeInitiativeSlugFromTitle(title);
try {
created = await prisma.democraticInitiative.create({
data: {
slug,
creatorId: session.user.id,
origin: DemocraticInitiativeOrigin.COMMUNITY,
title: title.trim(),
description: description.trim(),
},
});
break;
} catch (e) {
if (isUniqueViolation(e)) continue;
throw e;
}
}
if (!created) {
return NextResponse.json({ error: "Could not allocate a unique URL — try again." }, { status: 500 });
}
return NextResponse.json({
ok: true,
slug: created.slug,
title: created.title,
});
} catch (e) {
if (e instanceof z.ZodError) {
return NextResponse.json({ error: "Invalid input", issues: e.issues }, { status: 400 });
}
console.error(e);
return NextResponse.json({ error: "Could not create initiative." }, { status: 500 });
}
}

View File

@@ -0,0 +1,51 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export const revalidate = 0;
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const limit = Math.min(parseInt(url.searchParams.get("limit") ?? "25"), 100);
// Aggregate total donated per user
const rows = await prisma.donation.groupBy({
by: ["userId"],
where: { userId: { not: null } },
_sum: { amountUsdCents: true },
_count: { id: true },
orderBy: { _sum: { amountUsdCents: "desc" } },
take: limit,
});
// Fetch display names
const userIds = rows.map((r) => r.userId).filter((id): id is string => id != null);
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, name: true, email: true },
});
const userMap = new Map(users.map(u => [u.id, u]));
const leaderboard = rows.map((row, idx) => {
const uid = row.userId!;
const user = userMap.get(uid);
// Show name if present, else obfuscate email
let displayName = user?.name ?? "Anonymous";
// Ghost donors have emails ending in leaderboard.local — show name only
// Real donors: show first name + last initial, or truncated email
const email = user?.email ?? "";
if (!user?.name && !email.endsWith("leaderboard.local")) {
const [local] = email.split("@");
displayName = local.slice(0, 3) + "***";
}
return {
rank: idx + 1,
displayName,
totalUsdCents: row._sum.amountUsdCents ?? 0,
donationCount: row._count.id,
};
});
return NextResponse.json({ leaderboard });
}

View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { MISSION_CATALOG } from "@/lib/mission-catalog";
import { creditDisplayName } from "@/lib/credits-brand";
export async function GET() {
const grouped = await prisma.missionSpend.groupBy({
by: ["missionSlug"],
_sum: { creditsSpent: true },
});
const pledgedBySlug = Object.fromEntries(
grouped.map((g) => [g.missionSlug, g._sum.creditsSpent ?? 0]),
);
return NextResponse.json({
missions: MISSION_CATALOG,
pledgedCreditsBySlug: pledgedBySlug,
creditName: creditDisplayName(),
});
}

View File

@@ -0,0 +1,84 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { isAdminRole } from "@/lib/admin";
import { creditDisplayName } from "@/lib/credits-brand";
import { missionBySlug } from "@/lib/mission-catalog";
import { prisma } from "@/lib/prisma";
import { debitWalletCredits, INSUFFICIENT_CREDITS } from "@/lib/wallet-safety";
const bodySchema = z.object({
missionSlug: z.string().min(2).max(80),
supporterNote: z.string().max(280).optional(),
});
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Sign in to pledge credits toward a mission." }, { status: 401 });
}
const admin = isAdminRole(session.user.role);
const creditName = creditDisplayName();
try {
const json = await req.json();
const { missionSlug, supporterNote } = bodySchema.parse(json);
const mission = missionBySlug(missionSlug);
if (!mission) {
return NextResponse.json({ error: "Unknown mission." }, { status: 400 });
}
const cost = mission.costCredits;
const spent = admin ? 0 : cost;
await prisma.$transaction(async (tx) => {
const ms = await tx.missionSpend.create({
data: {
userId: session.user.id,
missionSlug: mission.slug,
missionTitle: mission.title,
creditsSpent: spent,
supporterNote: supporterNote?.trim() || null,
},
});
if (!admin) {
await debitWalletCredits(tx, session.user.id, cost);
await tx.ledgerEntry.create({
data: {
userId: session.user.id,
delta: -cost,
type: "DEBIT_MISSION_SPEND",
missionSpendId: ms.id,
memo: `Mission pledge: ${mission.title}`,
},
});
}
});
return NextResponse.json({
ok: true,
missionSlug: mission.slug,
missionTitle: mission.title,
creditsSpent: spent,
adminBypass: admin,
creditName,
});
} catch (e) {
if (e instanceof z.ZodError) {
return NextResponse.json({ error: "Invalid input", issues: e.issues }, { status: 400 });
}
if (e instanceof Error && e.message === INSUFFICIENT_CREDITS) {
return NextResponse.json(
{
error: `Not enough ${creditName} — contribute first so verified donations can credit your wallet.`,
},
{ status: 402 },
);
}
console.error(e);
return NextResponse.json({ error: "Could not record pledge." }, { status: 500 });
}
}

View File

@@ -0,0 +1,199 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { isAdminRole } from "@/lib/admin";
import { parseWriteIn } from "@/lib/poll-write-in";
import { prisma } from "@/lib/prisma";
import { NEXT_PRESIDENT_POLL_SLUG, NEXT_PRESIDENT_POLL_TITLE } from "@/lib/polls";
import { creditDisplayName } from "@/lib/credits-brand";
import { pollVoteCostCredits } from "@/lib/public-env";
import { debitWalletCredits, INSUFFICIENT_CREDITS } from "@/lib/wallet-safety";
const bodySchema = z.object({
writeIn: z.string(),
});
type TallyRow = {
normalizedKey: string;
label: string;
count: number;
pct: number;
};
function aggregateVotes(
rows: { displayName: string; normalizedKey: string; createdAt: Date }[],
): { tallies: TallyRow[]; totalBallots: number; uniqueCandidates: number } {
const labelForKey = new Map<string, string>();
const counts = new Map<string, number>();
for (const row of rows) {
if (!labelForKey.has(row.normalizedKey)) {
labelForKey.set(row.normalizedKey, row.displayName);
}
counts.set(row.normalizedKey, (counts.get(row.normalizedKey) ?? 0) + 1);
}
const totalBallots = rows.length;
const tallies: TallyRow[] = [];
for (const [normalizedKey, count] of counts) {
tallies.push({
normalizedKey,
label: labelForKey.get(normalizedKey) ?? normalizedKey,
count,
pct: totalBallots > 0 ? Math.round((count / totalBallots) * 1000) / 10 : 0,
});
}
tallies.sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
return {
tallies,
totalBallots,
uniqueCandidates: counts.size,
};
}
export async function GET() {
const costCredits = pollVoteCostCredits();
const creditName = creditDisplayName();
const session = await auth();
const userId = session?.user?.id;
const rows = await prisma.pollVote.findMany({
where: { pollSlug: NEXT_PRESIDENT_POLL_SLUG },
select: { displayName: true, normalizedKey: true, createdAt: true },
orderBy: { createdAt: "asc" },
});
const { tallies, totalBallots, uniqueCandidates } = aggregateVotes(rows);
const dayBuckets = new Map<string, number>();
for (const r of rows) {
const day = r.createdAt.toISOString().slice(0, 10);
dayBuckets.set(day, (dayBuckets.get(day) ?? 0) + 1);
}
const dailyActivity = [...dayBuckets.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.slice(-14)
.map(([date, count]) => ({ date, count }));
let you: { voted: boolean; yourChoice?: string } | null = null;
if (!userId) {
you = null;
} else {
const mine = await prisma.pollVote.findUnique({
where: {
pollSlug_userId: {
pollSlug: NEXT_PRESIDENT_POLL_SLUG,
userId,
},
},
select: { displayName: true },
});
you = mine ? { voted: true, yourChoice: mine.displayName } : { voted: false };
}
const recent = await prisma.pollVote.findMany({
where: { pollSlug: NEXT_PRESIDENT_POLL_SLUG },
select: { displayName: true, createdAt: true },
orderBy: { createdAt: "desc" },
take: 12,
});
return NextResponse.json({
pollSlug: NEXT_PRESIDENT_POLL_SLUG,
pollTitle: NEXT_PRESIDENT_POLL_TITLE,
costCredits,
creditName,
totalBallots,
uniqueCandidates,
tallies,
dailyActivity,
recentBallots: recent.map((r) => ({
name: r.displayName,
at: r.createdAt.toISOString(),
})),
you,
});
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Sign in to cast a ballot" }, { status: 401 });
}
const cost = pollVoteCostCredits();
const admin = isAdminRole(session.user.role);
try {
const json = await req.json();
const { writeIn } = bodySchema.parse(json);
const parsed = parseWriteIn(writeIn);
if (!parsed) {
return NextResponse.json(
{ error: "Enter a name (2120 characters). Letters, numbers, spaces, and usual name punctuation only." },
{ status: 400 },
);
}
const existing = await prisma.pollVote.findUnique({
where: {
pollSlug_userId: {
pollSlug: NEXT_PRESIDENT_POLL_SLUG,
userId: session.user.id,
},
},
});
if (existing) {
return NextResponse.json({ error: "You already cast your ballot—one vote per supporter." }, { status: 409 });
}
const spent = admin ? 0 : cost;
await prisma.$transaction(async (tx) => {
const vote = await tx.pollVote.create({
data: {
pollSlug: NEXT_PRESIDENT_POLL_SLUG,
userId: session.user.id,
displayName: parsed.displayName,
normalizedKey: parsed.normalizedKey,
creditsSpent: spent,
},
});
if (!admin) {
await debitWalletCredits(tx, session.user.id, cost);
await tx.ledgerEntry.create({
data: {
userId: session.user.id,
delta: -cost,
type: "DEBIT_POLL_VOTE",
pollVoteId: vote.id,
memo: `Straw poll: ${NEXT_PRESIDENT_POLL_TITLE}`,
},
});
}
});
return NextResponse.json({
ok: true,
choice: parsed.displayName,
creditsSpent: spent,
adminBypass: admin,
});
} catch (e) {
if (e instanceof z.ZodError) {
return NextResponse.json({ error: "Invalid input", issues: e.issues }, { status: 400 });
}
if (e instanceof Error && e.message === INSUFFICIENT_CREDITS) {
return NextResponse.json(
{ error: `Not enough ${creditDisplayName()} — donate first to earn credits.` },
{ status: 402 },
);
}
console.error(e);
return NextResponse.json({ error: "Could not record ballot" }, { status: 500 });
}
}

View File

@@ -2,15 +2,20 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function GET() {
const [agg, donorCount] = await Promise.all([
const [agg, donorRows, guestDonations] = await Promise.all([
prisma.donation.aggregate({
where: { status: "succeeded" },
_sum: { amountUsdCents: true },
_count: true,
}),
prisma.donation.groupBy({
by: ["userId"],
where: { userId: { not: null }, status: "succeeded" },
_count: true,
}),
prisma.donation.count({
where: { userId: null, status: "succeeded" },
}),
]);
const raisedUsd = (agg._sum.amountUsdCents ?? 0) / 100;
@@ -19,8 +24,10 @@ export async function GET() {
return NextResponse.json({
raisedUsd,
donationCount: agg._count,
uniqueDonors: donorCount.length,
uniqueDonors: donorRows.length,
guestCheckoutDonations: guestDonations,
goalUsd,
committeePlaceholder: process.env.COMMITTEE_LEGAL_NAME_PLACEHOLDER ?? "Demo Committee (configure COMMITTEE_LEGAL_NAME_PLACEHOLDER)",
committeePlaceholder:
process.env.COMMITTEE_LEGAL_NAME_PLACEHOLDER ?? "Authorized committee (COMMITTEE_LEGAL_NAME_PLACEHOLDER)",
});
}

View File

@@ -1,12 +1,18 @@
import { NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { z } from "zod";
import {
buildAutoUsernameCandidates,
normalizeEmail,
usernameFromInput,
} from "@/lib/account-identifiers";
import { prisma } from "@/lib/prisma";
const bodySchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1).max(120).optional(),
username: z.string().min(3).max(80).optional(),
});
export async function POST(req: Request) {
@@ -14,17 +20,50 @@ export async function POST(req: Request) {
const json = await req.json();
const data = bodySchema.parse(json);
const exists = await prisma.user.findUnique({ where: { email: data.email } });
const emailNorm = normalizeEmail(data.email);
const exists = await prisma.user.findUnique({ where: { email: emailNorm } });
if (exists) {
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
}
const rawUsername = data.username?.trim();
let chosenUsername: string | undefined;
if (rawUsername) {
const requestedUsername = usernameFromInput(data.username!);
if (!requestedUsername) {
return NextResponse.json(
{ error: "Username must be 332 characters and use only letters, numbers, or underscores." },
{ status: 400 },
);
}
const unameTaken = await prisma.user.findUnique({ where: { username: requestedUsername } });
if (unameTaken) {
return NextResponse.json({ error: "That username is already taken." }, { status: 409 });
}
chosenUsername = requestedUsername;
}
if (!chosenUsername) {
for (const candidate of buildAutoUsernameCandidates(emailNorm)) {
const unameTaken = await prisma.user.findUnique({ where: { username: candidate } });
if (!unameTaken) {
chosenUsername = candidate;
break;
}
}
if (!chosenUsername) {
return NextResponse.json({ error: "Could not allocate a username; try picking one explicitly." }, { status: 500 });
}
}
const passwordHash = await bcrypt.hash(data.password, 12);
const user = await prisma.user.create({
data: {
email: data.email,
name: data.name ?? data.email.split("@")[0],
email: emailNorm,
username: chosenUsername,
name: data.name ?? emailNorm.split("@")[0] ?? "",
passwordHash,
},
});
@@ -33,7 +72,7 @@ export async function POST(req: Request) {
data: { userId: user.id, balanceCredits: 0 },
});
return NextResponse.json({ ok: true, email: user.email });
return NextResponse.json({ ok: true, email: user.email, username: user.username });
} catch (e) {
if (e instanceof z.ZodError) {
return NextResponse.json({ error: "Invalid input", issues: e.issues }, { status: 400 });

View File

@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { creditDisplayName } from "@/lib/credits-brand";
import { prisma } from "@/lib/prisma";
export async function GET() {
@@ -10,6 +11,6 @@ export async function GET() {
return NextResponse.json({
prizes,
raffles,
creditName: process.env.PUBLIC_CREDIT_NAME ?? "BLW",
creditName: creditDisplayName(),
});
}

View File

@@ -1,8 +1,10 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { creditDisplayName } from "@/lib/credits-brand";
import { auth } from "@/auth";
import { isAdminRole } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
import { debitWalletCredits, INSUFFICIENT_CREDITS } from "@/lib/wallet-safety";
const bodySchema = z.object({
slug: z.string().min(1),
@@ -24,17 +26,14 @@ export async function POST(req: Request) {
return NextResponse.json({ error: "Raffle not found" }, { status: 404 });
}
if (raffle.endsAt && raffle.endsAt < new Date()) {
return NextResponse.json({ error: "This raffle has ended" }, { status: 410 });
}
const totalCost = raffle.ticketCostCredits * tickets;
const admin = isAdminRole(session.user!.role);
await prisma.$transaction(async (tx) => {
if (!admin) {
const wallet = await tx.wallet.findUnique({ where: { userId: session.user!.id } });
if (!wallet || wallet.balanceCredits < totalCost) {
throw new Error("INSUFFICIENT_CREDITS");
}
}
await tx.raffleEntry.create({
data: {
raffleId: raffle.id,
@@ -45,6 +44,8 @@ export async function POST(req: Request) {
});
if (!admin) {
await debitWalletCredits(tx, session.user!.id, totalCost);
await tx.ledgerEntry.create({
data: {
userId: session.user!.id,
@@ -53,11 +54,6 @@ export async function POST(req: Request) {
memo: `Raffle tickets: ${raffle.title} × ${tickets}`,
},
});
await tx.wallet.update({
where: { userId: session.user!.id },
data: { balanceCredits: { decrement: totalCost } },
});
}
});
@@ -71,8 +67,8 @@ export async function POST(req: Request) {
if (e instanceof z.ZodError) {
return NextResponse.json({ error: "Invalid input", issues: e.issues }, { status: 400 });
}
if (e instanceof Error && e.message === "INSUFFICIENT_CREDITS") {
return NextResponse.json({ error: "Insufficient BLW (Blue Wave)" }, { status: 402 });
if (e instanceof Error && e.message === INSUFFICIENT_CREDITS) {
return NextResponse.json({ error: `Insufficient ${creditDisplayName()}` }, { status: 402 });
}
console.error(e);
return NextResponse.json({ error: "Entry failed" }, { status: 500 });

View File

@@ -2,7 +2,9 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { isAdminRole } from "@/lib/admin";
import { creditDisplayName } from "@/lib/credits-brand";
import { prisma } from "@/lib/prisma";
import { debitWalletCredits, INSUFFICIENT_CREDITS } from "@/lib/wallet-safety";
const bodySchema = z.object({
slug: z.string().min(1),
@@ -26,13 +28,6 @@ export async function POST(req: Request) {
const admin = isAdminRole(session.user!.role);
const result = await prisma.$transaction(async (tx) => {
if (!admin) {
const wallet = await tx.wallet.findUnique({ where: { userId: session.user!.id } });
if (!wallet || wallet.balanceCredits < sku.costCredits) {
throw new Error("INSUFFICIENT_CREDITS");
}
}
const redemption = await tx.redemption.create({
data: {
userId: session.user!.id,
@@ -42,6 +37,8 @@ export async function POST(req: Request) {
});
if (!admin) {
await debitWalletCredits(tx, session.user!.id, sku.costCredits);
await tx.ledgerEntry.create({
data: {
userId: session.user!.id,
@@ -51,11 +48,6 @@ export async function POST(req: Request) {
memo: `Redeem: ${sku.title}`,
},
});
await tx.wallet.update({
where: { userId: session.user!.id },
data: { balanceCredits: { decrement: sku.costCredits } },
});
}
return redemption.id;
@@ -70,8 +62,8 @@ export async function POST(req: Request) {
if (e instanceof z.ZodError) {
return NextResponse.json({ error: "Invalid input", issues: e.issues }, { status: 400 });
}
if (e instanceof Error && e.message === "INSUFFICIENT_CREDITS") {
return NextResponse.json({ error: "Insufficient BLW (Blue Wave)" }, { status: 402 });
if (e instanceof Error && e.message === INSUFFICIENT_CREDITS) {
return NextResponse.json({ error: `Insufficient ${creditDisplayName()}` }, { status: 402 });
}
console.error(e);
return NextResponse.json({ error: "Redeem failed" }, { status: 500 });

View File

@@ -0,0 +1,109 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { isAdminRole } from "@/lib/admin";
import { creditDisplayName } from "@/lib/credits-brand";
import { prisma } from "@/lib/prisma";
import { debitWalletCredits, INSUFFICIENT_CREDITS } from "@/lib/wallet-safety";
const ISSUE_CATALOG = [
{ slug: "voting-rights", title: "Voting Rights & Access" },
{ slug: "healthcare", title: "Affordable Healthcare" },
{ slug: "climate", title: "Climate & Clean Energy Jobs" },
{ slug: "education", title: "Public Education Funding" },
{ slug: "gun-safety", title: "Common-Sense Gun Safety" },
{ slug: "workers-rights", title: "Workers Rights & Wages" },
{ slug: "housing", title: "Affordable Housing" },
{ slug: "democracy-reform", title: "Campaign Finance Reform" },
] as const;
function currentWeekOf(): string {
const d = new Date();
const day = d.getUTCDay();
const diff = d.getUTCDate() - day + (day === 0 ? -6 : 1);
const mon = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), diff));
return mon.toISOString().split("T")[0];
}
const postSchema = z.object({
issueSlug: z.string().min(2).max(80),
creditsSpent: z.number().int().min(5).max(10000),
});
export async function GET() {
const weekOf = currentWeekOf();
const bids = await prisma.spotlightBid.groupBy({
by: ["issueSlug", "issueTitle"],
where: { weekOf },
_sum: { creditsSpent: true },
_count: true,
orderBy: { _sum: { creditsSpent: "desc" } },
});
const totals = bids.map((b) => ({
issueSlug: b.issueSlug,
issueTitle: b.issueTitle,
total: b._sum.creditsSpent ?? 0,
backers: b._count,
}));
const leader = totals[0] ?? null;
return NextResponse.json({ weekOf, totals, leader, catalog: ISSUE_CATALOG });
}
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Sign in to bid on a Spotlight." }, { status: 401 });
}
const admin = isAdminRole(session.user.role);
const creditName = creditDisplayName();
try {
const json = await req.json();
const { issueSlug, creditsSpent } = postSchema.parse(json);
const issue = ISSUE_CATALOG.find((i) => i.slug === issueSlug);
if (!issue) return NextResponse.json({ error: "Unknown issue." }, { status: 400 });
const weekOf = currentWeekOf();
const spent = admin ? 0 : creditsSpent;
await prisma.$transaction(async (tx) => {
const bid = await tx.spotlightBid.create({
data: {
userId: session.user.id,
issueSlug: issue.slug,
issueTitle: issue.title,
creditsSpent: spent,
weekOf,
},
});
if (!admin) {
await debitWalletCredits(tx, session.user.id, creditsSpent);
await tx.ledgerEntry.create({
data: {
userId: session.user.id,
delta: -creditsSpent,
type: "DEBIT_SPOTLIGHT",
spotlightBidId: bid.id,
memo: `Spotlight bid: ${issue.title}`,
},
});
}
});
return NextResponse.json({ ok: true, issue, weekOf, spent });
} catch (e) {
if (e instanceof z.ZodError) return NextResponse.json({ error: "Invalid input", issues: e.issues }, { status: 400 });
if (e instanceof Error && e.message === INSUFFICIENT_CREDITS) {
return NextResponse.json({ error: `Not enough ${creditName}.` }, { status: 402 });
}
console.error(e);
return NextResponse.json({ error: "Could not place bid." }, { status: 500 });
}
}

View File

@@ -0,0 +1,147 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { ALLOWED_DONATION_USD_CENTS, blwCreditsForUsdCents } from "@/lib/exchange";
import { stripe } from "@/lib/stripe";
import { getTreasuryTotalUsdCents } from "@/lib/treasury";
import { usdPerBwtFromTreasury } from "@/lib/treasury-math";
import { appTitle, siteUrl } from "@/lib/public-env";
export const runtime = "nodejs";
const bodySchema = z.object({
amountUsdCents: z.number().int().refine(
(n): n is (typeof ALLOWED_DONATION_USD_CENTS)[number] =>
(ALLOWED_DONATION_USD_CENTS as readonly number[]).includes(n),
{ message: "Allowed tiers only: $5, $10, $20, $100" },
),
donorEmail: z.string().max(320).optional(),
donorName: z.string().max(120).optional(),
});
function normalizeOptionalEmail(s: string | undefined): string | undefined {
if (!s?.trim()) return undefined;
const t = s.trim();
return z.string().email().safeParse(t).success ? t : undefined;
}
export async function POST(req: Request) {
const sk = process.env.STRIPE_SECRET_KEY?.trim();
if (!sk || sk.includes("disabled_configure")) {
return NextResponse.json(
{ error: "Stripe is not configured. Set STRIPE_SECRET_KEY in .env." },
{ status: 503 },
);
}
const session = await auth();
try {
const json = await req.json();
const parsed = bodySchema.parse(json);
const { amountUsdCents } = parsed;
const donorName = parsed.donorName?.trim() || undefined;
const donorEmail = normalizeOptionalEmail(parsed.donorEmail);
const treasuryUsdCents = await getTreasuryTotalUsdCents();
const blwUsd = usdPerBwtFromTreasury(treasuryUsdCents);
const creditsPreview = blwCreditsForUsdCents(amountUsdCents, blwUsd);
const userId = session?.user?.id;
const isGuest = !userId;
const piMetadata: Record<string, string> = {
purpose: "donation",
blwUsdSnapshot: blwUsd.toFixed(6),
treasuryUsdCentsSnapshot: String(treasuryUsdCents),
tierCents: String(amountUsdCents),
expectedCredits: String(isGuest ? 0 : creditsPreview),
guest: isGuest ? "true" : "false",
};
if (userId) piMetadata.userId = userId;
if (donorEmail) piMetadata.donorEmail = donorEmail.slice(0, 450);
if (donorName) piMetadata.donorName = donorName.slice(0, 450);
const brand = process.env.PUBLIC_APP_NAME ?? process.env.NEXT_PUBLIC_APP_NAME ?? appTitle();
const dollars = (amountUsdCents / 100).toFixed(0);
const productName = `${brand}$${dollars} grassroots donation`;
const base = siteUrl();
const checkoutSession = await stripe.checkout.sessions.create({
ui_mode: "embedded_page",
mode: "payment",
submit_type: "donate",
line_items: [
{
quantity: 1,
price_data: {
currency: "usd",
unit_amount: amountUsdCents,
product_data: {
name: productName,
description: isGuest
? `Guest donation. Sign in next time to earn ${brand} credits.`
: `Signed-in donation — credits land in your wallet automatically.`,
},
},
},
],
automatic_tax: { enabled: false },
payment_intent_data: {
description: `${brand} — grassroots donation`,
metadata: piMetadata,
...(donorEmail ? { receipt_email: donorEmail } : {}),
},
...(donorEmail ? { customer_email: donorEmail } : {}),
metadata: piMetadata,
return_url: `${base}/donate/thank-you?session_id={CHECKOUT_SESSION_ID}`,
});
return NextResponse.json({
clientSecret: checkoutSession.client_secret,
sessionId: checkoutSession.id,
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "",
guest: isGuest,
exchange: {
blwUsd,
blwPerUsd: 1 / blwUsd,
creditsPreview: isGuest ? 0 : creditsPreview,
tierUsdCents: amountUsdCents,
},
});
} catch (e) {
if (e instanceof z.ZodError) {
return NextResponse.json({ error: "Invalid input", issues: e.issues }, { status: 400 });
}
console.error("create-checkout-session failed", e);
return NextResponse.json({ error: "Could not create checkout session" }, { status: 500 });
}
}
// Status fetch for /donate/thank-you confirmation
export async function GET(req: Request) {
const sk = process.env.STRIPE_SECRET_KEY?.trim();
if (!sk || sk.includes("disabled_configure")) {
return NextResponse.json({ error: "Stripe is not configured." }, { status: 503 });
}
const { searchParams } = new URL(req.url);
const sessionId = searchParams.get("session_id");
if (!sessionId) {
return NextResponse.json({ error: "Missing session_id" }, { status: 400 });
}
try {
const s = await stripe.checkout.sessions.retrieve(sessionId);
return NextResponse.json({
status: s.status,
paymentStatus: s.payment_status,
amountTotal: s.amount_total,
currency: s.currency,
customerEmail: s.customer_details?.email ?? null,
});
} catch (e) {
console.error("retrieve checkout session failed", e);
return NextResponse.json({ error: "Could not retrieve session" }, { status: 404 });
}
}

View File

@@ -1,8 +1,10 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { auth } from "@/auth";
import { ALLOWED_DONATION_USD_CENTS, blwCreditsForUsdCents, blwUsdAt } from "@/lib/exchange";
import { ALLOWED_DONATION_USD_CENTS, blwCreditsForUsdCents } from "@/lib/exchange";
import { stripe } from "@/lib/stripe";
import { getTreasuryTotalUsdCents } from "@/lib/treasury";
import { usdPerBwtFromTreasury } from "@/lib/treasury-math";
const bodySchema = z.object({
amountUsdCents: z.number().int().refine(
@@ -10,14 +12,17 @@ const bodySchema = z.object({
(ALLOWED_DONATION_USD_CENTS as readonly number[]).includes(n),
{ message: "Allowed tiers only: $5, $10, $20, $100" },
),
donorEmail: z.string().max(320).optional(),
donorName: z.string().max(120).optional(),
});
export async function POST(req: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
function normalizeOptionalEmail(s: string | undefined): string | undefined {
if (!s?.trim()) return undefined;
const t = s.trim();
return z.string().email().safeParse(t).success ? t : undefined;
}
export async function POST(req: Request) {
const sk = process.env.STRIPE_SECRET_KEY?.trim();
if (!sk || sk.includes("disabled_configure")) {
return NextResponse.json(
@@ -26,40 +31,68 @@ export async function POST(req: Request) {
);
}
const session = await auth();
try {
const json = await req.json();
const { amountUsdCents } = bodySchema.parse(json);
const parsed = bodySchema.parse(json);
const { amountUsdCents } = parsed;
const donorName = parsed.donorName?.trim() || undefined;
const donorEmail = normalizeOptionalEmail(parsed.donorEmail);
const blwUsd = blwUsdAt(Date.now());
const treasuryUsdCents = await getTreasuryTotalUsdCents();
const blwUsd = usdPerBwtFromTreasury(treasuryUsdCents);
const creditsPreview = blwCreditsForUsdCents(amountUsdCents, blwUsd);
const userId = session?.user?.id;
const isGuest = !userId;
if (isGuest && donorEmail === undefined && parsed.donorEmail?.trim()) {
return NextResponse.json({ error: "If provided, email must be valid." }, { status: 400 });
}
const metadata: Record<string, string> = {
purpose: "donation",
blwUsdSnapshot: blwUsd.toFixed(6),
treasuryUsdCentsSnapshot: String(treasuryUsdCents),
tierCents: String(amountUsdCents),
expectedCredits: String(isGuest ? 0 : creditsPreview),
guest: isGuest ? "true" : "false",
};
if (userId) {
metadata.userId = userId;
}
if (donorEmail) {
metadata.donorEmail = donorEmail.slice(0, 450);
}
if (donorName) {
metadata.donorName = donorName.slice(0, 450);
}
const paymentIntent = await stripe.paymentIntents.create({
amount: amountUsdCents,
currency: "usd",
automatic_payment_methods: { enabled: true },
metadata: {
userId: session.user.id,
purpose: "donation",
blwUsdSnapshot: blwUsd.toFixed(6),
tierCents: String(amountUsdCents),
expectedCredits: String(creditsPreview),
},
metadata,
description: `${process.env.PUBLIC_APP_NAME ?? process.env.NEXT_PUBLIC_APP_NAME ?? "Democracy Rising"} — grassroots donation`,
...(donorEmail ? { receipt_email: donorEmail } : {}),
});
return NextResponse.json({
clientSecret: paymentIntent.client_secret,
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "",
guest: isGuest,
exchange: {
blwUsd,
blwPerUsd: 1 / blwUsd,
creditsPreview,
creditsPreview: isGuest ? 0 : creditsPreview,
tierUsdCents: amountUsdCents,
},
});
} catch (e) {
if (e instanceof z.ZodError) {
return NextResponse.json({ error: "Invalid amount", issues: e.issues }, { status: 400 });
return NextResponse.json({ error: "Invalid input", issues: e.issues }, { status: 400 });
}
console.error(e);
return NextResponse.json({ error: "Could not create payment" }, { status: 500 });

View File

@@ -0,0 +1,47 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
/** Public community pulse for wallet preview (no auth). */
export async function GET() {
const [initiativeAgg, missionAgg, publicStats, topInitiatives] = await Promise.all([
prisma.initiativeSpend.aggregate({ _sum: { creditsSpent: true }, _count: true }),
prisma.missionSpend.aggregate({ _sum: { creditsSpent: true }, _count: true }),
prisma.donation.aggregate({
where: { status: "succeeded" },
_sum: { amountUsdCents: true },
_count: true,
}),
prisma.initiativeSpend.groupBy({
by: ["initiativeId"],
_sum: { creditsSpent: true },
orderBy: { _sum: { creditsSpent: "desc" } },
take: 5,
}),
]);
const initiativeIds = topInitiatives.map((t) => t.initiativeId);
const initiatives =
initiativeIds.length === 0
? []
: await prisma.democraticInitiative.findMany({
where: { id: { in: initiativeIds } },
select: { id: true, slug: true, title: true },
});
const titleById = Object.fromEntries(initiatives.map((i) => [i.id, i.title]));
return NextResponse.json({
bwtOnInitiatives: initiativeAgg._sum.creditsSpent ?? 0,
initiativePledgeActions: initiativeAgg._count,
bwtOnMissions: missionAgg._sum.creditsSpent ?? 0,
missionPledgeActions: missionAgg._count,
raisedUsd: (publicStats._sum.amountUsdCents ?? 0) / 100,
giftCount: publicStats._count,
topInitiatives: topInitiatives.map((t) => ({
title: titleById[t.initiativeId] ?? "Community initiative",
pledged: t._sum.creditsSpent ?? 0,
})),
});
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(req.url);
const cursor = url.searchParams.get("cursor") ?? undefined;
const take = Math.min(parseInt(url.searchParams.get("take") ?? "20"), 50);
const donations = await prisma.donation.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
take: take + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
const hasMore = donations.length > take;
const items = hasMore ? donations.slice(0, take) : donations;
const nextCursor = hasMore ? items[items.length - 1].id : null;
return NextResponse.json({ items, nextCursor });
}

View File

@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export async function GET(req: Request) {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const url = new URL(req.url);
const cursor = url.searchParams.get("cursor") ?? undefined;
const take = Math.min(parseInt(url.searchParams.get("take") ?? "20"), 50);
const entries = await prisma.ledgerEntry.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
take: take + 1,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
const hasMore = entries.length > take;
const items = hasMore ? entries.slice(0, take) : entries;
const nextCursor = hasMore ? items[items.length - 1].id : null;
return NextResponse.json({ items, nextCursor });
}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { ADMIN_WALLET_DISPLAY, isAdminRole } from "@/lib/admin";
import { creditDisplayName } from "@/lib/credits-brand";
import { prisma } from "@/lib/prisma";
export async function GET() {
@@ -23,6 +24,6 @@ export async function GET() {
balanceCredits: admin ? ADMIN_WALLET_DISPLAY : wallet?.balanceCredits ?? 0,
infiniteCredits: admin,
role: user?.role ?? session.user.role,
creditLabel: process.env.PUBLIC_CREDIT_NAME ?? "BLW",
creditLabel: creditDisplayName(),
});
}

View File

@@ -0,0 +1,107 @@
import { NextResponse } from "next/server";
import { auth } from "@/auth";
import { isAdminRole } from "@/lib/admin";
import { prisma } from "@/lib/prisma";
const SPEND_LABELS: Record<string, string> = {
DEBIT_SPEND: "Perks",
DEBIT_RAFFLE: "Raffles",
DEBIT_POLL_VOTE: "Straw poll",
DEBIT_MISSION_SPEND: "Missions",
DEBIT_INITIATIVE_SPEND: "Initiatives",
DEBIT_GAME_BET: "Games",
DEBIT_BILLBOARD: "Billboard",
DEBIT_SPOTLIGHT: "Spotlight",
DEBIT_CARD_MINT: "Cards",
DEBIT_FAQ_SUBMIT: "FAQ",
DEBIT_FAQ_VOTE: "FAQ votes",
DEBIT_BOOST: "Movement meter",
};
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = session.user.id;
const admin = isAdminRole(session.user.role);
const [wallet, entries, donations, initiativePledges, missionPledges] = await Promise.all([
prisma.wallet.findUnique({ where: { userId } }),
prisma.ledgerEntry.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
take: 80,
select: { delta: true, type: true, createdAt: true, memo: true },
}),
prisma.donation.findMany({
where: { userId, status: "succeeded" },
select: { creditsAwarded: true, amountUsdCents: true },
}),
prisma.initiativeSpend.aggregate({
where: { userId },
_sum: { creditsSpent: true },
_count: true,
}),
prisma.missionSpend.aggregate({
where: { userId },
_sum: { creditsSpent: true },
_count: true,
}),
]);
const balance = wallet?.balanceCredits ?? 0;
let earned = 0;
let spent = 0;
const spendByCategory: Record<string, number> = {};
for (const e of entries) {
if (e.delta > 0) earned += e.delta;
else {
const amt = Math.abs(e.delta);
spent += amt;
const label = SPEND_LABELS[e.type] ?? "Other";
spendByCategory[label] = (spendByCategory[label] ?? 0) + amt;
}
}
let running = 0;
const balanceHistory = entries.map((e) => {
running += e.delta;
return { at: e.createdAt.toISOString(), balance: running };
});
const spendBreakdown = Object.entries(spendByCategory)
.map(([label, credits]) => ({ label, credits }))
.sort((a, b) => b.credits - a.credits);
const recentActivity = [...entries]
.reverse()
.slice(0, 8)
.map((e) => ({
delta: e.delta,
type: e.type,
memo: e.memo,
at: e.createdAt.toISOString(),
}));
const totalDonatedUsd = donations.reduce((s, d) => s + d.amountUsdCents, 0) / 100;
const creditsFromDonations = donations.reduce((s, d) => s + d.creditsAwarded, 0);
return NextResponse.json({
balance,
infiniteCredits: admin,
earned,
spent,
spendBreakdown,
balanceHistory,
recentActivity,
totalDonatedUsd,
creditsFromDonations,
initiativePledges: initiativePledges._sum.creditsSpent ?? 0,
initiativePledgeCount: initiativePledges._count,
missionPledges: missionPledges._sum.creditsSpent ?? 0,
missionPledgeCount: missionPledges._count,
});
}

View File

@@ -1,8 +1,10 @@
import { NextResponse } from "next/server";
import type Stripe from "stripe";
import { creditTicker } from "@/lib/credits-brand";
import { blwCreditsForUsdCents } from "@/lib/exchange";
import { prisma } from "@/lib/prisma";
import { creditsFromUsdCents, stripe } from "@/lib/stripe";
import { creditWalletCredits } from "@/lib/wallet-safety";
export const runtime = "nodejs";
@@ -30,20 +32,18 @@ export async function POST(req: Request) {
if (event.type === "payment_intent.succeeded") {
const pi = event.data.object as Stripe.PaymentIntent;
const userId = pi.metadata?.userId;
if (!userId) {
console.warn("payment_intent.succeeded without userId metadata", pi.id);
return NextResponse.json({ received: true });
}
const userId = pi.metadata?.userId?.trim() || null;
const amountUsdCents = pi.amount_received ?? pi.amount;
const blwSnap = pi.metadata?.blwUsdSnapshot ?? pi.metadata?.mtkUsdSnapshot;
const blwUsd = blwSnap ? parseFloat(blwSnap) : NaN;
const credits =
Number.isFinite(blwUsd) && blwUsd > 0
? blwCreditsForUsdCents(amountUsdCents, blwUsd)
: creditsFromUsdCents(amountUsdCents);
const donorEmail =
pi.metadata?.donorEmail?.trim() ||
(typeof pi.receipt_email === "string" ? pi.receipt_email.trim() : "") ||
null;
const donorName = pi.metadata?.donorName?.trim() || null;
try {
await prisma.$transaction(async (tx) => {
@@ -52,6 +52,47 @@ export async function POST(req: Request) {
});
if (existing) return;
if (!userId) {
await tx.donation.create({
data: {
stripePaymentIntentId: pi.id,
userId: null,
amountUsdCents,
creditsAwarded: 0,
currency: pi.currency,
status: pi.status ?? "succeeded",
donorEmail: donorEmail || null,
donorName,
},
});
return;
}
const user = await tx.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (!user) {
await tx.donation.create({
data: {
stripePaymentIntentId: pi.id,
userId: null,
amountUsdCents,
creditsAwarded: 0,
currency: pi.currency,
status: pi.status ?? "succeeded",
donorEmail: donorEmail || null,
donorName,
},
});
return;
}
const credits =
Number.isFinite(blwUsd) && blwUsd > 0
? blwCreditsForUsdCents(amountUsdCents, blwUsd)
: creditsFromUsdCents(amountUsdCents);
const donation = await tx.donation.create({
data: {
stripePaymentIntentId: pi.id,
@@ -63,16 +104,15 @@ export async function POST(req: Request) {
},
});
await tx.wallet.upsert({
where: { userId },
create: { userId, balanceCredits: credits },
update: { balanceCredits: { increment: credits } },
});
if (credits > 0) {
await creditWalletCredits(tx, userId, credits);
}
if (credits > 0) {
const tk = creditTicker();
const rateNote =
Number.isFinite(blwUsd) && blwUsd > 0
? `@ ${blwUsd.toFixed(4)} USD/BLW`
? `@ ${blwUsd.toFixed(4)} USD/${tk}`
: "(legacy ratio)";
await tx.ledgerEntry.create({
data: {
@@ -80,7 +120,7 @@ export async function POST(req: Request) {
delta: credits,
type: "CREDIT_DONATION",
donationId: donation.id,
memo: `Donation ${(amountUsdCents / 100).toFixed(2)} USD → ${credits} BLW ${rateNote}`,
memo: `Donation ${(amountUsdCents / 100).toFixed(2)} USD → ${credits} ${tk} ${rateNote}`,
},
});
}

50
src/app/apple-icon.tsx Normal file
View File

@@ -0,0 +1,50 @@
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const size = { width: 180, height: 180 };
export const contentType = "image/png";
export default function AppleIcon() {
return new ImageResponse(
(
<div
style={{
width: 180,
height: 180,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #0f172a 0%, #1e1b4b 100%)",
borderRadius: 40,
position: "relative",
overflow: "hidden",
}}
>
{/* Glow */}
<div
style={{
position: "absolute",
top: -20,
left: -20,
width: 120,
height: 120,
borderRadius: "50%",
background: "radial-gradient(circle, rgba(56,189,248,0.4) 0%, transparent 70%)",
}}
/>
<span
style={{
fontSize: 100,
fontWeight: 800,
color: "white",
fontFamily: "sans-serif",
lineHeight: 1,
}}
>
D
</span>
</div>
),
{ ...size },
);
}

199
src/app/billboard/page.tsx Normal file
View File

@@ -0,0 +1,199 @@
"use client";
import { SiteFooter } from "@/components/SiteFooter";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useCallback, useEffect, useRef, useState } from "react";
const TIERS = [
{ cost: 10, hours: 6, label: "6 h" },
{ cost: 25, hours: 18, label: "18 h" },
{ cost: 50, hours: 36, label: "36 h" },
{ cost: 100, hours: 72, label: "3 days" },
];
type Msg = {
id: string;
displayName: string;
message: string;
creditsSpent: number;
expiresAt: string;
createdAt: string;
};
function timeLeft(exp: string): string {
const ms = new Date(exp).getTime() - Date.now();
if (ms <= 0) return "expired";
const h = Math.floor(ms / 3_600_000);
const m = Math.floor((ms % 3_600_000) / 60_000);
return h > 0 ? `${h}h ${m}m left` : `${m}m left`;
}
export default function BillboardPage() {
const { data: session } = useSession();
const [messages, setMessages] = useState<Msg[]>([]);
const [text, setText] = useState("");
const [selectedTier, setSelectedTier] = useState(TIERS[0]);
const [posting, setPosting] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [loadError, setLoadError] = useState("");
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
const fetchMessages = useCallback(async () => {
try {
const res = await fetch("/api/billboard");
if (!res.ok) {
setLoadError("Could not load the billboard.");
setMessages([]);
return;
}
const data = (await res.json()) as { messages?: unknown };
setMessages(Array.isArray(data.messages) ? (data.messages as Msg[]) : []);
setLoadError("");
} catch {
setLoadError("Could not load the billboard.");
setMessages([]);
}
}, []);
useEffect(() => {
fetchMessages();
tickRef.current = setInterval(fetchMessages, 15_000);
return () => { if (tickRef.current) clearInterval(tickRef.current); };
}, [fetchMessages]);
async function post() {
if (!text.trim() || posting) return;
setPosting(true);
setError("");
setSuccess("");
const res = await fetch("/api/billboard", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: text.trim(), creditsSpent: selectedTier.cost }),
});
const data = await res.json();
setPosting(false);
if (!res.ok) { setError(data.error ?? "Failed"); return; }
setSuccess(`Posted! Visible for ${selectedTier.label}.`);
setText("");
fetchMessages();
}
return (
<>
<main className="min-h-screen border-b border-white/10 py-16">
<div className="mx-auto max-w-4xl px-4 sm:px-6">
{/* Header */}
<p className="text-xs uppercase tracking-[0.32em] text-sky-200/75">Community</p>
<h1 className="mt-3 text-4xl font-semibold text-white sm:text-5xl">Democracy Billboard</h1>
<p className="mt-4 max-w-2xl text-slate-400">
Spend Blue Wave Tokens to broadcast your rally cry to every visitor. Higher spend = longer display + more prominent placement on the ticker.
</p>
{loadError && (
<p className="mt-4 rounded-xl border border-amber-500/35 bg-amber-500/10 px-4 py-3 text-sm text-amber-100/95">
{loadError}{" "}
<button type="button" onClick={() => fetchMessages()} className="font-semibold text-white underline">
Retry
</button>
</p>
)}
{/* Live ticker */}
{messages.length > 0 && (
<div className="relative mt-10 overflow-hidden rounded-2xl border border-sky-500/25 bg-sky-950/30 py-4">
<div
className="flex animate-[marquee_30s_linear_infinite] gap-12 whitespace-nowrap"
style={{ animationDuration: `${Math.max(20, messages.length * 6)}s` }}
>
{[...messages, ...messages].map((m, i) => (
<span key={`${m.id}-${i}`} className="flex items-center gap-2 text-sm text-white">
<span className="h-1.5 w-1.5 rounded-full bg-sky-400" />
<span className="font-medium text-sky-200">{m.displayName}:</span>
<span>{m.message}</span>
<span className="ml-1 text-xs text-slate-500">· {timeLeft(m.expiresAt)}</span>
</span>
))}
</div>
</div>
)}
{/* Post form */}
<div className="mt-10 rounded-3xl border border-white/10 bg-white/[0.03] p-6">
<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.
</p>
) : (
<>
<textarea
value={text}
onChange={(e) => setText(e.target.value.slice(0, 140))}
placeholder="Write your rally cry… (max 140 characters)"
rows={3}
className="mt-4 w-full resize-none rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
/>
<p className="mt-1 text-right text-xs text-slate-600">{text.length}/140</p>
<div className="mt-4 flex flex-wrap gap-2">
{TIERS.map((t) => (
<button
key={t.cost}
onClick={() => setSelectedTier(t)}
className={`rounded-full border px-4 py-2 text-sm font-medium transition ${
selectedTier.cost === t.cost
? "border-sky-400 bg-sky-400/15 text-sky-200"
: "border-white/10 text-slate-400 hover:border-white/20"
}`}
>
{t.cost} BWT · {t.label}
</button>
))}
</div>
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
{success && <p className="mt-3 text-sm text-emerald-400">{success}</p>}
<button
onClick={post}
disabled={posting || !text.trim()}
className="mt-4 rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-8 py-2.5 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
>
{posting ? "Posting…" : `Broadcast for ${selectedTier.cost} BWT`}
</button>
</>
)}
</div>
{/* Message grid */}
<div className="mt-10">
<h2 className="text-lg font-semibold text-white">Active Messages</h2>
{messages.length === 0 ? (
<p className="mt-4 text-slate-500">No active messages yet. Be the first to broadcast!</p>
) : (
<div className="mt-4 grid gap-3 sm:grid-cols-2">
{messages.map((m) => (
<div
key={m.id}
className="rounded-2xl border border-white/10 bg-white/[0.03] p-4"
style={{ boxShadow: m.creditsSpent >= 50 ? "0 0 24px rgba(56,189,248,0.1)" : undefined }}
>
<p className="text-sm font-medium text-sky-200">{m.displayName}</p>
<p className="mt-1 text-sm text-white">"{m.message}"</p>
<div className="mt-2 flex items-center gap-3 text-xs text-slate-500">
<span>{m.creditsSpent} BWT</span>
<span>·</span>
<span>{timeLeft(m.expiresAt)}</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
</main>
<SiteFooter />
</>
);
}

255
src/app/boost/page.tsx Normal file
View File

@@ -0,0 +1,255 @@
"use client";
import { SiteFooter } from "@/components/SiteFooter";
import { creditTicker } from "@/lib/credits-brand";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
type BoostState = {
epoch: number;
total: number;
target: number;
pct: number;
filled: boolean;
contributors: number;
topContributors: { name: string; total: number }[];
milestoneBonus: string;
};
const PRESETS = [5, 10, 25, 50, 100, 250];
const MILESTONES = [
{ pct: 25, label: "Spark", icon: "✦", desc: "Movement ignites" },
{ pct: 50, label: "Wave", icon: "〰", desc: "Momentum building" },
{ pct: 75, label: "Surge", icon: "⚡", desc: "Power surge" },
{ pct: 100, label: "Filled", icon: "🔥", desc: "Milestone unlocked — bonuses paid out!" },
];
export default function BoostPage() {
const { data: session } = useSession();
const ticker = creditTicker();
const [state, setState] = useState<BoostState | null>(null);
const [amount, setAmount] = useState(25);
const [boosting, setBoosting] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [justFilled, setJustFilled] = useState(false);
const [meterLoading, setMeterLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const fetch_ = useCallback(async () => {
try {
const res = await fetch("/api/boost");
if (!res.ok) {
setLoadError("Could not load the community meter.");
return;
}
const j = (await res.json()) as BoostState;
setState(j);
setLoadError("");
} catch {
setLoadError("Could not load the community meter.");
} finally {
setMeterLoading(false);
}
}, []);
useEffect(() => { fetch_(); const t = setInterval(fetch_, 10_000); return () => clearInterval(t); }, [fetch_]);
async function boost() {
setBoosting(true); setError(""); setSuccess("");
const res = await fetch("/api/boost", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ creditsSpent: amount }),
});
const d = await res.json();
setBoosting(false);
if (!res.ok) { setError(d.error ?? "Failed"); return; }
if (d.filled) {
setJustFilled(true);
setSuccess(`🔥 METER FILLED! Epoch ${state?.epoch} complete — everyone gets +${state?.milestoneBonus ?? "15%"} back!`);
} else {
setSuccess(`+${d.spent} ${ticker} boosted! Meter at ${d.newPct}%`);
}
fetch_();
}
const pct = state?.pct ?? 0;
const nextMilestone = MILESTONES.find((m) => pct < m.pct);
return (
<>
<main className="min-h-screen border-b border-white/10 py-16">
<div className="mx-auto max-w-2xl px-4 sm:px-6">
<p className="text-xs uppercase tracking-[0.32em] text-fuchsia-300/80">Community Power</p>
<h1 className="mt-3 text-4xl font-semibold text-white sm:text-5xl">Power the Movement</h1>
<p className="mt-4 text-slate-400">
Every {ticker} you add charges the community energy meter. When it hits 5,000 {ticker}, the milestone fires and every contributor gets <span className="text-fuchsia-300 font-semibold">+{state?.milestoneBonus ?? "15%"}</span> back automatically. Then the meter resets and it starts again.
</p>
{/* Meter */}
{meterLoading && !state && (
<div className="mt-10 animate-pulse rounded-3xl border border-white/10 bg-white/[0.03] p-8">
<div className="h-4 w-48 rounded bg-white/10" />
<div className="mt-4 h-12 w-32 rounded bg-white/10" />
<div className="mt-4 h-4 w-full max-w-md rounded bg-white/10" />
<div className="mt-6 h-6 w-full rounded-full bg-white/5" />
</div>
)}
{loadError && !state && !meterLoading && (
<div className="mt-10 rounded-3xl border border-amber-500/35 bg-amber-500/10 px-5 py-4 text-sm text-amber-100/95">
{loadError}{" "}
<button type="button" onClick={() => { setMeterLoading(true); fetch_(); }} className="font-semibold text-white underline">
Retry
</button>
</div>
)}
{state && (
<div className="mt-10 rounded-3xl border border-white/10 bg-white/[0.03] p-8">
<div className="flex items-end justify-between">
<div>
<p className="text-xs uppercase tracking-widest text-slate-400">Community Meter Epoch {state.epoch}</p>
<p className="mt-2 text-5xl font-bold text-white">{pct}%</p>
<p className="mt-1 text-sm text-slate-400">
{state.total.toLocaleString()} / {state.target.toLocaleString()} {ticker} ·{" "}
{state.contributors} contributor{state.contributors !== 1 ? "s" : ""}
</p>
</div>
<div className="text-right">
<p className="text-xs text-slate-500">Milestone bonus</p>
<p className="text-2xl font-bold text-fuchsia-300">{state.milestoneBonus}</p>
</div>
</div>
{/* Progress bar */}
<div className="relative mt-6 h-6 overflow-hidden rounded-full border border-white/10 bg-black/30">
<div
className={`absolute left-0 top-0 h-full rounded-full transition-all duration-700 ${
pct >= 100
? "bg-gradient-to-r from-fuchsia-500 via-purple-400 to-pink-400"
: "bg-gradient-to-r from-sky-500 via-indigo-400 to-fuchsia-500"
}`}
style={{ width: `${pct}%` }}
/>
{/* Milestone ticks */}
{[25, 50, 75].map((m) => (
<div
key={m}
className="absolute top-0 h-full w-px bg-white/20"
style={{ left: `${m}%` }}
/>
))}
</div>
{/* Milestone badges */}
<div className="mt-3 flex justify-between text-xs text-slate-600">
<span>0</span>
{MILESTONES.slice(0, 3).map((m) => (
<span key={m.pct} className={pct >= m.pct ? "text-fuchsia-300 font-semibold" : ""}>
{m.icon} {m.label}
</span>
))}
<span>5,000 {ticker}</span>
</div>
{/* Celebration banner */}
{(state.filled || justFilled) && (
<div className="mt-5 rounded-2xl border border-fuchsia-500/30 bg-fuchsia-950/30 px-5 py-4 text-center">
<p className="text-lg font-bold text-fuchsia-300">🔥 Milestone Complete!</p>
<p className="mt-1 text-sm text-slate-300">
Epoch {state.epoch} filled! All contributors received {state.milestoneBonus} {ticker} bonus. Epoch {state.epoch + 1} starting now.
</p>
</div>
)}
{/* Next milestone teaser */}
{nextMilestone && !state.filled && (
<p className="mt-4 text-center text-xs text-slate-500">
{nextMilestone.icon} Next: <span className="text-fuchsia-300">{nextMilestone.label}</span> {nextMilestone.desc}
{" · "}{(state.target * nextMilestone.pct / 100 - state.total).toLocaleString()} {ticker} to go
</p>
)}
</div>
)}
{loadError && state && (
<p className="mt-3 text-center text-xs text-amber-200/90">
{loadError}{" "}
<button type="button" onClick={() => fetch_()} className="underline">
Refresh
</button>
</p>
)}
{/* Boost form */}
<div className="mt-6 rounded-3xl border border-white/10 bg-white/[0.03] p-6">
{!session ? (
<p className="text-slate-400">
<Link href="/register" className="text-sky-300 hover:underline">Join</Link> or{" "}
<Link href="/login?callbackUrl=%2Fboost" className="text-sky-300 hover:underline">sign in</Link> to boost the meter.
</p>
) : (
<>
<h2 className="text-lg font-semibold text-white">Add your boost</h2>
<div className="mt-4 flex flex-wrap gap-2">
{PRESETS.map((p) => (
<button
key={p}
onClick={() => setAmount(p)}
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition ${
amount === p
? "border-fuchsia-400 bg-fuchsia-400/15 text-fuchsia-200"
: "border-white/10 text-slate-400 hover:border-white/20"
}`}
>
{p}
</button>
))}
<input
type="number"
min={5}
max={1000}
value={amount}
onChange={(e) => setAmount(Math.max(5, Math.min(1000, Number(e.target.value))))}
className="w-24 rounded-full border border-white/10 bg-white/5 px-4 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-fuchsia-500"
/>
</div>
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
{success && <p className="mt-3 text-sm text-emerald-400">{success}</p>}
<button
onClick={boost}
disabled={boosting}
className="mt-5 rounded-full bg-gradient-to-r from-fuchsia-500 to-purple-500 px-8 py-2.5 text-sm font-semibold text-white shadow-[0_0_24px_rgba(217,70,239,0.35)] disabled:opacity-40"
>
{boosting ? "Charging…" : `⚡ Boost ${amount} ${ticker}`}
</button>
</>
)}
</div>
{/* Leaderboard */}
{state && state.topContributors.length > 0 && (
<div className="mt-8">
<h2 className="text-sm font-semibold uppercase tracking-widest text-slate-400">Top boosters this epoch</h2>
<div className="mt-3 space-y-2">
{state.topContributors.map((c, i) => (
<div key={i} className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3">
<div className="flex items-center gap-3">
<span className="w-5 text-sm text-slate-500">{i + 1}</span>
<span className="text-sm font-medium text-white">{c.name}</span>
</div>
<span className="text-sm text-fuchsia-300">{c.total.toLocaleString()} {ticker}</span>
</div>
))}
</div>
</div>
)}
</div>
</main>
<SiteFooter />
</>
);
}

206
src/app/cards/page.tsx Normal file
View File

@@ -0,0 +1,206 @@
"use client";
import { SiteFooter } from "@/components/SiteFooter";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
const TIERS = [
{ tier: 1, label: "Supporter", cost: 20, gradient: "from-sky-400 to-cyan-500", glow: "rgba(56,189,248,0.35)" },
{ tier: 2, label: "Champion", cost: 50, gradient: "from-indigo-400 to-purple-500", glow: "rgba(129,140,248,0.35)" },
{ tier: 3, label: "Legend", cost: 100, gradient: "from-amber-400 to-orange-500", glow: "rgba(245,158,11,0.35)" },
];
type Card = {
id: string;
tier: number;
serialNumber: number;
statsSnapshot: {
totalDonatedUsd: number;
creditsEarned: number;
donationCount: number;
tierLabel: string;
mintedAt: string;
};
createdAt: string;
user?: { name: string | null };
};
function normalizeSnapshot(raw: Card["statsSnapshot"] | null | undefined): Card["statsSnapshot"] {
const r = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
return {
totalDonatedUsd: typeof r.totalDonatedUsd === "number" && !Number.isNaN(r.totalDonatedUsd) ? r.totalDonatedUsd : 0,
creditsEarned: typeof r.creditsEarned === "number" && !Number.isNaN(r.creditsEarned) ? r.creditsEarned : 0,
donationCount: typeof r.donationCount === "number" && r.donationCount >= 0 ? r.donationCount : 0,
tierLabel: typeof r.tierLabel === "string" ? r.tierLabel : "Supporter",
mintedAt: typeof r.mintedAt === "string" ? r.mintedAt : "",
};
}
function TradingCard({ card, showUser }: { card: Card; showUser?: boolean }) {
const tierDef = TIERS.find((t) => t.tier === card.tier) ?? TIERS[0];
const s = normalizeSnapshot(card.statsSnapshot);
const stars = "★".repeat(card.tier) + "☆".repeat(3 - card.tier);
return (
<div
className="relative overflow-hidden rounded-[20px] border border-white/10 p-5 transition-transform hover:scale-[1.02]"
style={{
background: `linear-gradient(135deg, #0f172a, #1e1b4b)`,
boxShadow: `0 0 40px ${tierDef.glow}`,
}}
>
{/* Tier stripe */}
<div className={`absolute left-0 top-0 h-1.5 w-full bg-gradient-to-r ${tierDef.gradient}`} />
<div className="mt-1 flex items-start justify-between">
<div>
<p className={`text-xs font-bold uppercase tracking-widest bg-gradient-to-r ${tierDef.gradient} bg-clip-text text-transparent`}>
{tierDef.label}
</p>
{showUser && card.user?.name && (
<p className="mt-0.5 text-sm font-semibold text-white">{card.user.name}</p>
)}
</div>
<span className="text-base text-amber-300">{stars}</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="rounded-xl bg-white/5 p-3 text-center">
<p className="text-lg font-bold text-white">${s.totalDonatedUsd.toFixed(0)}</p>
<p className="text-xs text-slate-400">Donated</p>
</div>
<div className="rounded-xl bg-white/5 p-3 text-center">
<p className="text-lg font-bold text-white">{s.creditsEarned.toLocaleString()}</p>
<p className="text-xs text-slate-400">BWT Earned</p>
</div>
</div>
<div className="mt-3 rounded-xl bg-white/5 p-3 text-center">
<p className="text-sm font-semibold text-white">{s.donationCount} Donation{s.donationCount !== 1 ? "s" : ""}</p>
</div>
<p className="mt-3 text-right font-mono text-xs text-slate-600">#{card.serialNumber}</p>
</div>
);
}
export default function CardsPage() {
const { data: session } = useSession();
const [myCards, setMyCards] = useState<Card[]>([]);
const [hallCards, setHallCards] = useState<Card[]>([]);
const [tab, setTab] = useState<"my" | "hall">("hall");
const [minting, setMinting] = useState(false);
const [selectedTier, setSelectedTier] = useState(1);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const fetchHall = useCallback(async () => {
const res = await fetch("/api/cards?hall=1");
if (res.ok) setHallCards((await res.json()).cards ?? []);
}, []);
const fetchMine = useCallback(async () => {
const res = await fetch("/api/cards");
if (res.ok) setMyCards((await res.json()).cards ?? []);
}, []);
useEffect(() => {
fetchHall();
if (session?.user) fetchMine();
}, [session, fetchHall, fetchMine]);
async function mint() {
setMinting(true); setError(""); setSuccess("");
const res = await fetch("/api/cards", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tier: selectedTier }),
});
const d = await res.json();
setMinting(false);
if (!res.ok) { setError(d.error ?? "Failed"); return; }
setSuccess(`${d.tier.label} card minted!`);
fetchMine(); fetchHall();
}
return (
<>
<main className="min-h-screen border-b border-white/10 py-16">
<div className="mx-auto max-w-5xl px-4 sm:px-6">
<p className="text-xs uppercase tracking-[0.32em] text-amber-300/80">Collectibles</p>
<h1 className="mt-3 text-4xl font-semibold text-white sm:text-5xl">Supporter Cards</h1>
<p className="mt-4 max-w-2xl text-slate-400">
Mint a collectible digital card that captures your supporter stats. Three tiers Supporter, Champion, Legend each with a unique visual and glow. Flex your commitment in the Hall of Champions.
</p>
{/* Mint panel */}
{session ? (
<div className="mt-10 rounded-3xl border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">Mint a new card</h2>
<div className="mt-4 grid gap-3 sm:grid-cols-3">
{TIERS.map((t) => (
<button
key={t.tier}
onClick={() => setSelectedTier(t.tier)}
className={`rounded-2xl border p-4 text-left transition ${
selectedTier === t.tier ? "border-indigo-400 bg-indigo-400/10" : "border-white/10 hover:border-white/20"
}`}
>
<p className={`font-bold text-sm bg-gradient-to-r ${t.gradient} bg-clip-text text-transparent uppercase tracking-widest`}>
{t.label}
</p>
<p className="mt-1 text-lg font-semibold text-white">{t.cost} BWT</p>
<p className="text-xs text-slate-500">{"★".repeat(t.tier)}{"☆".repeat(3 - t.tier)}</p>
</button>
))}
</div>
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
{success && <p className="mt-3 text-sm text-emerald-400"> {success}</p>}
<button
onClick={mint}
disabled={minting}
className="mt-5 rounded-full bg-gradient-to-r from-amber-500 to-orange-500 px-8 py-2.5 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
>
{minting ? "Minting…" : `Mint ${TIERS.find((t) => t.tier === selectedTier)?.label} Card`}
</button>
</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>
</div>
)}
{/* Tabs */}
<div className="mt-10 flex gap-4 border-b border-white/10 pb-0">
{(["hall", "my"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`pb-3 text-sm font-medium transition ${
tab === t ? "border-b-2 border-sky-400 text-white" : "text-slate-500 hover:text-slate-300"
}`}
>
{t === "hall" ? "Hall of Champions" : "My Cards"}
</button>
))}
</div>
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{tab === "hall"
? hallCards.length > 0
? hallCards.map((c) => <TradingCard key={c.id} card={c} showUser />)
: <p className="col-span-full text-slate-500">No cards minted yet be the first!</p>
: myCards.length > 0
? myCards.map((c) => <TradingCard key={c.id} card={c} />)
: <p className="col-span-full text-slate-500">{session ? "You haven't minted any cards yet." : "Sign in to view your cards."}</p>
}
</div>
</div>
</main>
<SiteFooter />
</>
);
}

View File

@@ -0,0 +1,95 @@
import { redirect, notFound } from "next/navigation";
import Link from "next/link";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { creditTicker } from "@/lib/credits-brand";
import { SiteFooter } from "@/components/SiteFooter";
import { ExchangePanel } from "@/components/casino/ExchangePanel";
import { CrashGame } from "@/components/casino/CrashGame";
import { DiceGame } from "@/components/casino/DiceGame";
import { MinesGame } from "@/components/casino/MinesGame";
import { TowerGame } from "@/components/casino/TowerGame";
import { SlotsGame } from "@/components/casino/SlotsGame";
import { BlackjackGame } from "@/components/casino/BlackjackGame";
import { RouletteGame } from "@/components/casino/RouletteGame";
import { CoinFlipRoom } from "@/components/casino/CoinFlipRoom";
import { PongGame } from "@/components/casino/PongGame";
import { PredictionMarket } from "@/components/casino/PredictionMarket";
const T = creditTicker();
const GAME_META: Record<string, { name: string; icon: string; desc: string }> = {
crash: { name: `${T} Crash`, icon: "📈", desc: "Multiplier rises from 1x — cash out before it crashes. Provably fair." },
dice: { name: "Dice Roll", icon: "🎲", desc: "Pick over or under a threshold. Adjust your risk and win chance live." },
mines: { name: "Mines", icon: "💣", desc: "5×5 grid — reveal tiles to grow your multiplier and cash out before hitting a mine." },
tower: { name: "Tower Climb", icon: "🏰", desc: "Pick a safe tile on each floor to climb higher and earn bigger multipliers." },
slots: { name: "Slots", icon: "🎰", desc: "3-reel classic slot machine with configurable seeds and a full paytable." },
blackjack: { name: "Blackjack", icon: "🃏", desc: "Classic single-deck blackjack. Hit, stand, or double down against the house." },
roulette: { name: "Roulette", icon: "🎡", desc: "European roulette (single zero). Bet on numbers, colors, dozens, and more." },
coinflip: { name: "Coin Flip Duel", icon: "🪙", desc: "Create or join a room. Both players wager the same amount — the flip decides." },
pong: { name: "PvP Pong", icon: "🏓", desc: `Real-time pong against another player. Bet ${T} — first to 5 wins the pot.` },
prediction: { name: "Prediction Market", icon: "📊", desc: `Post a yes/no question. Users pool ${T} — correct side splits the pot.` },
};
export default async function GamePage({ params }: { params: Promise<{ game: string }> }) {
const { game } = await params;
const meta = GAME_META[game];
if (!meta) notFound();
const session = await auth();
if (!session?.user?.id) redirect(`/login?callbackUrl=${encodeURIComponent(`/casino/${game}`)}`);
const wallet = await prisma.wallet.findUnique({ where: { userId: session.user.id } });
const balance = wallet?.balanceCredits ?? 0;
const userId = session.user.id;
function GameComponent() {
switch (game) {
case "crash": return <CrashGame balance={balance} />;
case "dice": return <DiceGame balance={balance} />;
case "mines": return <MinesGame balance={balance} />;
case "tower": return <TowerGame balance={balance} />;
case "slots": return <SlotsGame balance={balance} />;
case "blackjack": return <BlackjackGame balance={balance} />;
case "roulette": return <RouletteGame balance={balance} />;
case "coinflip": return <CoinFlipRoom userId={userId} balance={balance} />;
case "pong": return <PongGame userId={userId} balance={balance} />;
case "prediction": return <PredictionMarket userId={userId} balance={balance} />;
default: return null;
}
}
return (
<>
<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
</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">
<div className="space-y-4">
<ExchangePanel initialBalance={balance} />
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="flex items-center gap-3 mb-2">
<span className="text-3xl">{meta.icon}</span>
<h1 className="text-xl font-bold text-white">{meta.name}</h1>
</div>
<p className="text-slate-400 text-sm">{meta.desc}</p>
</div>
</div>
<div className="lg:col-span-2 rounded-2xl border border-white/10 bg-white/5 p-5">
<GameComponent />
</div>
</div>
</div>
</main>
<SiteFooter />
</>
);
}

97
src/app/casino/page.tsx Normal file
View File

@@ -0,0 +1,97 @@
import { redirect } from "next/navigation";
import Link from "next/link";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import { SiteFooter } from "@/components/SiteFooter";
import { ExchangePanel } from "@/components/casino/ExchangePanel";
import { GameHistory } from "@/components/casino/GameHistory";
const T = creditTicker();
const CREDIT_LONG = creditDisplayName();
const GAMES = [
{ slug: "crash", icon: "📈", name: `${T} Crash`, desc: "Cash out before it crashes", tag: "Solo", color: "from-orange-500 to-red-500" },
{ slug: "dice", icon: "🎲", name: "Dice Roll", desc: "Over/under with custom odds", tag: "Solo", color: "from-blue-500 to-cyan-500" },
{ slug: "mines", icon: "💣", name: "Mines", desc: "Navigate the minefield", tag: "Solo", color: "from-yellow-500 to-orange-500" },
{ slug: "tower", icon: "🏰", name: "Tower Climb", desc: "Climb higher for bigger rewards", tag: "Solo", color: "from-emerald-500 to-teal-500" },
{ slug: "slots", icon: "🎰", name: "Slots", desc: "Classic 3-reel slot machine", tag: "Solo", color: "from-purple-500 to-pink-500" },
{ slug: "blackjack", icon: "🃏", name: "Blackjack", desc: "Beat the dealer to 21", tag: "vs House", color: "from-slate-500 to-gray-600" },
{ slug: "roulette", icon: "🎡", name: "Roulette", desc: "European single-zero", tag: "vs House", color: "from-rose-500 to-red-600" },
{ slug: "coinflip", icon: "🪙", name: "Coin Flip Duel", desc: "Challenge another player", tag: "PvP", color: "from-sky-500 to-blue-600" },
{ slug: "pong", icon: "🏓", name: "PvP Pong", desc: `Pong for real ${T}`, tag: "PvP", color: "from-indigo-500 to-violet-600" },
{ slug: "prediction", icon: "📊", name: "Prediction Market", desc: `Pool ${T} on outcomes`, tag: "Community", color: "from-teal-500 to-cyan-600" },
];
const TAG_COLORS: Record<string, string> = {
Solo: "bg-sky-900/50 text-sky-300",
"vs House": "bg-red-900/50 text-red-300",
PvP: "bg-purple-900/50 text-purple-300",
Community: "bg-emerald-900/50 text-emerald-300",
};
export default async function CasinoLobby() {
const session = await auth();
if (!session?.user?.id) redirect(`/login?callbackUrl=${encodeURIComponent("/casino")}`);
const wallet = await prisma.wallet.findUnique({ where: { userId: session.user.id } });
const balance = wallet?.balanceCredits ?? 0;
return (
<>
<main className="min-h-screen bg-[#030712] px-4 py-8 sm:px-6">
<div className="mx-auto max-w-6xl space-y-8">
{/* Header */}
<div>
<h1 className="text-3xl font-black text-white">
<span className="bg-gradient-to-r from-sky-300 via-indigo-300 to-fuchsia-300 bg-clip-text text-transparent">
{T} · supporter games
</span>
</h1>
<p className="text-slate-400 mt-1">
Wager {CREDIT_LONG} provably fair solo games, house tables, and PvP rooms.
</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Exchange Panel + History */}
<div className="space-y-5">
<ExchangePanel initialBalance={balance} />
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur-sm">
<h2 className="text-sm font-semibold text-white mb-3">Recent Games</h2>
<GameHistory />
</div>
</div>
{/* Game Grid */}
<div className="lg:col-span-2 grid grid-cols-1 sm:grid-cols-2 gap-3">
{GAMES.map(game => (
<Link
key={game.slug}
href={`/casino/${game.slug}`}
className="group relative rounded-2xl border border-white/10 bg-white/5 p-5 hover:bg-white/8 hover:border-white/20 transition-all duration-200 overflow-hidden"
>
<div className={`absolute inset-0 bg-gradient-to-br ${game.color} opacity-0 group-hover:opacity-5 transition-opacity`} />
<div className="relative">
<div className="flex items-start justify-between mb-3">
<span className="text-3xl">{game.icon}</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${TAG_COLORS[game.tag]}`}>{game.tag}</span>
</div>
<h3 className="text-white font-semibold">{game.name}</h3>
<p className="text-slate-400 text-sm mt-0.5">{game.desc}</p>
</div>
</Link>
))}
</div>
</div>
<p className="text-xs text-slate-600 text-center">
{CREDIT_LONG} ({T}) have no cash redemption value and are not withdrawable. Use is limited to this authorized portal under
committee rules. Solo games employ HMAC-SHA256 provably fair randomness.
</p>
</div>
</main>
<SiteFooter />
</>
);
}

153
src/app/donate/page.tsx Normal file
View File

@@ -0,0 +1,153 @@
import type { Metadata } from "next";
import Link from "next/link";
import { auth } from "@/auth";
import { EmbeddedDonationCheckout } from "@/components/EmbeddedDonationCheckout";
import { SiteFooter } from "@/components/SiteFooter";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import { appTitle, siteUrl } from "@/lib/public-env";
const TITLE = appTitle();
const CREDIT_NAME = creditDisplayName();
const CREDIT_TICKER = creditTicker();
const BASE_URL = siteUrl();
export const metadata: Metadata = {
title: `Donate — ${TITLE}`,
description: `Make a secure Stripe donation to ${TITLE} starting at $5. Donate as a guest or sign in to earn ${CREDIT_NAME} (${CREDIT_TICKER}).`,
alternates: { canonical: `${BASE_URL}/donate` },
openGraph: {
title: `Donate to ${TITLE}`,
description: `Secure Stripe checkout. Donate as a guest or sign in to earn ${CREDIT_NAME} (${CREDIT_TICKER}).`,
url: `${BASE_URL}/donate`,
siteName: TITLE,
images: [{ url: "/opengraph-image", width: 1200, height: 630, alt: `${TITLE} — donate` }],
},
twitter: {
card: "summary_large_image",
title: `Donate to ${TITLE}`,
description: `Secure Stripe checkout. Donate as a guest or sign in to earn ${CREDIT_NAME} (${CREDIT_TICKER}).`,
images: ["/opengraph-image"],
},
};
const donateJsonLd = {
"@context": "https://schema.org",
"@type": "DonateAction",
"@id": `${BASE_URL}/donate#donate-action`,
name: `Donate to ${TITLE}`,
description: `Support ${TITLE} with a secure small-dollar contribution starting at $5.`,
url: `${BASE_URL}/donate`,
recipient: {
"@type": "Organization",
name: TITLE,
url: BASE_URL,
},
};
export default async function DonatePage() {
const session = await auth();
const loggedIn = !!session?.user;
const publishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "";
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(donateJsonLd) }}
/>
<main className="relative min-h-screen overflow-hidden border-b border-white/10 px-4 py-10 sm:px-6 sm:py-14">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_70%_45%_at_50%_0%,rgba(99,102,241,0.18),transparent_55%),radial-gradient(ellipse_60%_45%_at_50%_100%,rgba(56,189,248,0.12),transparent_55%)]" />
<div className="relative mx-auto max-w-3xl">
<div className="text-center">
<p className="text-xs font-medium uppercase tracking-[0.32em] text-sky-300/90">
Secure Stripe checkout
</p>
<h1 className="mt-3 text-balance text-3xl font-semibold tracking-tight text-white sm:text-4xl">
Donate to {TITLE}
</h1>
<p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-slate-300">
Pick a fixed tier <span className="text-white">$5, $10, $20, or $100</span>. Your gift lights up the public meter
the moment it clears.
</p>
</div>
<div
className={`mx-auto mt-8 max-w-2xl rounded-2xl border px-5 py-4 text-center text-sm sm:text-[15px] ${
loggedIn
? "border-emerald-500/35 bg-emerald-500/10 text-emerald-100"
: "border-amber-500/35 bg-amber-500/10 text-amber-50"
}`}
>
{loggedIn ? (
<p className="leading-relaxed">
<strong className="text-white">Signed in.</strong> Your donation will deposit {CREDIT_NAME} ({CREDIT_TICKER})
in your wallet right after Stripe confirms the charge.
</p>
) : (
<div className="space-y-3">
<p className="leading-relaxed">
<strong className="text-white">Two paths, same secure checkout:</strong>
</p>
<div className="flex flex-col items-stretch justify-center gap-2 sm:flex-row sm:items-center">
<Link
href={`/login?callbackUrl=${encodeURIComponent("/donate")}`}
className="rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-500/25 hover:opacity-95"
>
Sign in to earn {CREDIT_TICKER}
</Link>
<span className="px-1 text-xs uppercase tracking-[0.2em] text-amber-200/80">or</span>
<a
href="#donation-form"
className="rounded-full border border-white/25 px-5 py-2.5 text-sm font-semibold text-white hover:bg-white/5"
>
Continue as guest
</a>
</div>
<p className="text-xs leading-relaxed text-amber-100/90">
New here?{" "}
<Link
href={`/register?callbackUrl=${encodeURIComponent("/donate")}`}
className="font-semibold text-white underline"
>
Create a free account
</Link>{" "}
in under a minute same gift, plus {CREDIT_TICKER} unlocks the wallet, missions, initiatives, and games.
</p>
</div>
)}
</div>
<section
id="donation-form"
className="mt-8 rounded-3xl border border-white/10 bg-[#050816]/90 p-6 shadow-[0_0_80px_rgba(59,130,246,0.12)] backdrop-blur-xl sm:p-8"
>
<EmbeddedDonationCheckout publishableKey={publishableKey} />
</section>
<div className="mt-8 grid gap-3 sm:grid-cols-3">
<Link
href="/raised"
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-center text-sm text-slate-200 hover:bg-white/10"
>
See the live board
</Link>
<Link
href="/missions"
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-center text-sm text-slate-200 hover:bg-white/10"
>
Spend {CREDIT_TICKER} on missions
</Link>
<Link
href="/wallet"
className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-center text-sm text-slate-200 hover:bg-white/10"
>
Open your wallet
</Link>
</div>
</div>
</main>
<SiteFooter />
</>
);
}

View File

@@ -0,0 +1,107 @@
import type { Metadata } from "next";
import Link from "next/link";
import { SiteFooter } from "@/components/SiteFooter";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import { appTitle } from "@/lib/public-env";
import { stripe } from "@/lib/stripe";
const CREDIT_NAME = creditDisplayName();
const CREDIT_TICKER = creditTicker();
export const metadata: Metadata = {
title: `Thank you — ${appTitle()}`,
description: `Thank you for supporting ${appTitle()}. Your gift fuels the live meter; join to unlock the full supporter experience.`,
robots: { index: false, follow: false },
};
type Confirmation = {
status: string | null;
paymentStatus: string | null;
amountUsd: number | null;
email: string | null;
};
async function loadConfirmation(sessionId: string | undefined): Promise<Confirmation | null> {
if (!sessionId) return null;
const sk = process.env.STRIPE_SECRET_KEY?.trim();
if (!sk || sk.includes("disabled_configure")) return null;
try {
const s = await stripe.checkout.sessions.retrieve(sessionId);
return {
status: s.status ?? null,
paymentStatus: s.payment_status ?? null,
amountUsd: s.amount_total != null ? s.amount_total / 100 : null,
email: s.customer_details?.email ?? null,
};
} catch {
return null;
}
}
export default async function DonateThankYouPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const params = await searchParams;
const sid = typeof params.session_id === "string" ? params.session_id : undefined;
const conf = await loadConfirmation(sid);
const confirmed = conf?.paymentStatus === "paid" || conf?.status === "complete";
return (
<>
<main className="min-h-[60vh] border-b border-white/10 px-4 py-16 sm:px-6">
<div className="mx-auto max-w-lg text-center">
<p className="text-xs uppercase tracking-[0.28em] text-emerald-300/90">
{confirmed ? "Contribution confirmed" : "Contribution received"}
</p>
<h1 className="mt-4 text-3xl font-semibold text-white">Thank you</h1>
{conf && confirmed ? (
<div className="mt-6 rounded-2xl border border-emerald-500/35 bg-emerald-500/10 px-5 py-4 text-sm text-emerald-100">
<p>
<strong className="text-white">
${conf.amountUsd?.toFixed(2) ?? "—"}
</strong>{" "}
processed by Stripe
{conf.email ? (
<>
{" "} receipt sent to <strong className="text-white">{conf.email}</strong>
</>
) : null}
.
</p>
</div>
) : null}
<p className="mt-4 text-slate-400">
You&apos;re officially on Team Wave. Your dollars roll into the live meter as soon as they clear guest gifts move the
needle for everyone; {CREDIT_NAME} ({CREDIT_TICKER}) perks unlock when you&apos;re signed in at checkout.
</p>
<p className="mt-4 rounded-2xl border border-amber-500/35 bg-amber-500/10 px-4 py-3 text-sm text-amber-100/95">
Want the wallet, games, raffles, and missions?{" "}
<Link href="/register" className="font-semibold text-white underline">
Create a supporter account
</Link>{" "}
and chip in while you&apos;re logged in {CREDIT_TICKER} lands in your pocket automatically.
</p>
<div className="mt-10 flex flex-wrap justify-center gap-3">
<Link
href="/login"
className="rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-6 py-3 text-sm font-semibold text-white"
>
Sign in
</Link>
<Link href="/raised" className="rounded-full border border-white/15 px-6 py-3 text-sm font-semibold text-white hover:bg-white/5">
See the live board
</Link>
<Link href="/" className="rounded-full border border-white/15 px-6 py-3 text-sm font-semibold text-white hover:bg-white/5">
Home
</Link>
</div>
</div>
</main>
<SiteFooter />
</>
);
}

210
src/app/faq-board/page.tsx Normal file
View File

@@ -0,0 +1,210 @@
"use client";
import { SiteFooter } from "@/components/SiteFooter";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
type Submission = {
id: string;
displayName: string;
question: string;
voteTotal: number;
creditsSpent: number;
createdAt: string;
};
type Approved = { id: string; question: string; answer: string | null; voteTotal: number };
type FaqBoardPayload = {
pending: Submission[];
approved: Approved[];
signedIn?: boolean;
};
export default function FaqBoardPage() {
const { data: session } = useSession();
const [pending, setPending] = useState<Submission[]>([]);
const [approved, setApproved] = useState<Approved[]>([]);
const [tab, setTab] = useState<"vote" | "ask" | "approved">("vote");
const [question, setQuestion] = useState("");
const [submitting, setSubmitting] = useState(false);
const [voting, setVoting] = useState<string | null>(null);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [loadError, setLoadError] = useState("");
const fetchData = useCallback(async () => {
try {
const res = await fetch("/api/faq");
if (!res.ok) {
setLoadError("Could not load the FAQ board.");
return;
}
const d = (await res.json()) as FaqBoardPayload;
setPending(Array.isArray(d.pending) ? d.pending : []);
setApproved(Array.isArray(d.approved) ? d.approved : []);
setLoadError("");
} catch {
setLoadError("Could not load the FAQ board.");
}
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
async function submit() {
if (!question.trim()) return;
setSubmitting(true); setError(""); setSuccess("");
const res = await fetch("/api/faq", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question: question.trim() }),
});
const d = await res.json();
setSubmitting(false);
if (!res.ok) { setError(d.error ?? "Failed"); return; }
setSuccess("Question submitted! It will appear in the voting queue once reviewed.");
setQuestion("");
fetchData();
}
async function vote(submissionId: string) {
setVoting(submissionId); setError(""); setSuccess("");
const res = await fetch("/api/faq", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "vote", submissionId }),
});
const d = await res.json();
setVoting(null);
if (!res.ok) { setError(d.error ?? "Failed"); return; }
setSuccess("Vote recorded!");
fetchData();
}
return (
<>
<main className="min-h-screen border-b border-white/10 py-16">
<div className="mx-auto max-w-3xl px-4 sm:px-6">
<p className="text-xs uppercase tracking-[0.32em] text-purple-300/80">Community</p>
<h1 className="mt-3 text-4xl font-semibold text-white sm:text-5xl">Community FAQ Board</h1>
<p className="mt-4 max-w-2xl text-slate-400">
Spend 10 BWT to submit a question for the team to answer publicly. Spend 1 BWT to upvote questions you want answered most top questions get answered first and added to the live FAQ.
</p>
{loadError && (
<p className="mt-4 rounded-xl border border-amber-500/35 bg-amber-500/10 px-4 py-3 text-sm text-amber-100/95">
{loadError}{" "}
<button type="button" onClick={() => fetchData()} className="font-semibold text-white underline">
Retry
</button>
</p>
)}
{/* Tabs */}
<div className="mt-8 flex gap-4 border-b border-white/10">
{(["vote", "ask", "approved"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`pb-3 text-sm font-medium capitalize transition ${
tab === t ? "border-b-2 border-purple-400 text-white" : "text-slate-500 hover:text-slate-300"
}`}
>
{t === "vote" ? `Vote (${pending.length})` : t === "ask" ? "Ask a Question" : `Answered (${approved.length})`}
</button>
))}
</div>
{/* Error / success */}
{error && <p className="mt-4 text-sm text-red-400">{error}</p>}
{success && <p className="mt-4 text-sm text-emerald-400">{success}</p>}
{/* Vote tab */}
{tab === "vote" && (
<div className="mt-6 space-y-3">
{!session ? (
<p className="text-slate-400">
<Link href="/login?callbackUrl=%2Ffaq-board" className="text-sky-300 hover:underline">Sign in</Link> to view
and upvote pending questions.
</p>
) : pending.length === 0 ? (
<p className="text-slate-500">No questions in queue yet. Be the first to ask!</p>
) : (
pending.map((s) => (
<div key={s.id} className="flex items-start gap-4 rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div className="flex-1">
<p className="text-sm font-medium text-white">{s.question}</p>
<p className="mt-1 text-xs text-slate-500">Asked by {s.displayName}</p>
</div>
<div className="flex flex-col items-center gap-1">
<button
onClick={() => vote(s.id)}
disabled={!session || voting === s.id}
className="flex h-9 w-9 items-center justify-center rounded-xl bg-purple-500/20 text-purple-300 transition hover:bg-purple-500/30 disabled:opacity-40"
title={session ? "Upvote (1 BWT)" : "Sign in to vote"}
>
{voting === s.id ? "…" : "▲"}
</button>
<span className="text-xs font-semibold text-white">{s.voteTotal}</span>
<span className="text-xs text-slate-600">1 BWT</span>
</div>
</div>
))
)}
</div>
)}
{/* Ask tab */}
{tab === "ask" && (
<div className="mt-6">
{!session ? (
<p className="text-slate-400"><Link href="/login?callbackUrl=%2Ffaq-board" className="text-sky-300 hover:underline">Sign in</Link> to submit a question (costs 10 BWT).</p>
) : (
<>
<textarea
value={question}
onChange={(e) => setQuestion(e.target.value.slice(0, 280))}
placeholder="What do you want the Democracy Rising team to answer? (10280 chars)"
rows={4}
className="w-full resize-none rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-purple-500"
/>
<p className="mt-1 flex justify-between text-xs text-slate-600">
<span>10 BWT to submit</span>
<span>{question.length}/280</span>
</p>
<button
onClick={submit}
disabled={submitting || question.trim().length < 10}
className="mt-4 rounded-full bg-gradient-to-r from-purple-500 to-indigo-500 px-8 py-2.5 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
>
{submitting ? "Submitting…" : "Submit for 10 BWT"}
</button>
</>
)}
</div>
)}
{/* Approved tab */}
{tab === "approved" && (
<div className="mt-6 space-y-4">
{approved.length === 0 ? (
<p className="text-slate-500">No answered questions yet. Keep voting!</p>
) : (
approved.map((a) => (
<div key={a.id} className="rounded-2xl border border-emerald-500/20 bg-emerald-950/20 p-5">
<p className="font-medium text-white">{a.question}</p>
{a.answer && (
<p className="mt-3 rounded-xl bg-white/5 p-4 text-sm leading-relaxed text-slate-300">{a.answer}</p>
)}
<p className="mt-2 text-xs text-slate-600">{a.voteTotal} upvote{a.voteTotal !== 1 ? "s" : ""}</p>
</div>
))
)}
</div>
)}
</div>
</main>
<SiteFooter />
</>
);
}

View File

@@ -0,0 +1,43 @@
import { SiteFooter } from "@/components/SiteFooter";
import { appTitle } from "@/lib/public-env";
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: `Account recovery — ${appTitle()}`,
description: `Password assistance for authorized ${appTitle()} supporter accounts.`,
};
export default function ForgotPasswordPage() {
return (
<>
<div className="mx-auto max-w-lg px-4 py-20 sm:px-6">
<p className="text-xs uppercase tracking-[0.28em] text-sky-300/80">Secure account recovery</p>
<h1 className="mt-3 text-3xl font-semibold text-white">Reset credentials</h1>
<p className="mt-4 text-slate-400">
Automated password reset requires a configured mail provider or enterprise identity integration. Contact your committee
administrator for credential recovery through authorized channels.
</p>
<p className="mt-6 rounded-2xl border border-white/10 bg-white/5 p-5 text-sm text-slate-300">
<strong className="text-white">Official recovery:</strong> request assistance from the committees designated systems
administrator or treasurer using procedures established for authorized personnel.
</p>
<div className="mt-10 flex flex-wrap gap-4">
<Link
href="/login"
className="rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-6 py-3 text-sm font-semibold text-white"
>
Return to sign in
</Link>
<Link
href="/"
className="rounded-full border border-white/15 px-6 py-3 text-sm font-semibold text-white hover:bg-white/5"
>
Program home
</Link>
</div>
</div>
<SiteFooter />
</>
);
}

View File

@@ -7,6 +7,68 @@
--accent: #38bdf8;
}
/* ─── Custom animations ─────────────────────────────────────────────────── */
@keyframes shimmer {
0% { background-position: -200% center; }
100% { background-position: 200% center; }
}
@keyframes float-slow {
0%, 100% { transform: translateY(0px) rotate(0deg); }
33% { transform: translateY(-10px) rotate(1.5deg); }
66% { transform: translateY(-4px) rotate(-1deg); }
}
@keyframes glow-pulse {
0%, 100% { box-shadow: 0 0 24px rgba(56,189,248,0.15); }
50% { box-shadow: 0 0 48px rgba(56,189,248,0.32), 0 0 80px rgba(168,85,247,0.18); }
}
@keyframes wisp-drift {
0% { transform: translateX(0) scaleY(1); opacity: 0; }
15% { opacity: 1; }
80% { opacity: 0.6; }
100% { transform: translateX(40px) scaleY(0.7); opacity: 0; }
}
@keyframes ribbon-fall {
0% { transform: translateY(-10px) rotate(0deg); opacity: 1; }
100% { transform: translateY(60px) rotate(720deg); opacity: 0; }
}
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
@keyframes skeleton-shimmer {
0% { background-position: -400px 0; }
100% { background-position: 400px 0; }
}
/* Utility classes */
.animate-shimmer {
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.08), transparent);
background-size: 200% auto;
animation: shimmer 2.4s linear infinite;
}
.animate-float {
animation: float-slow 8s ease-in-out infinite;
}
.animate-glow-pulse {
animation: glow-pulse 4s ease-in-out infinite;
}
.skeleton {
background: linear-gradient(90deg, rgba(255,255,255,0.04) 25%, rgba(255,255,255,0.09) 50%, rgba(255,255,255,0.04) 75%);
background-size: 400px 100%;
animation: skeleton-shimmer 1.6s ease-in-out infinite;
border-radius: 0.75rem;
}
@theme inline {
--color-background: var(--bg);
--color-foreground: var(--fg);
@@ -16,6 +78,14 @@
html {
scroll-behavior: smooth;
/* Sticky nav offset when jumping to #anchors from any route */
scroll-padding-top: 5.5rem;
}
@media (max-width: 640px) {
html {
scroll-padding-top: 4.5rem;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -30,6 +100,17 @@ body {
color: var(--fg);
font-family: var(--font-geist-sans), system-ui, sans-serif;
min-height: 100vh;
min-height: 100dvh;
overflow-x: clip;
padding-bottom: env(safe-area-inset-bottom, 0px);
padding-left: env(safe-area-inset-left, 0px);
padding-right: env(safe-area-inset-right, 0px);
}
/* Faster taps on touch devices (legacy WebKit double-tap zoom mitigation) */
a,
button {
touch-action: manipulation;
}
::selection {

30
src/app/icon.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { ImageResponse } from "next/og";
export const runtime = "edge";
export const size = { width: 32, height: 32 };
export const contentType = "image/png";
export default function Icon() {
return new ImageResponse(
(
<div
style={{
width: 32,
height: 32,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "linear-gradient(135deg, #0ea5e9, #6366f1, #a855f7)",
borderRadius: 8,
fontSize: 20,
fontWeight: 700,
color: "white",
fontFamily: "sans-serif",
}}
>
D
</div>
),
{ ...size },
);
}

View File

@@ -0,0 +1,429 @@
"use client";
import { LOGIN_RETURN_INITIATIVES } from "@/lib/auth-links";
import { creditTicker } from "@/lib/credits-brand";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { useCallback, useEffect, useMemo, useState } from "react";
type InitiativeOrigin = "PLATFORM" | "COMMUNITY";
type InitiativeRow = {
id: string;
slug: string;
title: string;
description: string;
origin: InitiativeOrigin;
sortOrder: number;
createdAt: string;
creator: { id: string; displayName: string } | null;
pledgedCredits: number;
isMine: boolean;
};
type ApiPayload = {
creditName: string;
initiatives: InitiativeRow[];
myInitiativeSlug: string | null;
canCreate: boolean;
};
const PRESET_AMOUNTS = [10, 25, 50, 100, 250] as const;
function formatUsd(n: number) {
return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(n);
}
export function InitiativeBoard() {
const { data: session, status } = useSession();
const [data, setData] = useState<ApiPayload | null>(null);
const [balance, setBalance] = useState<number | null>(null);
const [infinite, setInfinite] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [notes, setNotes] = useState<Record<string, string>>({});
const [amounts, setAmounts] = useState<Record<string, string>>({});
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
const [preset, setPreset] = useState<number>(25);
const [blwUsd, setBlwUsd] = useState<number | null>(null);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [createBusy, setCreateBusy] = useState(false);
const ticker = creditTicker();
const load = useCallback(async () => {
const [iRes, wRes, rateRes] = await Promise.all([
fetch("/api/initiatives", { cache: "no-store" }),
session?.user ? fetch("/api/wallet", { cache: "no-store" }) : Promise.resolve(null as Response | null),
fetch("/api/exchange/rate", { cache: "no-store" }),
]);
if (!iRes.ok) return;
const j = (await iRes.json()) as ApiPayload;
setData(j);
if (wRes?.ok) {
const w = await wRes.json();
setBalance(w.balanceCredits ?? 0);
setInfinite(!!w.infiniteCredits);
} else {
setBalance(null);
setInfinite(false);
}
if (rateRes.ok) {
const r = await rateRes.json();
if (typeof r.blwUsd === "number" && !Number.isNaN(r.blwUsd)) setBlwUsd(r.blwUsd);
}
}, [session?.user]);
useEffect(() => {
load();
}, [load]);
const platform = useMemo(() => data?.initiatives.filter((i) => i.origin === "PLATFORM") ?? [], [data]);
const community = useMemo(() => data?.initiatives.filter((i) => i.origin === "COMMUNITY") ?? [], [data]);
const maxSignal = useMemo(() => {
const all = data?.initiatives.map((i) => i.pledgedCredits) ?? [];
return Math.max(1, ...all);
}, [data]);
const applyPreset = (n: number) => {
setPreset(n);
if (!data) return;
const next: Record<string, string> = {};
for (const i of data.initiatives) next[i.slug] = String(n);
setAmounts(next);
};
useEffect(() => {
if (!data) return;
setAmounts((prev) => {
const next = { ...prev };
for (const i of data.initiatives) {
if (next[i.slug] === undefined) next[i.slug] = String(preset);
}
return next;
});
}, [data, preset]);
const pledge = async (slug: string) => {
if (!session?.user || busy) return;
const raw = amounts[slug]?.trim() ?? "";
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < 1) {
setMsg({ text: `Enter a whole number of ${ticker} (at least 1).`, ok: false });
return;
}
setBusy(slug);
setMsg(null);
const note = notes[slug]?.trim();
const res = await fetch(`/api/initiatives/${encodeURIComponent(slug)}/pledge`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
credits: n,
supporterNote: note ? note : undefined,
}),
});
const body = await res.json().catch(() => ({}));
setBusy(null);
if (!res.ok) {
setMsg({ text: typeof body.error === "string" ? body.error : "Could not pledge.", ok: false });
return;
}
setMsg({
text: `Locked in — ${body.creditsSpent ?? 0} ${body.creditName ?? ticker} toward “${body.title ?? slug}”. Totals update instantly.`,
ok: true,
});
await load();
};
const createInitiative = async () => {
if (!session?.user || createBusy) return;
setCreateBusy(true);
setMsg(null);
const res = await fetch("/api/initiatives", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title.trim(), description: description.trim() }),
});
const body = await res.json().catch(() => ({}));
setCreateBusy(false);
if (!res.ok) {
setMsg({ text: typeof body.error === "string" ? body.error : "Could not create initiative.", ok: false });
return;
}
setTitle("");
setDescription("");
setMsg({
text: `Live — supporters can pledge ${ticker} to your initiative. Only one community initiative per account.`,
ok: true,
});
await load();
};
if (!data) {
return (
<div className="flex min-h-[30vh] items-center justify-center text-slate-500">
<span className={status === "loading" ? "animate-pulse" : ""}>Loading initiatives</span>
</div>
);
}
const creditName = data.creditName;
const renderCard = (i: InitiativeRow) => {
const rawAmt = amounts[i.slug] ?? String(preset);
const n = parseInt(rawAmt, 10);
const can = session?.user && (infinite || (balance !== null && Number.isFinite(n) && n >= 1 && balance >= n));
const disabled = busy !== null || !session?.user || !can;
const signalPct = Math.min(100, Math.round((i.pledgedCredits / maxSignal) * 100));
const estUsd = blwUsd !== null && blwUsd > 0 ? i.pledgedCredits * blwUsd : null;
return (
<article
key={i.id}
className="flex flex-col rounded-3xl border border-white/10 bg-white/[0.04] p-6 text-center shadow-[0_0_60px_rgba(99,102,241,0.06)] sm:text-left"
>
<div className="flex flex-col items-center gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1 text-center sm:text-left">
<h2 className="text-xl font-semibold leading-snug text-white">{i.title}</h2>
<p className="mt-1 text-xs text-slate-500">
{i.origin === "PLATFORM" ? (
<span className="rounded-full border border-sky-500/30 bg-sky-500/10 px-2 py-0.5 font-medium text-sky-200/95">
Official priority · spend {ticker} here
</span>
) : (
<>By {i.creator?.displayName ?? "Supporter"}</>
)}
</p>
</div>
{i.isMine ? (
<span className="shrink-0 rounded-full border border-emerald-400/35 bg-emerald-400/10 px-3 py-1 text-xs font-semibold text-emerald-100">
Your initiative
</span>
) : null}
</div>
<p className="mt-4 flex-1 whitespace-pre-wrap text-sm leading-relaxed text-slate-400">{i.description}</p>
<div className="mt-5 rounded-2xl border border-white/10 bg-black/30 p-4">
<div className="flex flex-col items-center justify-between gap-2 sm:flex-row sm:items-end">
<div className="text-center sm:text-left">
<p className="text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Democratic signal</p>
<p className="mt-1 font-mono text-2xl font-bold tabular-nums text-white">{i.pledgedCredits.toLocaleString()}</p>
<p className="text-xs text-slate-500">
total {ticker} pledged{estUsd !== null ? ` · ~${formatUsd(estUsd)} index` : ""}
</p>
</div>
<p className="text-center text-[11px] leading-snug text-slate-600 sm:max-w-[12rem] sm:text-right">
Rank rises as pledges stack this is supporter-driven demand, not committee cash.
</p>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full bg-gradient-to-r from-indigo-500 via-sky-400 to-fuchsia-400 transition-[width] duration-500"
style={{ width: `${signalPct}%` }}
/>
</div>
</div>
<div className="mt-5">
<p className="text-center text-xs uppercase tracking-[0.2em] text-slate-500 sm:text-left">Pledge {ticker}</p>
<div className="mt-2 flex flex-wrap justify-center gap-2 sm:justify-start">
{PRESET_AMOUNTS.map((a) => (
<button
key={a}
type="button"
onClick={() => {
setAmounts((prev) => ({ ...prev, [i.slug]: String(a) }));
}}
className={`rounded-full border px-3 py-1.5 text-xs font-semibold transition ${
(amounts[i.slug] ?? String(preset)) === String(a)
? "border-sky-400 bg-sky-500/15 text-sky-100"
: "border-white/10 text-slate-400 hover:border-white/20"
}`}
>
{a}
</button>
))}
</div>
<label className="mt-3 block text-left">
<span className="text-xs uppercase tracking-[0.2em] text-slate-500">Custom amount</span>
<input
type="number"
min={1}
inputMode="numeric"
value={amounts[i.slug] ?? String(preset)}
onChange={(e) => setAmounts((prev) => ({ ...prev, [i.slug]: e.target.value }))}
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 font-mono text-sm text-slate-200 focus:border-sky-500/50 focus:outline-none"
/>
</label>
</div>
<label className="mt-4 block text-left">
<span className="text-xs uppercase tracking-[0.2em] text-slate-500">Optional note to organizers</span>
<textarea
value={notes[i.slug] ?? ""}
onChange={(e) => setNotes((prev) => ({ ...prev, [i.slug]: e.target.value }))}
maxLength={280}
rows={2}
placeholder="Why this priority matters to you"
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm text-slate-200 placeholder:text-slate-600 focus:border-sky-500/50 focus:outline-none"
/>
</label>
<button
type="button"
disabled={disabled}
onClick={() => void pledge(i.slug)}
className="mt-4 rounded-xl bg-gradient-to-r from-fuchsia-600 to-indigo-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-fuchsia-500/20 disabled:opacity-40"
>
{busy === i.slug ? "Recording…" : `Pledge ${ticker}`}
</button>
</article>
);
};
return (
<div className="mx-auto max-w-6xl px-4 pb-24 pt-10 sm:px-6">
<div className="mx-auto max-w-3xl text-center">
<p className="text-xs uppercase tracking-[0.32em] text-indigo-200/80">Grassroots priorities</p>
<h1 className="mt-4 bg-gradient-to-r from-indigo-200 via-sky-200 to-fuchsia-200 bg-clip-text text-3xl font-bold tracking-tight text-transparent sm:text-5xl">
Democratic initiatives
</h1>
<p className="mx-auto mt-4 max-w-2xl text-slate-400">
Pick where your {ticker} goes: five official priorities you can fund directly, plus community proposals one published
initiative per account. Pledges stack on each card, re-ranking what supporters want the movement to emphasize next.
</p>
</div>
{session?.user ? (
<p className="mx-auto mt-8 max-w-2xl rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-4 text-center text-sm text-slate-300">
Wallet:{" "}
{infinite ? (
<span className="font-mono text-emerald-300"> {creditName}</span>
) : balance !== null ? (
<span className="font-mono text-white">
{balance.toLocaleString()} {creditName}
</span>
) : (
<span className="text-slate-500"></span>
)}
</p>
) : (
<div className="mx-auto mt-8 max-w-xl rounded-2xl border border-amber-500/30 bg-amber-500/10 px-5 py-4 text-center text-sm text-amber-100">
<Link href={LOGIN_RETURN_INITIATIVES} className="font-semibold text-white underline-offset-4 hover:underline">
Sign in
</Link>{" "}
to pledge {ticker} or publish your initiative.
</div>
)}
<div className="mx-auto mt-10 max-w-3xl rounded-2xl border border-indigo-500/20 bg-indigo-500/[0.06] px-5 py-4 text-center">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-indigo-200/90">Quick pledge amount</p>
<p className="mt-2 text-xs text-slate-500">Applies to every card until you change it tap a chip, then pledge on any priority.</p>
<div className="mt-4 flex flex-wrap justify-center gap-2">
{PRESET_AMOUNTS.map((a) => (
<button
key={a}
type="button"
onClick={() => applyPreset(a)}
className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${
preset === a ? "border-sky-400 bg-sky-500/20 text-white" : "border-white/10 text-slate-400 hover:border-white/25"
}`}
>
{a} {ticker}
</button>
))}
</div>
</div>
{data.canCreate ? (
<div className="mx-auto mt-10 max-w-2xl rounded-3xl border border-indigo-500/25 bg-indigo-500/[0.07] p-6 text-center sm:text-left">
<p className="text-xs uppercase tracking-[0.24em] text-indigo-200/90">Your community initiative (one per account)</p>
<p className="mt-2 text-sm text-slate-400">
Title and description are public. Other supporters pledge {ticker} here totals decide visibility and rank.
</p>
<label className="mt-4 block text-left">
<span className="text-xs uppercase tracking-[0.2em] text-slate-500">Title</span>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={120}
placeholder="e.g. Neighborhood canvass for early voting"
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm text-slate-200 placeholder:text-slate-600 focus:border-indigo-500/50 focus:outline-none"
/>
</label>
<label className="mt-4 block text-left">
<span className="text-xs uppercase tracking-[0.2em] text-slate-500">Description (20+ characters)</span>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={8000}
rows={5}
placeholder="What you want organized, where, and what success looks like."
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm text-slate-200 placeholder:text-slate-600 focus:border-indigo-500/50 focus:outline-none"
/>
</label>
<button
type="button"
disabled={createBusy || title.trim().length < 4 || description.trim().length < 20}
onClick={() => void createInitiative()}
className="mt-4 w-full rounded-xl bg-gradient-to-r from-indigo-500 to-sky-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-500/25 disabled:opacity-40"
>
{createBusy ? "Publishing…" : "Publish initiative"}
</button>
</div>
) : session?.user && data.myInitiativeSlug ? (
<p className="mx-auto mt-10 max-w-xl rounded-2xl border border-white/10 bg-white/[0.03] px-5 py-4 text-center text-sm text-slate-400">
Your community initiative is live supporters pledge {ticker} from the cards below.
</p>
) : null}
{msg ? (
<p
className={`mx-auto mt-6 max-w-2xl rounded-xl border px-4 py-3 text-center text-sm ${
msg.ok ? "border-emerald-500/35 bg-emerald-500/10 text-emerald-100" : "border-rose-500/35 bg-rose-500/10 text-rose-100"
}`}
>
{msg.text}
</p>
) : null}
{platform.length > 0 ? (
<div className="mt-16">
<div className="text-center">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-sky-200/85">Choose where {ticker} goes first</p>
<h2 className="mt-2 text-2xl font-bold text-white sm:text-3xl">Official democratic priorities</h2>
<p className="mx-auto mt-2 max-w-2xl text-sm text-slate-500">
These five lanes are always on the board. Pledge {ticker} to lift the ones you want organizers, messaging, and field
teams to overweight.
</p>
</div>
<div className="mt-8 grid gap-6 md:grid-cols-2 xl:grid-cols-3">{platform.map(renderCard)}</div>
</div>
) : null}
<div className="mt-16">
<div className="text-center">
<p className="text-xs font-semibold uppercase tracking-[0.28em] text-fuchsia-200/85">Community-powered</p>
<h2 className="mt-2 text-2xl font-bold text-white sm:text-3xl">Supporter-authored initiatives</h2>
<p className="mx-auto mt-2 max-w-2xl text-sm text-slate-500">
Anyone signed in can publish exactly one idea. Pledges from the crowd stack on each card same rules as the official
priorities.
</p>
</div>
<div className="mt-8 grid gap-6 md:grid-cols-2">
{community.length === 0 ? (
<p className="col-span-full text-center text-sm text-slate-500">
No community initiatives yet publish yours above.
</p>
) : (
community.map(renderCard)
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { SiteFooter } from "@/components/SiteFooter";
import { creditDisplayName } from "@/lib/credits-brand";
import { appTitle } from "@/lib/public-env";
import type { Metadata } from "next";
import { InitiativeBoard } from "./InitiativeBoard";
export const metadata: Metadata = {
title: `Democratic initiatives — ${appTitle()}`,
description: `Pledge ${creditDisplayName()} to official priorities or publish one community initiative per account — totals rank democratic demand on the board.`,
};
export default function InitiativesPage() {
return (
<>
<InitiativeBoard />
<SiteFooter />
</>
);
}

View File

@@ -1,24 +1,168 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { CursorStreamers } from "@/components/CursorStreamers";
import { Providers } from "@/components/Providers";
import { SiteNav } from "@/components/SiteNav";
import { appTitle } from "@/lib/public-env";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import { appTitle, siteUrl } from "@/lib/public-env";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
display: "swap",
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
display: "swap",
});
const BASE_URL = siteUrl();
const TITLE = appTitle();
const CREDIT_NAME = creditDisplayName();
const CREDIT_TICKER = creditTicker();
const DESCRIPTION = `Democracy Rising is a grassroots political fundraising platform — secure Stripe-confirmed donations, ${CREDIT_NAME} (${CREDIT_TICKER}) supporter credits, live disclosure totals, and committee-ready accountability tools.`;
const KEYWORDS = [
"Democracy Rising",
"political fundraising",
"grassroots fundraising",
"democratic fundraising platform",
"small dollar donations",
CREDIT_NAME,
`${CREDIT_TICKER} credits`,
"political donation",
"2026 midterms",
"campaign contributions",
"progressive fundraising",
"democratic campaign",
"secure political donations",
"Stripe political donations",
"FEC disclosure",
"voter engagement 2026",
];
/** Mobile: proper scaling, theme bar, safe-area friendly (no max-scale lock — preserves pinch-zoom a11y). */
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: "#030712",
};
export const metadata: Metadata = {
title: `${appTitle()} — Grassroots fundraising`,
description:
"Civic fundraising with Stripe-backed donations and Blue Wave (BLW) supporter perks—built for transparent local deployment.",
metadataBase: new URL(BASE_URL),
title: {
default: `${TITLE} — Official Grassroots Fundraising Portal`,
template: `%s | ${TITLE}`,
},
description: DESCRIPTION,
keywords: KEYWORDS,
authors: [{ name: TITLE, url: BASE_URL }],
creator: TITLE,
publisher: TITLE,
category: "politics",
classification: "Political Fundraising",
applicationName: TITLE,
// Canonical + alternate
alternates: {
canonical: "/",
},
// Indexing
robots: {
index: true,
follow: true,
nocache: false,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
// Open Graph
openGraph: {
type: "website",
locale: "en_US",
url: BASE_URL,
siteName: TITLE,
title: `${TITLE} — Official Grassroots Fundraising Portal`,
description: DESCRIPTION,
images: [
{
url: "/opengraph-image",
width: 1200,
height: 630,
alt: `${TITLE} — Grassroots Fundraising`,
type: "image/png",
},
],
},
// Twitter / X
twitter: {
card: "summary_large_image",
title: `${TITLE} — Official Grassroots Fundraising Portal`,
description: DESCRIPTION,
images: ["/opengraph-image"],
creator: "@DemocracyRising",
site: "@DemocracyRising",
},
// Optional site verification (search consoles): uncomment and set when configuring SEO tooling
// verification: {
// google: "YOUR_GOOGLE_SITE_VERIFICATION_TOKEN",
// yandex: "YOUR_YANDEX_TOKEN",
// bing: "YOUR_BING_TOKEN",
// },
// Icons — Next.js auto-discovers icon.tsx + apple-icon.tsx in the app dir
icons: {
icon: [{ url: "/favicon.ico", sizes: "any" }],
},
};
/** JSON-LD structured data — Organization + WebSite schemas for Google rich results. */
const jsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"@id": `${BASE_URL}/#organization`,
name: TITLE,
url: BASE_URL,
logo: {
"@type": "ImageObject",
url: `${BASE_URL}/opengraph-image`,
width: 1200,
height: 630,
},
description: DESCRIPTION,
sameAs: [
"https://twitter.com/DemocracyRising",
"https://www.facebook.com/DemocracyRising",
],
},
{
"@type": "WebSite",
"@id": `${BASE_URL}/#website`,
url: BASE_URL,
name: TITLE,
description: DESCRIPTION,
publisher: { "@id": `${BASE_URL}/#organization` },
potentialAction: {
"@type": "DonateAction",
target: `${BASE_URL}/donate`,
name: `Donate to ${TITLE}`,
},
inLanguage: "en-US",
},
],
};
export default async function RootLayout({
@@ -28,8 +172,19 @@ export default async function RootLayout({
}>) {
return (
<html lang="en">
<head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
{/* Preconnect to speed up third-party origins used on every page */}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
<link rel="preconnect" href="https://js.stripe.com" />
</head>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<Providers>
<CursorStreamers />
<SiteNav />
{children}
</Providers>

View File

@@ -1,5 +1,7 @@
"use client";
import { appTitle } from "@/lib/public-env";
import { motion } from "framer-motion";
import { signIn } from "next-auth/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -7,7 +9,8 @@ import { useState } from "react";
export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
const router = useRouter();
const [email, setEmail] = useState("");
const registerHref = `/register?callbackUrl=${encodeURIComponent(callbackUrl)}`;
const [emailOrUsername, setEmailOrUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
@@ -17,63 +20,108 @@ export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
setBusy(true);
setError(null);
const res = await signIn("credentials", {
email,
email: emailOrUsername.trim(),
password,
redirect: false,
callbackUrl,
});
setBusy(false);
if (res?.error) {
setError("Invalid email or password.");
return;
}
if (res?.error) { setError("Invalid email/username or password."); return; }
router.push(callbackUrl);
router.refresh();
};
return (
<div className="mx-auto flex min-h-[70vh] max-w-lg flex-col justify-center px-4 py-16 sm:px-6">
<h1 className="text-3xl font-semibold text-white">Sign in</h1>
<p className="mt-2 text-sm text-slate-400">
Demo account from seed: <code className="rounded bg-white/10 px-2 py-0.5">demo@local.dev</code> /{" "}
<code className="rounded bg-white/10 px-2 py-0.5">demo1234</code>
</p>
<form onSubmit={submit} className="mt-8 space-y-4">
<label className="block text-sm text-slate-300">
Email
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-2 w-full rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-white outline-none ring-sky-500/40 focus:ring"
/>
</label>
<label className="block text-sm text-slate-300">
Password
<input
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-2 w-full rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-white outline-none ring-sky-500/40 focus:ring"
/>
</label>
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
<button
type="submit"
disabled={busy}
className="w-full rounded-2xl bg-gradient-to-r from-sky-500 to-indigo-500 py-3 font-semibold text-white shadow-lg shadow-indigo-500/25 disabled:opacity-50"
>
{busy ? "Signing in…" : "Continue"}
</button>
</form>
<p className="mt-6 text-sm text-slate-400">
No account?{" "}
<Link className="text-sky-300 hover:underline" href="/register">
Create one
</Link>
</p>
<div className="relative flex min-h-[80vh] flex-col items-center justify-center px-4 py-16">
{/* Background glow */}
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_60%_50%_at_50%_30%,rgba(56,189,248,0.10),transparent)]" />
<motion.div
initial={{ opacity: 0, y: 24, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.5 }}
className="relative w-full max-w-md"
>
<div className="rounded-3xl border border-white/10 bg-white/[0.04] p-8 shadow-[0_0_80px_rgba(56,189,248,0.10)] backdrop-blur-xl">
{/* Inner border shimmer */}
<div className="pointer-events-none absolute inset-0 rounded-3xl border border-sky-400/10" />
<p className="text-xs uppercase tracking-[0.28em] text-sky-300/80">Secure portal access</p>
<h1 className="mt-2 text-3xl font-bold text-white">Sign in</h1>
<p className="mt-1.5 text-sm text-slate-400">
Supporter login for{" "}
<span className="text-slate-300">{appTitle()}</span>
</p>
<p className="mt-3 rounded-xl border border-white/8 bg-white/[0.04] px-3 py-2 text-xs text-slate-400">
Access is restricted to credentials issued by the committee. Unauthorized use may violate law or committee policy.
</p>
<form onSubmit={submit} className="mt-7 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-300" htmlFor="login-id">
Email or username
</label>
<input
id="login-id"
type="text"
autoComplete="username"
required
value={emailOrUsername}
onChange={(e) => setEmailOrUsername(e.target.value)}
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-base text-white placeholder-slate-600 outline-none ring-sky-500/40 transition focus:border-sky-500/40 focus:ring"
placeholder="you@example.com or supporter_name"
/>
</div>
<div>
<div className="flex items-center justify-between">
<label className="block text-sm font-medium text-slate-300" htmlFor="password">
Password
</label>
<Link href="/forgot-password" className="text-xs text-sky-400 hover:text-sky-300 transition">
Forgot password?
</Link>
</div>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-base text-white placeholder-slate-600 outline-none ring-sky-500/40 transition focus:border-sky-500/40 focus:ring"
placeholder="••••••••"
/>
</div>
{error ? (
<motion.p
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
className="rounded-xl border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-300"
>
{error}
</motion.p>
) : null}
<motion.button
type="submit"
disabled={busy}
whileTap={{ scale: 0.98 }}
className="group relative w-full overflow-hidden rounded-2xl bg-gradient-to-r from-sky-500 to-indigo-500 py-3.5 font-semibold text-white shadow-xl shadow-indigo-500/30 disabled:opacity-50"
>
<span className="relative z-10">{busy ? "Signing in…" : "Continue →"}</span>
<span className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/15 to-transparent transition-transform duration-500 group-hover:translate-x-full" />
</motion.button>
</form>
<p className="mt-6 text-center text-sm text-slate-500">
Need credentials?{" "}
<Link className="font-medium text-sky-300 hover:text-sky-200 transition" href={registerHref}>
Create an account
</Link>
</p>
</div>
</motion.div>
</div>
);
}

View File

@@ -1,13 +1,42 @@
import type { Metadata } from "next";
import { SiteFooter } from "@/components/SiteFooter";
import { appTitle, siteUrl } from "@/lib/public-env";
import { LoginForm } from "./LoginForm";
const TITLE = appTitle();
const BASE_URL = siteUrl();
export const metadata: Metadata = {
title: "Supporter Sign In",
description: `Sign in to your ${TITLE} supporter account — access your donation wallet, Blue Wave credit balance, contribution history, and exclusive perks.`,
alternates: { canonical: `${BASE_URL}/login` },
robots: { index: false, follow: false },
openGraph: {
title: `Sign In — ${TITLE}`,
description: `Access your ${TITLE} supporter wallet and contribution history.`,
url: `${BASE_URL}/login`,
siteName: TITLE,
},
};
function sanitizeCallback(raw: string | string[] | undefined): string {
const value = typeof raw === "string" ? raw : "/wallet";
if (!value.startsWith("/") || value.startsWith("//")) return "/wallet";
return value;
}
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<{ callbackUrl?: string | string[] }>;
}) {
const sp = await searchParams;
const raw = sp.callbackUrl;
const callbackUrl = typeof raw === "string" ? raw : "/wallet";
const callbackUrl = sanitizeCallback(sp.callbackUrl);
return <LoginForm callbackUrl={callbackUrl} />;
return (
<>
<LoginForm callbackUrl={callbackUrl} />
<SiteFooter />
</>
);
}

View File

@@ -0,0 +1,175 @@
"use client";
import { LOGIN_RETURN_MISSIONS } from "@/lib/auth-links";
import type { MissionCatalogEntry } from "@/lib/mission-catalog";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { useCallback, useEffect, useState } from "react";
type ApiPayload = {
missions: MissionCatalogEntry[];
pledgedCreditsBySlug: Record<string, number>;
creditName: string;
};
export function MissionPledges() {
const { data: session, status } = useSession();
const [data, setData] = useState<ApiPayload | null>(null);
const [balance, setBalance] = useState<number | null>(null);
const [infinite, setInfinite] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [notes, setNotes] = useState<Record<string, string>>({});
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
const load = useCallback(async () => {
const [mRes, wRes] = await Promise.all([
fetch("/api/missions", { cache: "no-store" }),
session?.user ? fetch("/api/wallet", { cache: "no-store" }) : Promise.resolve(null as Response | null),
]);
if (!mRes.ok) return;
const j = (await mRes.json()) as ApiPayload;
setData(j);
if (wRes?.ok) {
const w = await wRes.json();
setBalance(w.balanceCredits ?? 0);
setInfinite(!!w.infiniteCredits);
} else {
setBalance(null);
setInfinite(false);
}
}, [session?.user]);
useEffect(() => {
load();
}, [load]);
const pledge = async (slug: string) => {
if (!session?.user || busy) return;
setBusy(slug);
setMsg(null);
const note = notes[slug]?.trim();
const res = await fetch("/api/missions/spend", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
missionSlug: slug,
supporterNote: note ? note : undefined,
}),
});
const body = await res.json().catch(() => ({}));
setBusy(null);
if (!res.ok) {
setMsg({ text: typeof body.error === "string" ? body.error : "Could not pledge.", ok: false });
return;
}
setMsg({
text: `Recorded — ${body.creditsSpent ?? 0} ${body.creditName ?? "credits"} pledged toward ${body.missionTitle ?? "this mission"}.`,
ok: true,
});
await load();
};
if (!data) {
return (
<div className="flex min-h-[30vh] items-center justify-center text-slate-500">
<span className={status === "loading" ? "animate-pulse" : ""}>Loading missions</span>
</div>
);
}
const creditName = data.creditName;
return (
<div className="mx-auto max-w-6xl px-4 pb-24 pt-10 sm:px-6">
<div className="text-center">
<p className="text-xs uppercase tracking-[0.32em] text-sky-200/80">Movement priorities</p>
<h1 className="mt-4 bg-gradient-to-r from-sky-200 via-indigo-200 to-fuchsia-200 bg-clip-text text-3xl font-bold tracking-tight text-transparent sm:text-5xl">
Put your {creditName} behind the mission
</h1>
<p className="mx-auto mt-4 max-w-2xl text-slate-400">
Each pledge debits your supporter ledger in real time. Aggregate totals below reflect program-wide supporter prioritization;
disbursements and budgets require authorization by the committee treasurer under applicable rules.
</p>
</div>
{session?.user ? (
<p className="mx-auto mt-8 max-w-2xl rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-4 text-center text-sm text-slate-300">
Wallet:{" "}
{infinite ? (
<span className="font-mono text-emerald-300"> {creditName}</span>
) : balance !== null ? (
<span className="font-mono text-white">
{balance.toLocaleString()} {creditName}
</span>
) : (
<span className="text-slate-500"></span>
)}
</p>
) : (
<div className="mx-auto mt-8 max-w-xl rounded-2xl border border-amber-500/30 bg-amber-500/10 px-5 py-4 text-center text-sm text-amber-100">
<Link href={LOGIN_RETURN_MISSIONS} className="font-semibold text-white underline-offset-4 hover:underline">
Sign in
</Link>{" "}
to pledge we need an account to debit your ledger fairly.
</div>
)}
{msg ? (
<p
className={`mx-auto mt-6 max-w-2xl rounded-xl border px-4 py-3 text-center text-sm ${
msg.ok ? "border-emerald-500/35 bg-emerald-500/10 text-emerald-100" : "border-rose-500/35 bg-rose-500/10 text-rose-100"
}`}
>
{msg.text}
</p>
) : null}
<div className="mt-14 grid gap-6 md:grid-cols-2">
{data.missions.map((m) => {
const pledged = data.pledgedCreditsBySlug[m.slug] ?? 0;
const cost = m.costCredits;
const can =
session?.user && (infinite || (balance !== null && balance >= cost));
const disabled = busy !== null || !session?.user || !can;
return (
<article
key={m.slug}
className="flex flex-col rounded-3xl border border-white/10 bg-white/[0.04] p-6 shadow-[0_0_60px_rgba(99,102,241,0.06)]"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<h2 className="text-xl font-semibold text-white">{m.title}</h2>
<span className="shrink-0 rounded-full border border-sky-400/35 bg-sky-400/10 px-3 py-1 text-xs font-semibold text-sky-100">
{cost.toLocaleString()} {creditName}
</span>
</div>
<p className="mt-3 flex-1 text-sm leading-relaxed text-slate-400">{m.description}</p>
<p className="mt-4 text-xs text-slate-500">
Community pledged: <span className="font-mono text-slate-400">{pledged.toLocaleString()}</span> {creditName}
</p>
<label className="mt-4 block text-left">
<span className="text-xs uppercase tracking-[0.2em] text-slate-500">Optional note</span>
<textarea
value={notes[m.slug] ?? ""}
onChange={(e) => setNotes((prev) => ({ ...prev, [m.slug]: e.target.value }))}
maxLength={280}
rows={2}
placeholder="Optional context for organizers (may be used in reports)"
className="mt-2 w-full rounded-xl border border-white/10 bg-black/40 px-3 py-2 text-sm text-slate-200 placeholder:text-slate-600 focus:border-sky-500/50 focus:outline-none"
/>
</label>
<button
type="button"
disabled={disabled}
onClick={() => pledge(m.slug)}
className="mt-4 rounded-xl bg-gradient-to-r from-indigo-500 to-fuchsia-600 px-4 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-500/25 disabled:opacity-40"
>
{busy === m.slug ? "Recording…" : `Pledge ${cost.toLocaleString()} ${creditName}`}
</button>
</article>
);
})}
</div>
</div>
);
}

21
src/app/missions/page.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { SiteFooter } from "@/components/SiteFooter";
import { creditDisplayName } from "@/lib/credits-brand";
import { appTitle } from "@/lib/public-env";
import type { Metadata } from "next";
import { MissionPledges } from "./MissionPledges";
const creditName = creditDisplayName();
export const metadata: Metadata = {
title: `Mission pledges — ${appTitle()}`,
description: `Allocate ${creditName} toward democratic field work, organizing, outreach, and other committee priorities.`,
};
export default function MissionsPage() {
return (
<>
<MissionPledges />
<SiteFooter />
</>
);
}

105
src/app/opengraph-image.tsx Normal file
View File

@@ -0,0 +1,105 @@
import { ImageResponse } from "next/og";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import { appTitle } from "@/lib/public-env";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const alt = "Democracy Rising — Official Grassroots Fundraising Portal";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function OgImage() {
const title = appTitle();
const cn = creditDisplayName();
const ct = creditTicker();
return new ImageResponse(
(
<div
style={{
width: "1200px",
height: "630px",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: "64px",
background: "linear-gradient(135deg, #0f172a 0%, #1e1b4b 50%, #0f172a 100%)",
fontFamily: "sans-serif",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "12px" }}>
<div
style={{
display: "flex",
width: "12px",
height: "12px",
borderRadius: "50%",
background: "#38bdf8",
}}
/>
<span
style={{
fontSize: "16px",
letterSpacing: "0.3em",
textTransform: "uppercase",
color: "rgba(186,230,253,0.9)",
}}
>
Official fundraising portal · 2026
</span>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
<div
style={{
display: "flex",
fontSize: "76px",
fontWeight: 700,
lineHeight: 1.05,
color: "white",
letterSpacing: "-0.02em",
}}
>
{title}
</div>
<div
style={{
display: "flex",
fontSize: "28px",
color: "rgba(148,163,184,0.95)",
maxWidth: "1000px",
lineHeight: 1.35,
}}
>
Grassroots political fundraising secure Stripe donations, {cn} ({ct}), live disclosure totals.
</div>
</div>
<div style={{ display: "flex", gap: "32px", alignItems: "center" }}>
{[
{ label: "Fixed tiers", value: "$5 · $10 · $20 · $100" },
{ label: "Credits", value: `${cn} (${ct})` },
{ label: "Transparency", value: "Live Stripe totals" },
].map((item) => (
<div
key={item.label}
style={{
display: "flex",
flexDirection: "column",
gap: "6px",
padding: "16px 24px",
borderRadius: "16px",
border: "1px solid rgba(255,255,255,0.12)",
background: "rgba(255,255,255,0.06)",
}}
>
<span style={{ fontSize: "20px", fontWeight: 600, color: "white" }}>{item.value}</span>
<span style={{ fontSize: "14px", color: "rgba(148,163,184,0.85)" }}>{item.label}</span>
</div>
))}
</div>
</div>
),
{ ...size },
);
}

View File

@@ -1,41 +1,237 @@
import type { Metadata } from "next";
import { ActionCenter } from "@/components/ActionCenter";
import { BwtPrinciplesSection } from "@/components/BwtPrinciplesSection";
import { DonateSection } from "@/components/DonateSection";
import { DonorLeaderboard } from "@/components/DonorLeaderboard";
import { FaqSection } from "@/components/FaqSection";
import { Hero } from "@/components/Hero";
import { MissionStatement } from "@/components/MissionStatement";
import { ImpactPlanner } from "@/components/ImpactPlanner";
import { IssueGrid } from "@/components/IssueGrid";
import { OppositionSection } from "@/components/OppositionSection";
import { ProgressSection } from "@/components/ProgressSection";
import { RewardsPreview } from "@/components/RewardsPreview";
import { SiteFooter } from "@/components/SiteFooter";
import { SupporterQuest } from "@/components/SupporterQuest";
import { SupporterFeed } from "@/components/SupporterFeed";
import { CreditsFlowSection } from "@/components/CreditsFlowSection";
import { WelcomePath } from "@/components/WelcomePath";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import { appTitle, siteUrl } from "@/lib/public-env";
const TITLE = appTitle();
const CREDIT_NAME = creditDisplayName();
const CREDIT_TICKER = creditTicker();
const BASE_URL = siteUrl();
export const metadata: Metadata = {
title: `${TITLE} — Grassroots Political Fundraising & Supporter Rewards`,
description:
`Join Democracy Rising — the grassroots fundraising platform for the 2026 midterms. Make secure Stripe-confirmed donations starting at $5, earn ${CREDIT_NAME} (${CREDIT_TICKER}), unlock supporter perks and mission pledges, and watch live transparent totals.`,
keywords: [
"Democracy Rising donate",
"grassroots political fundraising 2026",
"democratic small dollar donations",
"donate to progressive cause",
`${CREDIT_NAME} supporter credits`,
`${CREDIT_TICKER} political rewards`,
"midterm election fundraising",
"secure political donation",
"Stripe political donations",
"progressive campaign 2026",
"FEC compliant donations",
"voting rights fundraising",
"climate policy fundraising",
"healthcare fundraising",
"democracy fundraising platform",
],
alternates: { canonical: BASE_URL },
openGraph: {
title: `${TITLE} — Grassroots Political Fundraising & Supporter Rewards`,
description:
`Make secure donations starting at $5, earn ${CREDIT_NAME}, and track live fundraising totals. Join the Democracy Rising grassroots movement.`,
url: BASE_URL,
siteName: TITLE,
images: [
{
url: "/opengraph-image",
width: 1200,
height: 630,
alt: `${TITLE} — Grassroots Fundraising Platform`,
},
],
},
twitter: {
card: "summary_large_image",
title: `${TITLE} — Grassroots Political Fundraising`,
description:
`Secure donations from $5. Earn ${CREDIT_NAME}. Live transparent totals. Join Democracy Rising.`,
images: ["/opengraph-image"],
},
};
/** FAQ data kept in sync with `FaqSection` for JSON-LD structured data. */
const FAQ_LD = [
{
q: `What is ${TITLE}?`,
a: `${TITLE} is this committees digital home for small-dollar fundraising: clear tiers, a live public meter, and supporter tools that keep people engaged after they give.`,
},
{
q: `What is ${CREDIT_NAME} (${CREDIT_TICKER})?`,
a: `${CREDIT_NAME} (${CREDIT_TICKER}) is the on-site recognition you earn when you donate while signed in. Spend it on perks, raffles, the straw poll, optional games, mission pledges, and democratic initiatives. The amount you receive is set at checkout for that gift.`,
},
{
q: `Is ${CREDIT_TICKER} cryptocurrency?`,
a: `No. ${CREDIT_TICKER} are supporter credits tied to your donation — they live in your wallet on this site and power games, pledges, and perks. They are not a tradable blockchain token or cash balance.`,
},
{
q: "Where does my donation go?",
a: "Your card payment supports the committees authorized program — the same dollars that appear on our live totals and disclosure pages.",
},
{
q: "Why do the homepage meter and /raised match?",
a: "They read the same completed contributions. The homepage refreshes on a short timer; the Raised page is the full snapshot with goal context.",
},
{
q: "What are mission pledges?",
a: `On /missions, signed-in supporters steer ${CREDIT_TICKER} toward committee priorities like field organizing, voter protection, and digital rapid response. Pledges show where energy should go.`,
},
{
q: "What are democratic initiatives?",
a: `On /initiatives, each account can publish one grassroots idea. Everyone else pledges ${CREDIT_TICKER} to lift the proposals they believe in — a live signal of what the community wants next.`,
},
{
q: "What is the presidential straw poll?",
a: `At /vote/next-president you can cast weighted supporter ballots using ${CREDIT_TICKER}. Its for engagement and conversation — not an official election.`,
},
{
q: "Can I donate without creating an account?",
a: `Yes. Guest checkout still counts on the public meter. To earn ${CREDIT_NAME}, use the wallet, missions, initiatives, and games, sign in (or enroll) before you pay.`,
},
{
q: "Can I get a refund?",
a: "Refunds follow the committees published policy and applicable law. Reach out through the official channels listed in committee filings.",
},
{
q: "How do I volunteer?",
a: "Use the contact and volunteer routes published in the committees Statement of Organization and other authorized disclosures.",
},
];
const pageJsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "FAQPage",
"@id": `${BASE_URL}/#faq`,
mainEntity: FAQ_LD.map(({ q, a }) => ({
"@type": "Question",
name: q,
acceptedAnswer: {
"@type": "Answer",
text: a,
},
})),
},
{
"@type": "DonateAction",
"@id": `${BASE_URL}/donate#donate-action`,
name: `Donate to ${TITLE}`,
description: `Support ${TITLE} with a secure small-dollar contribution starting at $5.`,
url: `${BASE_URL}/donate`,
recipient: {
"@type": "Organization",
name: TITLE,
url: BASE_URL,
},
},
{
"@type": "BreadcrumbList",
itemListElement: [
{
"@type": "ListItem",
position: 1,
name: "Home",
item: BASE_URL,
},
{
"@type": "ListItem",
position: 2,
name: "Public Disclosure",
item: `${BASE_URL}/raised`,
},
{
"@type": "ListItem",
position: 3,
name: "Enroll",
item: `${BASE_URL}/register`,
},
],
},
],
};
export default function Home() {
const publishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "";
return (
<main>
<Hero />
<SupporterFeed />
<ProgressSection />
<ImpactPlanner />
<ActionCenter />
<OppositionSection />
<section className="py-16">
<div className="mx-auto mb-12 max-w-6xl px-4 sm:px-6">
<p className="text-xs uppercase tracking-[0.32em] text-slate-400">National priorities</p>
<h2 className="mt-3 text-3xl font-semibold text-white sm:text-4xl">
Policy lanes rooted in 2026 voter reality
</h2>
<p className="mt-4 max-w-3xl text-slate-400">
Messaging modules below are data-informed draftsswap copy without touching core flows by editing{" "}
<code className="rounded bg-white/10 px-2 py-0.5 text-sm text-slate-200">content/issues.json</code>.
</p>
</div>
<IssueGrid />
</section>
<RewardsPreview />
<DonateSection publishableKey={publishableKey} />
<SiteFooter />
</main>
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(pageJsonLd) }}
/>
<main>
<Hero />
<WelcomePath />
<CreditsFlowSection />
<MissionStatement />
<SupporterFeed />
<SupporterQuest />
<ProgressSection />
<BwtPrinciplesSection />
<ImpactPlanner />
<ActionCenter />
<OppositionSection />
<section className="scroll-mt-28 py-12 sm:py-14" aria-labelledby="priorities-heading">
<div className="mx-auto mb-8 max-w-6xl px-4 text-center sm:px-6">
<p className="text-xs uppercase tracking-[0.32em] text-slate-400">National priorities</p>
<h2 id="priorities-heading" className="mx-auto mt-2 max-w-3xl text-3xl font-semibold text-white sm:text-4xl">
Policy lanes rooted in 2026 voter reality
</h2>
<p className="mx-auto mt-4 max-w-3xl text-base leading-relaxed text-slate-400">
Think of this wall as the campaign&apos;s north star not fine print, but the fights we refuse to walk away from:
fair elections, good jobs, care people can afford, and a democracy that works for neighbors, not donors alone.
</p>
<p className="mx-auto mt-3 max-w-2xl text-sm leading-relaxed text-slate-500">
Each card is a conversation starter you can share with friends and family. When you&apos;re ready to fund the field,
head to <a href="/donate" className="text-sky-300 underline-offset-2 hover:underline">donate</a>, then use{" "}
<a href="/missions" className="text-sky-300 underline-offset-2 hover:underline">missions</a> and{" "}
<a href="/initiatives" className="text-sky-300 underline-offset-2 hover:underline">initiatives</a> to steer your{" "}
{CREDIT_TICKER}.
</p>
</div>
<IssueGrid />
</section>
<RewardsPreview />
{/* Donor Leaderboard section */}
<section className="border-t border-white/5 py-10" id="leaderboard">
<div className="mx-auto max-w-3xl px-4 sm:px-6">
<div className="mb-5 text-center">
<p className="text-xs uppercase tracking-[0.32em] text-sky-200/80">Hall of fame</p>
<h2 className="mt-2 text-3xl font-semibold text-white sm:text-4xl">Top Donors</h2>
<p className="mt-2 text-slate-400">
Founding supporters powering this movement every dollar counted, every name honored.
</p>
</div>
<DonorLeaderboard />
</div>
</section>
<DonateSection publishableKey={publishableKey} />
<FaqSection />
<SiteFooter />
</main>
</>
);
}

View File

@@ -1,52 +1,147 @@
import { SiteFooter } from "@/components/SiteFooter";
import { DonorLeaderboard } from "@/components/DonorLeaderboard";
import { prisma } from "@/lib/prisma";
import { appTitle, siteUrl } from "@/lib/public-env";
import type { Metadata } from "next";
import Link from "next/link";
import { appTitle } from "@/lib/public-env";
export const metadata = {
title: `Dollars raised — ${appTitle()}`,
description: "Live totals from confirmed Stripe donations in this deployment.",
const TITLE = appTitle();
const BASE_URL = siteUrl();
export const metadata: Metadata = {
title: `Live scoreboard — ${TITLE}`,
description: `Watch the live fundraising meter for ${TITLE}: dollars raised, supporter count, and progress toward the campaign goal.`,
keywords: [
"Democracy Rising donations",
"live fundraising totals",
"political donation transparency",
"campaign contribution disclosure",
"grassroots funding tracker",
"how much raised 2026",
"FEC disclosure",
"grassroots fundraising tracker",
],
alternates: { canonical: `${BASE_URL}/raised` },
openGraph: {
title: `Live scoreboard — ${TITLE}`,
description: `Official live fundraising totals for ${TITLE}.`,
url: `${BASE_URL}/raised`,
siteName: TITLE,
images: [{ url: "/opengraph-image", width: 1200, height: 630, alt: `${TITLE} fundraising totals` }],
},
twitter: {
card: "summary_large_image",
title: `Live scoreboard — ${TITLE}`,
description: `Official live fundraising totals for ${TITLE}.`,
images: ["/opengraph-image"],
},
};
export default async function RaisedPage() {
const [agg, donorRows] = await Promise.all([
const [agg, donorRows, guestCount] = await Promise.all([
prisma.donation.aggregate({
_sum: { amountUsdCents: true },
_count: true,
}),
prisma.donation.groupBy({
by: ["userId"],
where: { userId: { not: null } },
_count: true,
}),
prisma.donation.count({ where: { userId: null } }),
]);
const raisedUsdForLd = (agg._sum.amountUsdCents ?? 0) / 100;
const pageJsonLd = {
"@context": "https://schema.org",
"@graph": [
{
"@type": "WebPage",
"@id": `${BASE_URL}/raised#webpage`,
url: `${BASE_URL}/raised`,
name: `Live scoreboard — ${TITLE}`,
description: `Official live fundraising totals for ${TITLE}.`,
isPartOf: { "@id": `${BASE_URL}/#website` },
breadcrumb: { "@id": `${BASE_URL}/raised#breadcrumb` },
},
{
"@type": "BreadcrumbList",
"@id": `${BASE_URL}/raised#breadcrumb`,
itemListElement: [
{ "@type": "ListItem", position: 1, name: "Home", item: BASE_URL },
{ "@type": "ListItem", position: 2, name: "Live board", item: `${BASE_URL}/raised` },
],
},
{
"@type": "Dataset",
"@id": `${BASE_URL}/raised#dataset`,
name: `${TITLE} Fundraising Totals`,
description: "Live fundraising totals updated as gifts land.",
url: `${BASE_URL}/raised`,
creator: { "@id": `${BASE_URL}/#organization` },
variableMeasured: [
{ "@type": "PropertyValue", name: "Total Raised USD", value: raisedUsdForLd },
{ "@type": "PropertyValue", name: "Total Donations", value: agg._count },
{ "@type": "PropertyValue", name: "Unique Supporters", value: donorRows.length },
],
},
],
};
const raisedUsd = (agg._sum.amountUsdCents ?? 0) / 100;
const donationCount = agg._count;
const uniqueDonors = donorRows.length;
const guestDonations = guestCount;
const goalUsd = parseFloat(process.env.PUBLIC_CAMPAIGN_GOAL_USD ?? "250000");
const pct = goalUsd > 0 ? Math.min(100, Math.round((raisedUsd / goalUsd) * 100)) : 0;
const formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(raisedUsd);
return (
<main className="min-h-[70vh] border-b border-white/10 py-16">
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(pageJsonLd) }}
/>
<main className="min-h-[70vh] border-b border-white/10 py-16">
<div className="mx-auto max-w-3xl px-4 sm:px-6">
<p className="text-xs uppercase tracking-[0.32em] text-sky-200/80">Transparency</p>
<h1 className="mt-4 text-4xl font-semibold text-white sm:text-5xl">Total dollars raised</h1>
<p className="text-xs uppercase tracking-[0.32em] text-sky-200/80">The big board</p>
<h1 className="mt-4 text-4xl font-semibold text-white sm:text-5xl">What we&apos;ve raised together</h1>
<p className="mt-4 text-lg text-slate-400">
Sum of successful donations processed through Stripe for this app ({appTitle()}). Updates as webhooks confirm
payments.
Every chip-in that clears shows up heresame energy as the homepage meter, with the full story: dollars, gifts, and who
showed up for the wave.
</p>
{raisedUsd === 0 && donationCount === 0 ? (
<div className="mt-10 rounded-2xl border border-sky-500/35 bg-sky-500/10 px-6 py-8 text-center">
<p className="text-lg font-semibold text-white">Be the first on the board</p>
<p className="mt-2 text-sm text-slate-300">
Be the spark that gets the ticker movingyour name hits the board the moment your gift clears.
</p>
<Link
href="/donate"
className="mt-6 inline-flex rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-8 py-3 text-sm font-semibold text-white shadow-lg shadow-sky-500/25"
>
Donate now
</Link>
</div>
) : null}
<div className="mt-12 rounded-[28px] border border-white/10 bg-gradient-to-br from-sky-500/15 via-indigo-900/40 to-fuchsia-900/30 p-8 shadow-[0_0_100px_rgba(56,189,248,0.12)]">
<p className="text-sm uppercase tracking-[0.2em] text-slate-400">Confirmed via Stripe</p>
<p className="text-sm uppercase tracking-[0.2em] text-slate-400">Verified gifts</p>
<p className="mt-4 font-mono text-5xl font-semibold tracking-tight text-white sm:text-6xl">{formatted}</p>
<p className="mt-6 flex flex-wrap gap-6 text-sm text-slate-300">
<span>
<strong className="text-white">{donationCount}</strong> donation{donationCount === 1 ? "" : "s"}
</span>
<span>
<strong className="text-white">{uniqueDonors}</strong> supporter{uniqueDonors === 1 ? "" : "s"}
<strong className="text-white">{uniqueDonors}</strong> enrolled supporter{uniqueDonors === 1 ? "" : "s"}
</span>
{guestDonations > 0 ? (
<span className="text-slate-400">
<strong className="text-white">{guestDonations}</strong> quick anonymous gift{guestDonations === 1 ? "" : "s"}
</span>
) : null}
</p>
</div>
@@ -54,8 +149,8 @@ export default async function RaisedPage() {
<div className="flex justify-between text-xs text-slate-500">
<span>$0</span>
<span>
Goal {new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(goalUsd)}{" "}
<span className="text-slate-600">(PUBLIC_CAMPAIGN_GOAL_USD)</span>
Campaign goal{" "}
{new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(goalUsd)}
</span>
</div>
<div className="mt-2 h-3 overflow-hidden rounded-full border border-white/10 bg-black/40">
@@ -64,26 +159,45 @@ export default async function RaisedPage() {
style={{ width: `${pct}%` }}
/>
</div>
<p className="mt-2 text-center text-xs text-slate-500">{pct}% of demo goal</p>
<p className="mt-2 text-center text-xs text-slate-500">{pct}% of committee goal</p>
</div>
{/* Donor Leaderboard */}
<div className="mt-14">
<div className="flex items-baseline justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-white">Top Donors</h2>
<p className="text-sm text-slate-400 mt-1">Founding supporters who made this movement possible</p>
</div>
<Link href="/donate" className="rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-5 py-2 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 hover:opacity-90 transition-opacity">
Join the board
</Link>
</div>
<DonorLeaderboard />
</div>
<div className="mt-12 rounded-2xl border border-white/10 bg-white/5 p-6 text-sm text-slate-400">
<p className="font-medium text-white">Note</p>
<p className="font-medium text-white">Small print, big heart</p>
<p className="mt-2 leading-relaxed">
This total reflects <code className="rounded bg-black/30 px-1">Donation</code> rows created by the Stripe webhook
onlyonly processed charges count. Configure committee reporting separately for compliance.
Only completed gifts count herepending or declined charges never touch the board. Official committee filings follow
your treasurer&apos;s playbook.
</p>
</div>
<div className="mt-10 flex flex-wrap gap-4">
<Link href="/#donate" className="rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-6 py-3 text-sm font-semibold text-white">
<Link href="/donate" className="rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-6 py-3 text-sm font-semibold text-white">
Donate
</Link>
<Link href="/#meter" className="rounded-full border border-white/15 px-6 py-3 text-sm font-semibold text-white hover:bg-white/5">
See homepage meter
</Link>
<Link href="/" className="rounded-full border border-white/15 px-6 py-3 text-sm font-semibold text-white hover:bg-white/5">
Back home
</Link>
</div>
</div>
</main>
</main>
<SiteFooter />
</>
);
}

View File

@@ -0,0 +1,119 @@
"use client";
import { appTitle } from "@/lib/public-env";
import Link from "next/link";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
export function RegisterForm({ callbackUrl }: { callbackUrl: string }) {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [username, setUsername] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
password,
...(name.trim() ? { name: name.trim() } : {}),
...(username.trim() ? { username: username.trim() } : {}),
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "Could not register");
setBusy(false);
return;
}
const signInRes = await signIn("credentials", { email, password, redirect: false, callbackUrl });
if (signInRes?.error) {
setError("Account created but sign-in failed — please sign in manually.");
setBusy(false);
router.push(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`);
return;
}
router.push(callbackUrl);
router.refresh();
setBusy(false);
};
return (
<div className="mx-auto flex min-h-[60vh] max-w-lg flex-col justify-center px-4 py-16 sm:px-6">
<p className="text-xs uppercase tracking-[0.28em] text-sky-300/80">Official supporter account</p>
<h1 className="mt-3 text-3xl font-semibold text-white">Create your credentials</h1>
<p className="mt-2 text-sm text-slate-300">
Join {appTitle()}secure access to disclosure-grade totals, your supporter wallet, and committee-approved perks.
</p>
<p className="mt-3 text-sm text-slate-400">
Password must be at least 8 characters. Your wallet is provisioned automatically upon enrollment.
</p>
<form onSubmit={submit} className="mt-8 space-y-4">
<label className="block text-sm text-slate-300">
Display name
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-2 w-full rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-base text-white outline-none ring-sky-500/40 focus:ring"
/>
</label>
<label className="block text-sm text-slate-300">
Username · optional
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Letters, numbers, underscores (332)"
className="mt-2 w-full rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-base text-white outline-none ring-sky-500/40 focus:ring"
/>
<span className="mt-1 block text-xs text-slate-500">
Leave blank for an auto-assigned username. Sign in works with email or username.
</span>
</label>
<label className="block text-sm text-slate-300">
Email
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-2 w-full rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-base text-white outline-none ring-sky-500/40 focus:ring"
/>
</label>
<label className="block text-sm text-slate-300">
Password
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-2 w-full rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-base text-white outline-none ring-sky-500/40 focus:ring"
/>
</label>
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
<button
type="submit"
disabled={busy}
className="w-full rounded-2xl bg-gradient-to-r from-fuchsia-500 to-indigo-500 py-3 font-semibold text-white shadow-lg shadow-fuchsia-500/25 disabled:opacity-50"
>
{busy ? "Creating…" : "Create account"}
</button>
</form>
<p className="mt-6 text-sm text-slate-400">
Already enrolled?{" "}
<Link className="text-sky-300 hover:underline" href={`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`}>
Sign in
</Link>
</p>
</div>
);
}

View File

@@ -1,90 +1,60 @@
"use client";
import type { Metadata } from "next";
import { SiteFooter } from "@/components/SiteFooter";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import { appTitle, siteUrl } from "@/lib/public-env";
import { RegisterForm } from "./RegisterForm";
import Link from "next/link";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
const TITLE = appTitle();
const BASE_URL = siteUrl();
const CREDIT_NAME = creditDisplayName();
const CREDIT_TICKER = creditTicker();
export default function RegisterPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
export const metadata: Metadata = {
title: "Join as a Supporter — Enroll Today",
description: `Create your free ${TITLE} supporter account. Get your donation wallet, earn ${CREDIT_NAME} (${CREDIT_TICKER}) on every contribution, unlock perks, and track your impact in real time.`,
keywords: [
"join Democracy Rising",
"political supporter account",
"grassroots fundraising signup",
CREDIT_NAME,
`${CREDIT_TICKER} enrollment`,
"donate and earn rewards",
"Democracy Rising register",
],
alternates: { canonical: `${BASE_URL}/register` },
openGraph: {
title: `Enroll as a Supporter — ${TITLE}`,
description: `Join ${TITLE}: get your donation wallet, earn ${CREDIT_NAME}, and track your impact.`,
url: `${BASE_URL}/register`,
siteName: TITLE,
images: [{ url: "/opengraph-image", width: 1200, height: 630, alt: `Join ${TITLE}` }],
},
twitter: {
card: "summary_large_image",
title: `Enroll as a Supporter — ${TITLE}`,
description: `Join ${TITLE}: donation wallet, ${CREDIT_NAME}, real-time impact tracking.`,
images: ["/opengraph-image"],
},
};
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
const res = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, name }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "Could not register");
setBusy(false);
return;
}
await signIn("credentials", { email, password, redirect: false });
router.push("/wallet");
router.refresh();
setBusy(false);
};
function sanitizeCallback(raw: string | string[] | undefined): string {
const value = typeof raw === "string" ? raw : "/wallet";
if (!value.startsWith("/") || value.startsWith("//")) return "/wallet";
return value;
}
export default async function RegisterPage({
searchParams,
}: {
searchParams: Promise<{ callbackUrl?: string | string[] }>;
}) {
const sp = await searchParams;
const callbackUrl = sanitizeCallback(sp.callbackUrl);
return (
<div className="mx-auto flex min-h-[70vh] max-w-lg flex-col justify-center px-4 py-16 sm:px-6">
<h1 className="text-3xl font-semibold text-white">Create supporter login</h1>
<p className="mt-2 text-sm text-slate-400">
Password must be at least 8 characters. Your wallet is created automatically.
</p>
<form onSubmit={submit} className="mt-8 space-y-4">
<label className="block text-sm text-slate-300">
Display name
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="mt-2 w-full rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-white outline-none ring-sky-500/40 focus:ring"
/>
</label>
<label className="block text-sm text-slate-300">
Email
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-2 w-full rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-white outline-none ring-sky-500/40 focus:ring"
/>
</label>
<label className="block text-sm text-slate-300">
Password
<input
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-2 w-full rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-white outline-none ring-sky-500/40 focus:ring"
/>
</label>
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
<button
type="submit"
disabled={busy}
className="w-full rounded-2xl bg-gradient-to-r from-fuchsia-500 to-indigo-500 py-3 font-semibold text-white shadow-lg shadow-fuchsia-500/25 disabled:opacity-50"
>
{busy ? "Creating…" : "Create account"}
</button>
</form>
<p className="mt-6 text-sm text-slate-400">
Already joined?{" "}
<Link className="text-sky-300 hover:underline" href="/login">
Sign in
</Link>
</p>
</div>
<>
<RegisterForm callbackUrl={callbackUrl} />
<SiteFooter />
</>
);
}

17
src/app/robots.ts Normal file
View File

@@ -0,0 +1,17 @@
import type { MetadataRoute } from "next";
import { siteUrl } from "@/lib/public-env";
export default function robots(): MetadataRoute.Robots {
const base = siteUrl();
return {
rules: [
{
userAgent: "*",
allow: ["/", "/raised", "/register", "/login", "/forgot-password", "/donate", "/missions", "/initiatives", "/billboard", "/boost", "/spotlight", "/cards", "/faq-board"],
disallow: ["/wallet", "/api/", "/vote", "/casino", "/_next/"],
},
],
sitemap: `${base}/sitemap.xml`,
host: base,
};
}

94
src/app/sitemap.ts Normal file
View File

@@ -0,0 +1,94 @@
import type { MetadataRoute } from "next";
import { siteUrl } from "@/lib/public-env";
export default function sitemap(): MetadataRoute.Sitemap {
const base = siteUrl();
const now = new Date();
return [
{
url: `${base}/`,
lastModified: now,
changeFrequency: "daily",
priority: 1.0,
},
{
url: `${base}/raised`,
lastModified: now,
changeFrequency: "hourly",
priority: 0.9,
},
{
url: `${base}/register`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.8,
},
{
url: `${base}/login`,
lastModified: now,
changeFrequency: "monthly",
priority: 0.5,
},
{
url: `${base}/forgot-password`,
lastModified: now,
changeFrequency: "yearly",
priority: 0.3,
},
{
url: `${base}/donate`,
lastModified: now,
changeFrequency: "weekly",
priority: 0.95,
},
{
url: `${base}/billboard`,
lastModified: now,
changeFrequency: "hourly",
priority: 0.7,
},
{
url: `${base}/spotlight`,
lastModified: now,
changeFrequency: "daily",
priority: 0.7,
},
{
url: `${base}/cards`,
lastModified: now,
changeFrequency: "daily",
priority: 0.7,
},
{
url: `${base}/faq-board`,
lastModified: now,
changeFrequency: "daily",
priority: 0.7,
},
{
url: `${base}/boost`,
lastModified: now,
changeFrequency: "hourly",
priority: 0.8,
},
{
url: `${base}/missions`,
lastModified: now,
changeFrequency: "daily",
priority: 0.85,
},
{
url: `${base}/initiatives`,
lastModified: now,
changeFrequency: "daily",
priority: 0.85,
},
{
url: `${base}/vote/next-president`,
lastModified: now,
changeFrequency: "daily",
priority: 0.75,
},
];
}

212
src/app/spotlight/page.tsx Normal file
View File

@@ -0,0 +1,212 @@
"use client";
import { SiteFooter } from "@/components/SiteFooter";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { useCallback, useEffect, useState } from "react";
type IssueTotal = { issueSlug: string; issueTitle: string; total: number; backers: number };
type CatalogItem = { slug: string; title: string };
export default function SpotlightPage() {
const { data: session } = useSession();
const [totals, setTotals] = useState<IssueTotal[]>([]);
const [catalog, setCatalog] = useState<CatalogItem[]>([]);
const [leader, setLeader] = useState<IssueTotal | null>(null);
const [weekOf, setWeekOf] = useState("");
const [selected, setSelected] = useState("");
const [bidAmount, setBidAmount] = useState(25);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [pageLoading, setPageLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const fetchData = useCallback(async () => {
try {
const res = await fetch("/api/spotlight");
if (!res.ok) {
setLoadError("Could not load spotlight data.");
setTotals([]);
setCatalog([]);
setLeader(null);
setWeekOf("");
return;
}
const d = await res.json();
setTotals(Array.isArray(d.totals) ? d.totals : []);
setCatalog(Array.isArray(d.catalog) ? d.catalog : []);
setLeader(d.leader ?? null);
setWeekOf(typeof d.weekOf === "string" ? d.weekOf : "");
setLoadError("");
} catch {
setLoadError("Could not load spotlight data.");
setTotals([]);
setCatalog([]);
setLeader(null);
setWeekOf("");
} finally {
setPageLoading(false);
}
}, []);
useEffect(() => { fetchData(); }, [fetchData]);
const maxTotal = totals[0]?.total ?? 1;
async function bid() {
if (!selected || !bidAmount) return;
setLoading(true); setError(""); setSuccess("");
const res = await fetch("/api/spotlight", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ issueSlug: selected, creditsSpent: bidAmount }),
});
const d = await res.json();
setLoading(false);
if (!res.ok) { setError(d.error ?? "Failed"); return; }
setSuccess(`Bid placed! ${bidAmount} BWT toward "${d.issue.title}"`);
fetchData();
}
const presets = [10, 25, 50, 100, 250];
return (
<>
<main className="min-h-screen border-b border-white/10 py-16">
<div className="mx-auto max-w-4xl px-4 sm:px-6">
<p className="text-xs uppercase tracking-[0.32em] text-indigo-300/80">Weekly Auction</p>
<h1 className="mt-3 text-4xl font-semibold text-white sm:text-5xl">Issue Spotlight</h1>
<p className="mt-4 max-w-2xl text-slate-400">
Bid Blue Wave Tokens on the policy issue you want featured this week. The issue with the most BWT by Sunday midnight earns the homepage spotlight and all backers get credited.
</p>
{pageLoading && (
<div className="mt-8 animate-pulse space-y-3">
<div className="h-24 rounded-3xl bg-white/5" />
<div className="h-16 rounded-xl bg-white/5" />
</div>
)}
{loadError && !pageLoading && (
<div className="mt-8 rounded-2xl border border-amber-500/35 bg-amber-500/10 px-4 py-3 text-sm text-amber-100/95">
{loadError}{" "}
<button type="button" onClick={() => { setPageLoading(true); fetchData(); }} className="font-semibold text-white underline">
Retry
</button>
</div>
)}
{!pageLoading && !loadError && !leader && totals.length === 0 && (
<p className="mt-8 rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">
No bids yet this week pick an issue below and be the first to back it.
</p>
)}
{/* Current leader */}
{leader && (
<div className="mt-8 rounded-3xl border border-indigo-500/30 bg-indigo-950/30 p-6 shadow-[0_0_60px_rgba(99,102,241,0.12)]">
<p className="text-xs uppercase tracking-widest text-indigo-300">🏆 This week&apos;s leader</p>
<p className="mt-2 text-2xl font-semibold text-white">{leader.issueTitle}</p>
<p className="mt-1 text-slate-400">
<span className="font-semibold text-indigo-300">{leader.total.toLocaleString()} BWT</span>
{" "}from {leader.backers} backer{leader.backers !== 1 ? "s" : ""}
</p>
<p className="mt-1 text-xs text-slate-600">Week of {weekOf}</p>
</div>
)}
{/* Leaderboard bars */}
{totals.length > 0 && (
<div className="mt-8 space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-widest text-slate-400">Current standings</h2>
{totals.map((t, i) => (
<div key={t.issueSlug} className="group">
<div className="flex items-center justify-between text-sm">
<span className={`font-medium ${i === 0 ? "text-indigo-300" : "text-white"}`}>
{i === 0 ? "🥇 " : i === 1 ? "🥈 " : i === 2 ? "🥉 " : ""}{t.issueTitle}
</span>
<span className="text-slate-400">{t.total.toLocaleString()} BWT · {t.backers} backer{t.backers !== 1 ? "s" : ""}</span>
</div>
<div className="mt-1 h-2.5 overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full bg-gradient-to-r from-indigo-500 to-sky-400 transition-all duration-500"
style={{ width: `${Math.max(2, Math.round((t.total / maxTotal) * 100))}%` }}
/>
</div>
</div>
))}
</div>
)}
{/* Bid form */}
<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>
) : (
<>
{catalog.length === 0 ? (
<p className="mt-4 text-sm text-amber-200/90">Issue catalog is unavailable. Refresh the page or try again later.</p>
) : null}
<div className="mt-4 grid gap-2 sm:grid-cols-2">
{catalog.map((c) => (
<button
key={c.slug}
onClick={() => setSelected(c.slug)}
className={`rounded-xl border px-4 py-3 text-left text-sm font-medium transition ${
selected === c.slug
? "border-indigo-400 bg-indigo-400/15 text-indigo-200"
: "border-white/10 text-slate-300 hover:border-white/20 hover:text-white"
}`}
>
{c.title}
</button>
))}
</div>
<div className="mt-5">
<p className="text-sm text-slate-400 mb-2">BWT amount</p>
<div className="flex flex-wrap gap-2">
{presets.map((p) => (
<button
key={p}
onClick={() => setBidAmount(p)}
className={`rounded-full border px-4 py-1.5 text-sm font-medium transition ${
bidAmount === p
? "border-indigo-400 bg-indigo-400/15 text-indigo-200"
: "border-white/10 text-slate-400 hover:border-white/20"
}`}
>
{p}
</button>
))}
<input
type="number"
min={5}
max={10000}
value={bidAmount}
onChange={(e) => setBidAmount(Math.max(5, Math.min(10000, Number(e.target.value))))}
className="w-24 rounded-full border border-white/10 bg-white/5 px-4 py-1.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
</div>
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
{success && <p className="mt-3 text-sm text-emerald-400">{success}</p>}
<button
onClick={bid}
disabled={loading || !selected}
className="mt-5 rounded-full bg-gradient-to-r from-indigo-500 to-sky-500 px-8 py-2.5 text-sm font-semibold text-white shadow-lg disabled:opacity-40"
>
{loading ? "Bidding…" : `Bid ${bidAmount} BWT`}
</button>
</>
)}
</div>
</div>
</main>
<SiteFooter />
</>
);
}

View File

@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { PresidentialPoll } from "@/components/PresidentialPoll";
import { SiteFooter } from "@/components/SiteFooter";
import { creditTicker } from "@/lib/credits-brand";
import { appTitle } from "@/lib/public-env";
const TITLE = appTitle();
const T = creditTicker();
export const metadata: Metadata = {
title: `Supporter straw poll — ${TITLE}`,
description: `${TITLE} optional write-in engagement poll—aggregate tallies and ${T}-priced ballots. Not an official election; supporter engagement only.`,
};
export default function NextPresidentVotePage() {
return (
<main className="min-h-[70vh] border-b border-white/10 bg-gradient-to-b from-[#030712] via-[#061022] to-[#020617]">
<PresidentialPoll />
<SiteFooter />
</main>
);
}

View File

@@ -2,9 +2,27 @@
import type { PrizeSku, Raffle } from "@prisma/client";
import { usdValueOfBlwCredits } from "@/lib/exchange";
import Link from "next/link";
import { signOut } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
type LedgerItem = {
id: string;
delta: number;
type: string;
memo: string | null;
createdAt: string;
};
type DonationItem = {
id: string;
amountUsdCents: number;
creditsAwarded: number;
currency: string;
status: string;
createdAt: string;
};
type Props = {
initialBalance: number;
@@ -12,22 +30,51 @@ type Props = {
creditName: string;
prizes: PrizeSku[];
raffles: Raffle[];
/** Hide top balance card when WalletDashboard is shown above */
compactBalance?: boolean;
};
type Tab = "perks" | "raffles" | "history" | "donations";
export function WalletActions({
initialBalance,
infiniteCredits: initialInfinite,
creditName,
prizes,
raffles,
compactBalance = false,
}: Props) {
const router = useRouter();
const searchParams = useSearchParams();
const justDonated = searchParams.get("donated") === "1";
const [balance, setBalance] = useState(initialBalance);
const [infiniteCredits, setInfiniteCredits] = useState(!!initialInfinite);
const [message, setMessage] = useState<string | null>(null);
const [message, setMessage] = useState<{ text: string; type: "success" | "error" } | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [blwUsd, setBlwUsd] = useState<number | null>(null);
const [activeTab, setActiveTab] = useState<Tab>("perks");
const [ledger, setLedger] = useState<LedgerItem[]>([]);
const [ledgerCursor, setLedgerCursor] = useState<string | null>(null);
const [ledgerLoading, setLedgerLoading] = useState(false);
const [donations, setDonations] = useState<DonationItem[]>([]);
const [donationsCursor, setDonationsCursor] = useState<string | null>(null);
const [donationsLoading, setDonationsLoading] = useState(false);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const pollCountRef = useRef(0);
const refreshBalance = async () => {
const res = await fetch("/api/wallet", { cache: "no-store" });
if (!res.ok) return;
const data = await res.json();
setBalance(data.balanceCredits ?? 0);
setInfiniteCredits(!!data.infiniteCredits);
};
// Supporter credit spot index (USD per credit unit)
useEffect(() => {
if (infiniteCredits) return;
let alive = true;
@@ -37,29 +84,68 @@ export function WalletActions({
if (!res.ok) return;
const j = await res.json();
if (alive) setBlwUsd(j.blwUsd as number);
} catch {
/* ignore */
}
} catch { /* ignore */ }
};
tick();
const id = setInterval(tick, 15_000);
return () => {
alive = false;
clearInterval(id);
};
return () => { alive = false; clearInterval(id); };
}, [infiniteCredits]);
// Auto-poll balance for 30 s if user just donated
useEffect(() => {
if (!justDonated) return;
pollCountRef.current = 0;
pollingRef.current = setInterval(async () => {
await refreshBalance();
pollCountRef.current += 1;
if (pollCountRef.current >= 6 && pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
}, 5_000);
return () => {
if (pollingRef.current) clearInterval(pollingRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [justDonated]);
const loadLedger = async (cursor?: string) => {
setLedgerLoading(true);
try {
const url = cursor ? `/api/wallet/ledger?cursor=${cursor}` : "/api/wallet/ledger";
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return;
const data = await res.json();
setLedger((prev) => cursor ? [...prev, ...data.items] : data.items);
setLedgerCursor(data.nextCursor);
} finally {
setLedgerLoading(false);
}
};
const loadDonations = async (cursor?: string) => {
setDonationsLoading(true);
try {
const url = cursor ? `/api/wallet/donations?cursor=${cursor}` : "/api/wallet/donations";
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) return;
const data = await res.json();
setDonations((prev) => cursor ? [...prev, ...data.items] : data.items);
setDonationsCursor(data.nextCursor);
} finally {
setDonationsLoading(false);
}
};
useEffect(() => {
if (activeTab === "history" && ledger.length === 0) loadLedger();
if (activeTab === "donations" && donations.length === 0) loadDonations();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeTab]);
const portfolioUsd =
!infiniteCredits && blwUsd !== null ? usdValueOfBlwCredits(balance, blwUsd) : null;
const refreshBalance = async () => {
const res = await fetch("/api/wallet", { cache: "no-store" });
if (!res.ok) return;
const data = await res.json();
setBalance(data.balanceCredits ?? 0);
setInfiniteCredits(!!data.infiniteCredits);
};
const redeem = async (slug: string) => {
setBusy(`redeem:${slug}`);
setMessage(null);
@@ -71,15 +157,16 @@ export function WalletActions({
const data = await res.json();
setBusy(null);
if (!res.ok) {
setMessage(data.error ?? "Could not redeem");
setMessage({ text: data.error ?? "Could not redeem", type: "error" });
return;
}
setMessage("Redeemed — fulfillment details are stubbed for now.");
setMessage({ text: "Reward redeemed! Check your email for fulfillment details.", type: "success" });
await refreshBalance();
setLedger([]);
router.refresh();
};
const raffle = async (slug: string, tickets: number) => {
const enterRaffle = async (slug: string, tickets: number) => {
setBusy(`raffle:${slug}`);
setMessage(null);
const res = await fetch("/api/rewards/raffle", {
@@ -90,16 +177,25 @@ export function WalletActions({
const data = await res.json();
setBusy(null);
if (!res.ok) {
setMessage(data.error ?? "Could not enter raffle");
setMessage({ text: data.error ?? "Could not enter raffle", type: "error" });
return;
}
setMessage(`Entered raffle — ${data.tickets} ticket(s).`);
setMessage({ text: `You're in! ${data.tickets} ticket(s) entered for this draw.`, type: "success" });
await refreshBalance();
setLedger([]);
router.refresh();
};
const tabs: { id: Tab; label: string }[] = [
{ id: "perks", label: "Digital perks" },
{ id: "raffles", label: "Raffles" },
{ id: "history", label: "Transaction history" },
{ id: "donations", label: "My donations" },
];
return (
<div className="space-y-10">
<div className="space-y-8">
{!compactBalance ? (
<div className="flex flex-wrap items-center justify-between gap-4 rounded-3xl border border-white/10 bg-white/5 p-6">
<div>
<p className="text-xs uppercase tracking-[0.28em] text-slate-400">Wallet balance</p>
@@ -118,15 +214,16 @@ export function WalletActions({
</p>
{!infiniteCredits && portfolioUsd !== null && blwUsd !== null ? (
<p className="mt-3 text-sm text-slate-400">
Mock marktomarket:{" "}
Index value:{" "}
<span className="font-semibold text-emerald-300/95">
${portfolioUsd.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} USD
</span>{" "}
at ${blwUsd.toFixed(4)} / BLW <span className="text-slate-500">(index moves not cash)</span>
at {blwUsd.toFixed(4)} USD per {creditName}{" "}
<span className="text-slate-500">(recognition index not a cash balance)</span>
</p>
) : null}
{infiniteCredits ? (
<p className="mt-3 text-sm text-amber-200/90">Admin QA mode portfolio index hidden.</p>
<p className="mt-3 text-sm text-amber-200/90">Admin mode unlimited credits.</p>
) : null}
</div>
<button
@@ -137,72 +234,431 @@ export function WalletActions({
Sign out
</button>
</div>
) : (
<div className="flex justify-end">
<button
type="button"
onClick={() => signOut({ callbackUrl: "/" })}
className="rounded-full border border-white/15 px-4 py-2 text-sm text-slate-200 hover:bg-white/5"
>
Sign out
</button>
</div>
)}
{message ? (
<p className="rounded-2xl border border-sky-500/30 bg-sky-500/10 px-4 py-3 text-sm text-sky-100">{message}</p>
{/* Spend surfaces — same ledger across the portal */}
<div className="rounded-3xl border border-white/10 bg-white/[0.03] p-6">
<p className="text-xs uppercase tracking-[0.28em] text-slate-400">Spend {creditName}</p>
<p className="mt-2 text-sm text-slate-500">
Use the tabs below for perks and raffles, or move credits to committee engagement tools.
</p>
<div className="mt-5 grid gap-2 grid-cols-2 lg:grid-cols-3">
<button
type="button"
onClick={() => setActiveTab("perks")}
className="rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-left text-sm font-medium text-white transition hover:border-sky-400/35 hover:bg-white/[0.06]"
>
Digital perks
</button>
<button
type="button"
onClick={() => setActiveTab("raffles")}
className="rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-left text-sm font-medium text-white transition hover:border-sky-400/35 hover:bg-white/[0.06]"
>
Raffles
</button>
<Link
href="/missions"
className="rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm font-medium text-white transition hover:border-indigo-400/35 hover:bg-white/[0.06]"
>
Mission pledges
</Link>
<Link
href="/initiatives"
className="rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm font-medium text-white transition hover:border-violet-400/35 hover:bg-white/[0.06]"
>
Initiatives
</Link>
<Link
href="/vote/next-president"
className="rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm font-medium text-white transition hover:border-fuchsia-400/35 hover:bg-white/[0.06]"
>
Straw poll
</Link>
<Link
href="/casino"
className="rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm font-medium text-white transition hover:border-emerald-400/35 hover:bg-white/[0.06]"
>
Supporter games
</Link>
<Link
href="/billboard"
className="rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm font-medium text-white transition hover:border-sky-400/35 hover:bg-white/[0.06]"
>
📣 Billboard
</Link>
<Link
href="/spotlight"
className="rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm font-medium text-white transition hover:border-indigo-400/35 hover:bg-white/[0.06]"
>
🎯 Spotlight Auction
</Link>
<Link
href="/cards"
className="rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm font-medium text-white transition hover:border-amber-400/35 hover:bg-white/[0.06]"
>
Supporter Cards
</Link>
<Link
href="/faq-board"
className="rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm font-medium text-white transition hover:border-purple-400/35 hover:bg-white/[0.06]"
>
💬 FAQ Board
</Link>
<Link
href="/boost"
className="rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm font-medium text-white transition hover:border-fuchsia-400/35 hover:bg-white/[0.06]"
>
Power the Movement
</Link>
</div>
</div>
{/* Just-donated banner */}
{justDonated ? (
<div className="rounded-2xl border border-emerald-500/35 bg-emerald-500/10 px-5 py-4">
<p className="font-semibold text-emerald-50">Payment confirmed!</p>
<p className="mt-1 text-sm text-emerald-100/85">
Your {creditName} are landing nowgive the meter a breath and watch your balance tick up.
</p>
</div>
) : null}
<section>
<h2 className="text-xl font-semibold text-white">Digital perks (stub catalog)</h2>
<p className="mt-2 text-sm text-slate-400">
Spend credits on placeholder perksswap SKUs for real merchandise integrations later.
</p>
<div className="mt-6 grid gap-4 md:grid-cols-2">
{prizes.map((p) => (
<div key={p.id} className="rounded-2xl border border-white/10 bg-black/30 p-5">
<h3 className="text-lg font-semibold text-white">{p.title}</h3>
<p className="mt-2 text-sm text-slate-400">{p.description}</p>
<p className="mt-4 text-sm text-slate-300">
Cost: <span className="font-semibold text-white">{p.costCredits}</span> credits
</p>
<button
type="button"
disabled={busy !== null}
onClick={() => redeem(p.slug)}
className="mt-4 w-full rounded-xl bg-white/10 py-2 text-sm font-semibold text-white hover:bg-white/15 disabled:opacity-40"
>
{busy === `redeem:${p.slug}` ? "Working…" : "Redeem"}
</button>
</div>
))}
{/* Zero-balance prompt */}
{!infiniteCredits && balance === 0 && !justDonated ? (
<div className="rounded-3xl border border-sky-500/25 bg-sky-500/10 px-6 py-6">
<p className="font-medium text-sky-50">No {creditName} yet unlock perks after your first donation.</p>
<p className="mt-2 text-sm text-sky-100/85">
Pick a tier on the donate flowyour first gift drops {creditName} into this wallet the moment it clears.
</p>
<div className="mt-4 flex flex-wrap gap-3">
<Link
href="/donate"
className="inline-flex rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-6 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20"
>
Make your first donation
</Link>
<Link
href="/missions"
className="inline-flex rounded-full border border-white/15 px-6 py-2.5 text-sm font-medium text-slate-200 hover:bg-white/5"
>
Mission pledges
</Link>
<Link
href="/initiatives"
className="inline-flex rounded-full border border-white/15 px-6 py-2.5 text-sm font-medium text-slate-200 hover:bg-white/5"
>
Initiatives
</Link>
<Link
href="/vote/next-president"
className="inline-flex rounded-full border border-white/15 px-6 py-2.5 text-sm font-medium text-slate-200 hover:bg-white/5"
>
Straw poll
</Link>
<Link
href="/casino"
className="inline-flex rounded-full border border-white/15 px-6 py-2.5 text-sm font-medium text-slate-200 hover:bg-white/5"
>
Supporter games
</Link>
<Link
href="/billboard"
className="inline-flex rounded-full border border-white/15 px-6 py-2.5 text-sm font-medium text-slate-200 hover:bg-white/5"
>
📣 Billboard
</Link>
<Link
href="/boost"
className="inline-flex rounded-full border border-white/15 px-6 py-2.5 text-sm font-medium text-slate-200 hover:bg-white/5"
>
Power the Movement
</Link>
</div>
</div>
</section>
) : null}
<section>
<h2 className="text-xl font-semibold text-white">Raffles</h2>
<div className="mt-6 space-y-4">
{raffles.map((r) => (
<div key={r.id} className="flex flex-col gap-3 rounded-2xl border border-white/10 bg-black/30 p-5 md:flex-row md:items-center md:justify-between">
<div>
<h3 className="text-lg font-semibold text-white">{r.title}</h3>
<p className="mt-1 text-sm text-slate-400">{r.description}</p>
<p className="mt-2 text-xs text-slate-500">
Ticket cost: {r.ticketCostCredits} credits · Ends{" "}
{r.endsAt ? new Date(r.endsAt).toLocaleDateString() : "TBD"}
</p>
</div>
<div className="flex gap-2">
<button
type="button"
disabled={busy !== null}
onClick={() => raffle(r.slug, 1)}
className="rounded-xl bg-gradient-to-r from-sky-500 to-indigo-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-40"
>
{busy === `raffle:${r.slug}` ? "…" : "Buy 1 ticket"}
</button>
<button
type="button"
disabled={busy !== null}
onClick={() => raffle(r.slug, 5)}
className="rounded-xl border border-white/15 px-4 py-2 text-sm text-white hover:bg-white/5 disabled:opacity-40"
>
Buy 5
</button>
</div>
</div>
{/* Action feedback */}
{message ? (
<p
className={`rounded-2xl border px-4 py-3 text-sm ${
message.type === "success"
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-100"
: "border-rose-500/30 bg-rose-500/10 text-rose-100"
}`}
>
{message.text}
</p>
) : null}
{/* Tabs */}
<div>
<div className="flex gap-1 overflow-x-auto rounded-2xl border border-white/10 bg-white/[0.04] p-1">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`whitespace-nowrap rounded-xl px-4 py-2 text-sm font-medium transition ${
activeTab === tab.id
? "bg-white/15 text-white shadow-sm"
: "text-slate-400 hover:text-slate-200"
}`}
>
{tab.label}
</button>
))}
</div>
</section>
{/* Perks tab */}
{activeTab === "perks" ? (
<div className="mt-6">
{prizes.length === 0 ? (
<EmptyState
title="Perks coming soon"
body="New digital rewards are being prepared. Check back after your first donation."
/>
) : (
<div className="grid gap-4 md:grid-cols-2">
{prizes.map((p) => (
<div
key={p.id}
className="flex flex-col justify-between rounded-2xl border border-white/10 bg-black/30 p-5"
>
<div>
<h3 className="text-lg font-semibold text-white">{p.title}</h3>
<p className="mt-2 text-sm text-slate-400">{p.description}</p>
</div>
<div className="mt-5 flex items-center justify-between gap-3">
<span className="text-sm text-slate-300">
<span className="font-semibold text-white">{p.costCredits.toLocaleString()}</span> {creditName}
</span>
<button
type="button"
disabled={busy !== null || (!infiniteCredits && balance < p.costCredits)}
onClick={() => redeem(p.slug)}
className="rounded-xl bg-gradient-to-r from-fuchsia-500 to-indigo-500 px-4 py-2 text-sm font-semibold text-white shadow-md shadow-indigo-500/20 disabled:opacity-40"
>
{busy === `redeem:${p.slug}` ? "Working…" : "Redeem"}
</button>
</div>
</div>
))}
</div>
)}
</div>
) : null}
{/* Raffles tab */}
{activeTab === "raffles" ? (
<div className="mt-6 space-y-4">
{raffles.length === 0 ? (
<EmptyState
title="No active raffles"
body="Raffles will appear here when available. Keep an eye on your inbox for announcements."
/>
) : (
raffles.map((r) => {
const expired = r.endsAt ? new Date(r.endsAt) < new Date() : false;
return (
<div
key={r.id}
className={`flex flex-col gap-3 rounded-2xl border p-5 md:flex-row md:items-center md:justify-between ${
expired ? "border-white/5 bg-black/20 opacity-60" : "border-white/10 bg-black/30"
}`}
>
<div>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-white">{r.title}</h3>
{expired ? (
<span className="rounded-full border border-rose-500/40 bg-rose-500/10 px-2 py-0.5 text-xs text-rose-300">
Closed
</span>
) : (
<span className="rounded-full border border-emerald-500/40 bg-emerald-500/10 px-2 py-0.5 text-xs text-emerald-300">
Open
</span>
)}
</div>
<p className="mt-1 text-sm text-slate-400">{r.description}</p>
<p className="mt-2 text-xs text-slate-500">
{r.ticketCostCredits} {creditName}/ticket ·{" "}
{r.endsAt
? expired
? `Ended ${new Date(r.endsAt).toLocaleDateString()}`
: `Closes ${new Date(r.endsAt).toLocaleDateString()}`
: "Draw date — committee notice"}
</p>
</div>
{expired ? (
<span className="self-start rounded-xl border border-white/10 px-4 py-2 text-sm text-slate-500 md:self-auto">
Draw complete
</span>
) : (
<div className="flex gap-2">
<button
type="button"
disabled={busy !== null || (!infiniteCredits && balance < r.ticketCostCredits)}
onClick={() => enterRaffle(r.slug, 1)}
className="rounded-xl bg-gradient-to-r from-sky-500 to-indigo-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-40"
>
{busy === `raffle:${r.slug}` ? "…" : "Buy 1 ticket"}
</button>
<button
type="button"
disabled={busy !== null || (!infiniteCredits && balance < r.ticketCostCredits * 5)}
onClick={() => enterRaffle(r.slug, 5)}
className="rounded-xl border border-white/15 px-4 py-2 text-sm text-white hover:bg-white/5 disabled:opacity-40"
>
Buy 5
</button>
</div>
)}
</div>
);
})
)}
</div>
) : null}
{/* Transaction history tab */}
{activeTab === "history" ? (
<div className="mt-6">
{ledger.length === 0 && !ledgerLoading ? (
<EmptyState
title="No transactions yet"
body="Your credit history will appear here once you make a donation or redeem a perk."
/>
) : (
<div className="space-y-2">
{ledger.map((entry) => (
<div
key={entry.id}
className="flex items-center justify-between rounded-xl border border-white/10 bg-black/20 px-4 py-3"
>
<div className="min-w-0">
<p className="truncate text-sm text-slate-200">{entry.memo ?? entry.type.replace(/_/g, " ")}</p>
<p className="mt-0.5 text-xs text-slate-500">
{new Date(entry.createdAt).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
<span
className={`ml-4 shrink-0 font-mono text-sm font-semibold ${
entry.delta > 0 ? "text-emerald-300" : "text-rose-300"
}`}
>
{entry.delta > 0 ? "+" : ""}
{entry.delta.toLocaleString()}
</span>
</div>
))}
{ledgerLoading ? (
<p className="py-4 text-center text-sm text-slate-500">Loading</p>
) : ledgerCursor ? (
<button
type="button"
onClick={() => loadLedger(ledgerCursor)}
className="mt-2 w-full rounded-xl border border-white/10 py-2 text-sm text-slate-400 hover:bg-white/5"
>
Load more
</button>
) : null}
</div>
)}
</div>
) : null}
{/* Donation history tab */}
{activeTab === "donations" ? (
<div className="mt-6">
{donations.length === 0 && !donationsLoading ? (
<EmptyState
title="No donations yet"
body="Your donation receipts will appear here after your first contribution."
cta={{ label: "Make your first donation →", href: "/donate" }}
/>
) : (
<div className="space-y-2">
{donations.map((d) => (
<div
key={d.id}
className="flex items-center justify-between rounded-xl border border-white/10 bg-black/20 px-4 py-3"
>
<div>
<p className="text-sm font-medium text-white">
${(d.amountUsdCents / 100).toFixed(2)} USD
</p>
<p className="mt-0.5 text-xs text-slate-500">
{new Date(d.createdAt).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
{" · "}
<span className="capitalize">{d.status}</span>
</p>
</div>
<span className="ml-4 shrink-0 rounded-full border border-emerald-500/30 bg-emerald-500/10 px-3 py-1 font-mono text-xs text-emerald-200">
+{d.creditsAwarded.toLocaleString()} {creditName}
</span>
</div>
))}
{donationsLoading ? (
<p className="py-4 text-center text-sm text-slate-500">Loading</p>
) : donationsCursor ? (
<button
type="button"
onClick={() => loadDonations(donationsCursor)}
className="mt-2 w-full rounded-xl border border-white/10 py-2 text-sm text-slate-400 hover:bg-white/5"
>
Load more
</button>
) : null}
</div>
)}
</div>
) : null}
</div>
</div>
);
}
function EmptyState({
title,
body,
cta,
}: {
title: string;
body: string;
cta?: { label: string; href: string };
}) {
return (
<div className="rounded-2xl border border-white/8 bg-white/[0.03] px-6 py-10 text-center">
<p className="text-base font-medium text-white">{title}</p>
<p className="mt-2 text-sm text-slate-400">{body}</p>
{cta ? (
<Link
href={cta.href}
className="mt-4 inline-flex rounded-full border border-sky-400/30 bg-sky-400/10 px-5 py-2 text-sm font-medium text-sky-200 hover:bg-sky-400/15"
>
{cta.label}
</Link>
) : null}
</div>
);
}

View File

@@ -0,0 +1,269 @@
"use client";
import { creditTicker } from "@/lib/credits-brand";
import { motion } from "framer-motion";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
type Summary = {
balance: number;
infiniteCredits: boolean;
earned: number;
spent: number;
spendBreakdown: { label: string; credits: number }[];
balanceHistory: { at: string; balance: number }[];
recentActivity: { delta: number; type: string; memo: string | null; at: string }[];
totalDonatedUsd: number;
initiativePledges: number;
initiativePledgeCount: number;
missionPledges: number;
missionPledgeCount: number;
};
const SPEND_DESTINATIONS = [
{ emoji: "🌱", title: "Initiatives", body: "Lift ideas you want the coalition to fight for.", href: "/initiatives", accent: "border-emerald-400/30 hover:border-emerald-400/50" },
{ emoji: "🎯", title: "Missions", body: "Field, digital, or democracy-defense pledges.", href: "/missions", accent: "border-indigo-400/30 hover:border-indigo-400/50" },
{ emoji: "🗳️", title: "Straw poll", body: "Weighted supporter ballots.", href: "/vote/next-president", accent: "border-fuchsia-400/30 hover:border-fuchsia-400/50" },
{ emoji: "⚡", title: "Movement meter", body: "Pool credits for milestone bonuses.", href: "/boost", accent: "border-amber-400/30 hover:border-amber-400/50" },
{ emoji: "🎮", title: "Games", body: "Optional supporter games.", href: "/casino", accent: "border-sky-400/30 hover:border-sky-400/50" },
{ emoji: "🎁", title: "Perks", body: "Redeem perks and raffles below.", href: "#wallet-perks", accent: "border-violet-400/30 hover:border-violet-400/50" },
];
const CHART_COLORS = ["#38bdf8", "#a78bfa", "#34d399", "#f472b6", "#fbbf24", "#fb7185", "#818cf8"];
function ImpactBar({ label, pct, credits, count, color }: { label: string; pct: number; credits: number; count: number; color: string }) {
return (
<div>
<div className="flex justify-between text-sm">
<span className="text-slate-300">{label}</span>
<span className="font-mono text-slate-200">
{credits.toLocaleString()} {count > 0 ? `· ${count}×` : ""}
</span>
</div>
<div className="mt-1.5 h-2.5 overflow-hidden rounded-full bg-white/10">
<div className={`h-full rounded-full ${color}`} style={{ width: `${Math.min(100, pct)}%` }} />
</div>
<p className="mt-1 text-xs text-slate-500">{pct}% of your spending</p>
</div>
);
}
export function WalletDashboard() {
const t = creditTicker();
const [summary, setSummary] = useState<Summary | null>(null);
const [blwUsd, setBlwUsd] = useState<number | null>(null);
useEffect(() => {
let alive = true;
const load = async () => {
try {
const [sRes, rRes] = await Promise.all([
fetch("/api/wallet/summary", { cache: "no-store" }),
fetch("/api/exchange/rate", { cache: "no-store" }),
]);
if (sRes.ok && alive) setSummary((await sRes.json()) as Summary);
if (rRes.ok && alive) {
const j = await rRes.json();
if (typeof j.blwUsd === "number") setBlwUsd(j.blwUsd);
}
} catch {
/* ignore */
}
};
void load();
const id = setInterval(() => void load(), 20_000);
return () => {
alive = false;
clearInterval(id);
};
}, []);
const historyPoints = summary?.balanceHistory ?? [];
const chartMax = Math.max(1, ...historyPoints.map((p) => p.balance), summary?.balance ?? 1);
const maxSpend = useMemo(
() => Math.max(1, ...(summary?.spendBreakdown.map((s) => s.credits) ?? [1])),
[summary],
);
if (!summary) {
return (
<div className="mb-10 animate-pulse space-y-6">
<div className="h-44 rounded-3xl bg-white/5" />
<div className="grid gap-4 sm:grid-cols-2">
<div className="h-36 rounded-2xl bg-white/5" />
<div className="h-36 rounded-2xl bg-white/5" />
</div>
</div>
);
}
const indexUsd = !summary.infiniteCredits && blwUsd !== null ? (summary.balance * blwUsd).toFixed(2) : null;
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 sparkPath =
historyPoints.length < 2
? ""
: historyPoints
.map((p, i) => {
const x = (i / (historyPoints.length - 1)) * 100;
const y = 100 - (p.balance / chartMax) * 100;
return `${i === 0 ? "M" : "L"}${x},${y}`;
})
.join(" ");
return (
<WalletDashboardView
summary={summary}
t={t}
indexUsd={indexUsd}
initiativePct={initiativePct}
missionPct={missionPct}
sparkPath={sparkPath}
maxSpend={maxSpend}
/>
);
}
function WalletDashboardView(props: {
summary: Summary;
t: string;
indexUsd: string | null;
initiativePct: number;
missionPct: number;
sparkPath: string;
maxSpend: number;
}) {
const { summary, t, indexUsd, initiativePct, missionPct, sparkPath, maxSpend } = props;
return (
<div className="mb-10 space-y-8">
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="relative overflow-hidden rounded-3xl border border-violet-500/25 bg-gradient-to-br from-violet-950/80 via-[#0a1628] to-indigo-950/60 p-6 sm:p-8"
>
<div className="relative flex flex-wrap items-end justify-between gap-6">
<div>
<p className="text-xs uppercase tracking-[0.28em] text-violet-200/80">Your {t} stash</p>
<p className="mt-3 flex items-baseline gap-2">
<span className="text-5xl font-bold tabular-nums text-white sm:text-6xl">
{summary.infiniteCredits ? "∞" : summary.balance.toLocaleString()}
</span>
{!summary.infiniteCredits ? <span className="text-lg text-violet-200/90">{t}</span> : null}
</p>
{indexUsd ? (
<p className="mt-2 text-sm text-slate-400">
Index <span className="font-semibold text-emerald-300">${indexUsd}</span> · not cash
</p>
) : null}
</div>
<div className="flex gap-6 text-center sm:gap-8">
<div>
<p className="text-2xl font-semibold tabular-nums text-emerald-300">+{summary.earned.toLocaleString()}</p>
<p className="text-xs text-slate-500">earned</p>
</div>
<div>
<p className="text-2xl font-semibold tabular-nums text-rose-300">{summary.spent.toLocaleString()}</p>
<p className="text-xs text-slate-500">spent</p>
</div>
<div>
<p className="text-2xl font-semibold tabular-nums text-sky-300">${summary.totalDonatedUsd.toLocaleString()}</p>
<p className="text-xs text-slate-500">donated</p>
</div>
</div>
</div>
{sparkPath ? (
<div className="relative mt-6 h-20 w-full">
<svg viewBox="0 0 100 100" className="h-full w-full" preserveAspectRatio="none" aria-hidden>
<defs>
<linearGradient id="walletSpark" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgba(167,139,250,0.5)" />
<stop offset="100%" stopColor="rgba(167,139,250,0)" />
</linearGradient>
</defs>
<path d={`${sparkPath} L100,100 L0,100 Z`} fill="url(#walletSpark)" />
<path d={sparkPath} fill="none" stroke="#a78bfa" strokeWidth="2" vectorEffect="non-scaling-stroke" />
</svg>
<p className="absolute bottom-0 left-0 text-[10px] uppercase tracking-wider text-slate-500">Balance over time</p>
</div>
) : null}
</motion.div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<p className="text-sm font-medium text-white">Where you&apos;ve spent {t}</p>
{summary.spendBreakdown.length === 0 ? (
<p className="mt-4 text-sm text-slate-500">No spends yet try an initiative or mission first.</p>
) : (
<ul className="mt-4 space-y-3">
{summary.spendBreakdown.map((row, i) => (
<li key={row.label}>
<div className="flex justify-between text-xs text-slate-400">
<span>{row.label}</span>
<span className="font-mono text-slate-200">{row.credits.toLocaleString()}</span>
</div>
<div className="mt-1.5 h-2 overflow-hidden rounded-full bg-white/10">
<div
className="h-full rounded-full transition-all"
style={{ width: `${(row.credits / maxSpend) * 100}%`, backgroundColor: CHART_COLORS[i % CHART_COLORS.length] }}
/>
</div>
</li>
))}
</ul>
)}
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<p className="text-sm font-medium text-white">Your coalition impact</p>
<div className="mt-4 space-y-4">
<ImpactBar label="Initiatives" pct={initiativePct} credits={summary.initiativePledges} count={summary.initiativePledgeCount} color="bg-emerald-400" />
<ImpactBar label="Missions" pct={missionPct} credits={summary.missionPledges} count={summary.missionPledgeCount} color="bg-indigo-400" />
</div>
<Link href="/initiatives" className="mt-4 inline-block text-sm font-medium text-emerald-300 hover:text-emerald-200">
Steer the movement on initiatives
</Link>
</div>
</div>
{summary.recentActivity.length > 0 ? (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<p className="text-sm font-medium text-white">Recent activity</p>
<ul className="mt-3 space-y-2">
{summary.recentActivity.map((a, i) => (
<li key={`${a.at}-${i}`} className="flex items-center justify-between gap-3 text-sm">
<span className="truncate text-slate-400">{a.memo ?? a.type.replace(/_/g, " ")}</span>
<span className={`shrink-0 font-mono font-semibold ${a.delta > 0 ? "text-emerald-300" : "text-rose-300"}`}>
{a.delta > 0 ? "+" : ""}
{a.delta.toLocaleString()}
</span>
</li>
))}
</ul>
</div>
) : null}
<div>
<h2 className="text-lg font-semibold text-white">Put your {t} to work</h2>
<p className="mt-1 text-sm text-slate-400">Your spend shapes what the community prioritizes.</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{SPEND_DESTINATIONS.map((d) => (
<Link
key={d.href}
href={d.href}
className={`rounded-2xl border bg-black/20 p-4 transition ${d.accent}`}
>
<span className="text-2xl" aria-hidden>
{d.emoji}
</span>
<p className="mt-2 font-medium text-white">{d.title}</p>
<p className="mt-1 text-xs leading-relaxed text-slate-500">{d.body}</p>
</Link>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,162 @@
"use client";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import { motion } from "framer-motion";
import Link from "next/link";
import { useEffect, useState } from "react";
type Community = {
bwtOnInitiatives: number;
initiativePledgeActions: number;
bwtOnMissions: number;
missionPledgeActions: number;
raisedUsd: number;
giftCount: number;
topInitiatives: { title: string; pledged: number }[];
};
const spendTiles = [
{ emoji: "🌱", title: "Democratic initiatives", body: "Back grassroots ideas or rally behind platform priorities.", href: "/initiatives" },
{ emoji: "🎯", title: "Mission pledges", body: "Steer credits toward field, digital, or democracy-defense lanes.", href: "/missions" },
{ emoji: "🗳️", title: "Straw poll", body: "Cast weighted supporter ballots and shape the conversation.", href: "/vote/next-president" },
{ emoji: "🎮", title: "Games & perks", body: "Optional fun — raffles, perks, and supporter games from one balance.", href: "/casino" },
];
export function WalletGuestView() {
const t = creditTicker();
const n = creditDisplayName();
const [community, setCommunity] = useState<Community | null>(null);
useEffect(() => {
let alive = true;
void fetch("/api/wallet/community", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
.then((j) => {
if (alive && j) setCommunity(j as Community);
})
.catch(() => {});
return () => {
alive = false;
};
}, []);
return (
<div className="relative overflow-hidden pb-16">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_70%_50%_at_50%_0%,rgba(99,102,241,0.15),transparent_55%)]" />
<div className="relative mx-auto max-w-5xl px-4 py-12 sm:px-6 sm:py-16">
<motion.div initial={{ opacity: 0, y: 12 }} animate={{ opacity: 1, y: 0 }} className="text-center">
<p className="text-xs font-medium uppercase tracking-[0.28em] text-violet-200/90">Supporter wallet</p>
<h1 className="mt-3 text-3xl font-bold text-white sm:text-4xl">Your {t} home base</h1>
<p className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-slate-400">
Donate with Stripe while signed in, earn {n} ({t}), then spend it across the movement initiatives, missions,
polls, and more. Sign in to see your balance and charts.
</p>
<div className="mt-8 flex flex-wrap justify-center gap-3">
<Link
href="/register"
className="rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-500/25"
>
Create free account
</Link>
<Link
href="/login?callbackUrl=%2Fwallet"
className="rounded-full border border-white/20 px-6 py-3 text-sm font-medium text-slate-200 hover:bg-white/5"
>
Sign in to open wallet
</Link>
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="mx-auto mt-12 max-w-md rounded-3xl border border-dashed border-violet-400/35 bg-violet-950/30 p-8 text-center"
>
<p className="text-6xl" aria-hidden>
🪙
</p>
<p className="mt-4 font-mono text-4xl font-bold tabular-nums text-white"></p>
<p className="mt-1 text-sm text-slate-400">{n} balance (sign in to view yours)</p>
</motion.div>
{community ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.15 }}
className="mt-10 grid gap-4 sm:grid-cols-3"
>
<StatCard label="Raised together" value={`$${community.raisedUsd.toLocaleString()}`} sub={`${community.giftCount} gifts`} />
<StatCard label={`${t} on initiatives`} value={community.bwtOnInitiatives.toLocaleString()} sub={`${community.initiativePledgeActions} pledges`} />
<StatCard label={`${t} on missions`} value={community.bwtOnMissions.toLocaleString()} sub={`${community.missionPledgeActions} pledges`} />
</motion.div>
) : null}
<div className="mt-12">
<h2 className="text-center text-xl font-semibold text-white">What your {t} can do</h2>
<p className="mx-auto mt-2 max-w-lg text-center text-sm text-slate-400">
Every credit you spend is a signal where the coalition should focus next.
</p>
<div className="mt-8 grid gap-4 sm:grid-cols-2">
{spendTiles.map((tile) => (
<Link
key={tile.href}
href={tile.href}
className="rounded-2xl border border-white/10 bg-white/[0.04] p-5 transition hover:border-violet-400/35 hover:bg-white/[0.07]"
>
<span className="text-2xl" aria-hidden>
{tile.emoji}
</span>
<p className="mt-3 font-semibold text-white">{tile.title}</p>
<p className="mt-2 text-sm text-slate-400">{tile.body}</p>
<p className="mt-3 text-sm font-medium text-violet-300">Explore </p>
</Link>
))}
</div>
</div>
{community && community.topInitiatives.length > 0 ? (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="mt-10 rounded-2xl border border-emerald-500/20 bg-emerald-950/20 p-6"
>
<p className="text-sm font-medium text-emerald-200">Community is spending {t} on</p>
<ul className="mt-4 space-y-3">
{community.topInitiatives.map((i) => (
<li key={i.title} className="flex items-center justify-between gap-4 text-sm">
<span className="text-slate-200">{i.title}</span>
<span className="shrink-0 font-mono text-emerald-300">{i.pledged.toLocaleString()} {t}</span>
</li>
))}
</ul>
<Link href="/initiatives" className="mt-4 inline-block text-sm font-medium text-emerald-300 hover:text-emerald-200">
Browse all initiatives
</Link>
</motion.div>
) : null}
<p className="mx-auto mt-10 max-w-md text-center text-sm text-slate-500">
Already gave as a guest?{" "}
<Link href="/register" className="text-sky-300 hover:underline">
Enroll with the same email
</Link>{" "}
future gifts will credit your wallet automatically.
</p>
</div>
</div>
);
}
function StatCard({ label, value, sub }: { label: string; value: string; sub: string }) {
return (
<div className="rounded-2xl border border-white/10 bg-white/[0.04] p-4 text-center">
<p className="text-xs uppercase tracking-wider text-slate-500">{label}</p>
<p className="mt-2 font-mono text-2xl font-semibold text-white">{value}</p>
<p className="mt-1 text-xs text-slate-500">{sub}</p>
</div>
);
}

View File

@@ -1,13 +1,30 @@
import { auth } from "@/auth";
import { isAdminRole } from "@/lib/admin";
import { SiteFooter } from "@/components/SiteFooter";
import { creditDisplayName } from "@/lib/credits-brand";
import { appTitle } from "@/lib/public-env";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import type { Metadata } from "next";
import { Suspense } from "react";
import { WalletActions } from "./WalletActions";
import { WalletDashboard } from "./WalletDashboard";
import { WalletGuestView } from "./WalletGuestView";
export const metadata: Metadata = {
title: `Supporter wallet — ${appTitle()}`,
description: `Your ${creditDisplayName()} wallet: balance, charts, coalition impact, and every way to spend supporter credits.`,
};
export default async function WalletPage() {
const session = await auth();
if (!session?.user?.id) {
redirect("/login?callbackUrl=/wallet");
return (
<>
<WalletGuestView />
<SiteFooter />
</>
);
}
const userId = session.user.id;
@@ -20,31 +37,36 @@ export default async function WalletPage() {
]);
const admin = isAdminRole(dbUser?.role ?? session.user.role);
const creditName = process.env.PUBLIC_CREDIT_NAME ?? "BLW";
const creditName = creditDisplayName();
return (
<div className="mx-auto max-w-5xl px-4 py-16 sm:px-6">
<p className="text-xs uppercase tracking-[0.32em] text-slate-400">Supporter wallet</p>
<h1 className="mt-3 text-3xl font-semibold text-white sm:text-4xl">Your Blue Wave (BLW)</h1>
<p className="mt-4 max-w-2xl text-slate-400">
BLW accrues after Stripe confirms a donation via webhook. This page exercises redemption and raffle flows against the
ledgerswap SKUs for production fulfillment when ready.
</p>
{admin ? (
<p className="mt-4 rounded-2xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">
Signed in as <strong className="text-white">ADMIN</strong> unlimited credits for QA (spends do not
debit your wallet).
</p>
) : null}
<div className="mt-10">
<WalletActions
initialBalance={wallet?.balanceCredits ?? 0}
infiniteCredits={admin}
creditName={creditName}
prizes={prizes}
raffles={raffles}
/>
<>
<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">
<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>
<Suspense
fallback={
<div className="animate-pulse rounded-3xl border border-white/10 bg-white/5 p-8 text-center text-slate-500">
Loading wallet actions
</div>
}
>
<WalletActions
initialBalance={wallet?.balanceCredits ?? 0}
infiniteCredits={admin}
creditName={creditName}
prizes={prizes}
raffles={raffles}
compactBalance
/>
</Suspense>
</div>
</div>
</div>
</div>
<SiteFooter />
</>
);
}

30
src/auth.config.ts Normal file
View File

@@ -0,0 +1,30 @@
/**
* Edge-safe NextAuth config — no Node.js modules (no bcrypt, no prisma).
* Used by the middleware wrapper which delegates to Edge runtime rules.
* The full auth config (with providers + bcrypt) lives in auth.ts.
*/
import type { NextAuthConfig } from "next-auth";
const THIRTY_DAYS_SEC = 30 * 24 * 60 * 60;
export const authConfig: NextAuthConfig = {
trustHost: true,
/**
* JWT strategy + maxAge keeps the supporter session alive for 30 days in the HTTP-only cookie
* (`authjs.session-token`). Persisted logins survive browser restart until expiry or logout.
* Keep `AUTH_SECRET` stable across deploys — rotating it logs everyone out immediately.
*/
session: { strategy: "jwt", maxAge: THIRTY_DAYS_SEC },
jwt: {
maxAge: THIRTY_DAYS_SEC,
},
pages: {
signIn: "/login",
},
callbacks: {
authorized() {
return true;
},
},
providers: [], // providers live in auth.ts — not needed on edge middleware config
};

View File

@@ -2,29 +2,48 @@ import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import { z } from "zod";
import {
isEmailShape,
normalizeEmail,
normalizeUsername,
USERNAME_RE,
} from "@/lib/account-identifiers";
import { prisma } from "@/lib/prisma";
import { authConfig } from "./auth.config";
const credentialsSchema = z.object({
email: z.string().email(),
email: z.string().trim().min(1),
password: z.string().min(1),
});
export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true,
session: { strategy: "jwt", maxAge: 30 * 24 * 60 * 60 },
...authConfig,
providers: [
Credentials({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
email: { label: "Email or username", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(raw) {
const parsed = credentialsSchema.safeParse(raw);
if (!parsed.success) return null;
const { email, password } = parsed.data;
const user = await prisma.user.findUnique({ where: { email } });
const identifier = parsed.data.email;
const password = parsed.data.password;
let user = null;
if (isEmailShape(identifier)) {
const emailLookup = normalizeEmail(identifier);
user = await prisma.user.findFirst({
where: { email: { equals: emailLookup, mode: "insensitive" } },
});
} else {
const loginName = normalizeUsername(identifier);
if (!USERNAME_RE.test(loginName)) return null;
user = await prisma.user.findUnique({ where: { username: loginName } });
}
if (!user?.passwordHash) return null;
const ok = await bcrypt.compare(password, user.passwordHash);

View File

@@ -1,86 +1,210 @@
"use client";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import { motion } from "framer-motion";
import Link from "next/link";
const actions = [
const ticker = creditTicker();
const creditName = creditDisplayName();
type Tone = "sky" | "indigo" | "violet" | "fuchsia" | "amber" | "emerald";
const toneRing: Record<Tone, string> = {
sky: "border-sky-500/20 from-sky-500/10 shadow-[0_0_0_1px_rgba(56,189,248,0.08)] hover:border-sky-400/35 hover:shadow-[0_12px_48px_-12px_rgba(56,189,248,0.35)]",
indigo:
"border-indigo-500/20 from-indigo-500/10 shadow-[0_0_0_1px_rgba(99,102,241,0.08)] hover:border-indigo-400/35 hover:shadow-[0_12px_48px_-12px_rgba(99,102,241,0.3)]",
violet:
"border-violet-500/20 from-violet-500/10 shadow-[0_0_0_1px_rgba(139,92,246,0.08)] hover:border-violet-400/35 hover:shadow-[0_12px_48px_-12px_rgba(139,92,246,0.28)]",
fuchsia:
"border-fuchsia-500/20 from-fuchsia-500/10 shadow-[0_0_0_1px_rgba(217,70,239,0.08)] hover:border-fuchsia-400/35 hover:shadow-[0_12px_48px_-12px_rgba(217,70,239,0.28)]",
amber:
"border-amber-500/20 from-amber-500/10 shadow-[0_0_0_1px_rgba(245,158,11,0.08)] hover:border-amber-400/35 hover:shadow-[0_12px_48px_-12px_rgba(245,158,11,0.25)]",
emerald:
"border-emerald-500/20 from-emerald-500/10 shadow-[0_0_0_1px_rgba(52,211,153,0.08)] hover:border-emerald-400/35 hover:shadow-[0_12px_48px_-12px_rgba(52,211,153,0.28)]",
};
const primary = [
{
title: "Donate and lock BLW",
eyebrow: "Money",
body: "Start checkout, freeze the mock spot rate, and let the Stripe webhook credit your supporter wallet.",
href: "/#donate",
cta: "Donate now",
tone: "sky" as const,
icon: "💸",
kicker: "Give",
title: "Contribute at a fixed tier",
body: `Pick $5$100, lock your rate at checkout, and watch the meter move. Signed-in supporters bank ${ticker} for the full experience.`,
href: "/donate",
cta: "Donate",
},
{
title: "Spend credits",
eyebrow: "Rewards",
body: "Redeem digital perks or enter raffles from the wallet once donations settle.",
tone: "indigo" as const,
icon: "🎁",
kicker: "Spend",
title: `Put ${ticker} to work`,
body: `Perks, raffles, straw poll, missions, initiatives, and games — one ${creditName} wallet, every surface wired the same way.`,
href: "/wallet",
cta: "Open wallet",
},
{
title: "Recruit three people",
eyebrow: "Network",
body: "Use the issue cards as a conversation script, then pull friends into the donation and action loop.",
href: "/#priorities",
cta: "Pick an issue",
tone: "violet" as const,
icon: "🎯",
kicker: "Field",
title: "Pledge mission energy",
body: "Tell the committee where you want organizers focused — voting access, climate jobs, healthcare affordability, and more.",
href: "/missions",
cta: "Mission pledges",
},
{
title: "Plan a mini-sprint",
eyebrow: "Field",
body: "Use the impact planner to pair dollars with hours and decide where to focus the next local push.",
href: "/#impact",
cta: "Build a plan",
tone: "fuchsia" as const,
icon: "🌱",
kicker: "Ideas",
title: "Grassroots initiatives",
body: "Post one proposal per account and rally backers. The leaderboard keeps the best community ideas in sight.",
href: "/initiatives",
cta: "Browse initiatives",
},
{
title: "Run accountability messaging",
eyebrow: "Narrative",
body: "Frame the contrast around corruption, rights, evidence, and solidarity without cheap shots.",
href: "/#accountability",
cta: "Read the frame",
tone: "amber" as const,
icon: "🗳️",
kicker: "Voice",
title: "Straw poll & sentiment",
body: `Cast weighted ballots, shape the narrative, and see how supporters stack up — engagement only, not an official election.`,
href: "/vote/next-president",
cta: "Join the poll",
},
{
title: "Bring the receipts",
eyebrow: "Trust",
body: "Point donors to aggregate totals on /raised, wallet ledger behavior, and compliance stubs before asking again.",
tone: "emerald" as const,
icon: "📊",
kicker: "Proof",
title: "Live fundraising board",
body: "Dollars raised, gift count, and goal progress — the same numbers that power the homepage hero, in one place.",
href: "/raised",
cta: "Show the loop",
cta: "See the board",
},
];
const quickLinks = [
{ href: "/#start", label: "Start here" },
{ href: "/#impact", label: "Impact planner" },
{ href: "/#accountability", label: "Our frame" },
{ href: "/#priorities", label: "Priorities" },
{ href: "/#meter", label: "Live meter" },
{ href: "/#leaderboard", label: "Hall of fame" },
{ href: "/#faq", label: "FAQ" },
{ href: "/#mission", label: "Mission" },
{ href: "/missions", label: "Missions" },
{ href: "/initiatives", label: "Initiatives" },
{ href: "/cards", label: "Supporter cards" },
{ href: "/faq-board", label: "FAQ board" },
{ href: "/casino", label: "Games" },
{ href: "/spotlight", label: "Spotlight" },
];
export function ActionCenter() {
return (
<section id="actions" className="border-b border-white/10 py-20">
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.32em] text-slate-400">More things to do</p>
<h2 className="mt-4 max-w-3xl text-3xl font-semibold text-white sm:text-4xl">
Turn a donation page into a supporter playground.
</h2>
</div>
<p className="max-w-xl text-sm leading-relaxed text-slate-400">
The best fundraising experience gives people immediate next steps. This hub keeps the supporter moving
from money to identity, then from identity to action.
</p>
<section id="actions" className="relative overflow-hidden border-b border-white/10 py-14 sm:py-16">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_70%_50%_at_50%_0%,rgba(99,102,241,0.09),transparent_55%),radial-gradient(ellipse_55%_45%_at_50%_100%,rgba(56,189,248,0.06),transparent_55%)]" />
<div className="relative mx-auto max-w-6xl px-4 sm:px-6">
<div className="mx-auto max-w-3xl text-center">
<motion.p
initial={{ opacity: 0, y: 8 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45 }}
className="text-xs font-medium uppercase tracking-[0.28em] text-slate-400"
>
Action center
</motion.p>
<motion.h2
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.05 }}
className="mt-3 text-3xl font-bold tracking-tight text-white sm:text-4xl"
>
Everything worth doing{" "}
<span className="bg-gradient-to-r from-sky-300 via-indigo-200 to-fuchsia-300 bg-clip-text text-sky-100 supports-[(-webkit-background-clip:text)]:text-transparent">
in one loop
</span>
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.1 }}
className="mt-4 text-sm leading-relaxed text-slate-400 sm:text-base"
>
Give once, stay in the portal, and keep moving missions, initiatives, polls, perks, and the public meter are all
wired to the same supporter journey.
</motion.p>
<motion.p
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5, delay: 0.14 }}
className="mx-auto mt-4 max-w-2xl text-sm leading-relaxed text-slate-500 sm:text-base"
>
No scavenger hunt: use the big cards for the main paths, then skim Also on this site for deep cuts like the spotlight
auction, supporter games, or the hall of fame. Everything links somewhere real if a page asks you to sign in, it is
because that feature touches your wallet.
</motion.p>
</div>
<div className="mt-10 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{actions.map((action, index) => (
<Link
key={action.title}
href={action.href}
className="group rounded-3xl border border-white/10 bg-white/[0.04] p-6 transition hover:-translate-y-1 hover:border-sky-300/40 hover:bg-white/[0.07] hover:shadow-[0_0_70px_rgba(56,189,248,0.12)]"
<div className="mx-auto mt-10 grid max-w-5xl gap-5 sm:grid-cols-2 lg:mt-12 lg:grid-cols-3 lg:gap-6">
{primary.map((action, index) => (
<motion.div
key={action.href + action.title}
initial={{ opacity: 0, y: 18 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-40px" }}
transition={{ delay: index * 0.05, duration: 0.45 }}
className="min-h-0"
>
<div className="flex items-center justify-between gap-4">
<p className="text-xs uppercase tracking-[0.26em] text-sky-200/80">{action.eyebrow}</p>
<span className="rounded-full border border-white/10 px-2 py-1 font-mono text-xs text-slate-500">
{String(index + 1).padStart(2, "0")}
<Link
href={action.href}
className={`group relative flex h-full min-h-[240px] flex-col items-center overflow-hidden rounded-2xl border bg-gradient-to-b to-transparent p-6 text-center transition duration-300 hover:-translate-y-0.5 ${toneRing[action.tone]}`}
>
<span
aria-hidden
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.06] text-2xl shadow-inner shadow-black/20"
>
{action.icon}
</span>
</div>
<h3 className="mt-4 text-xl font-semibold text-white">{action.title}</h3>
<p className="mt-3 text-sm leading-relaxed text-slate-400">{action.body}</p>
<p className="mt-6 text-sm font-semibold text-sky-200 group-hover:text-white">{action.cta} </p>
</Link>
<p className="mt-4 text-[11px] font-semibold uppercase tracking-[0.22em] text-slate-500">{action.kicker}</p>
<h3 className="mt-2 text-lg font-semibold leading-snug text-white sm:text-xl">{action.title}</h3>
<p className="mt-3 flex-1 text-sm leading-relaxed text-slate-400">{action.body}</p>
<span className="mt-6 inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/[0.06] px-5 py-2 text-sm font-semibold text-white transition group-hover:border-white/25 group-hover:bg-white/[0.1]">
{action.cta}
<span aria-hidden className="text-sky-300 transition group-hover:translate-x-0.5 group-hover:text-white">
</span>
</span>
</Link>
</motion.div>
))}
</div>
<motion.div
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.45, delay: 0.15 }}
className="mx-auto mt-10 max-w-4xl rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-5 sm:px-6"
>
<p className="text-center text-xs font-semibold uppercase tracking-[0.2em] text-slate-500">Also on this site</p>
<nav
aria-label="Secondary actions"
className="mt-4 flex flex-wrap items-center justify-center gap-2 text-sm text-slate-400 sm:gap-x-3"
>
{quickLinks.map((q) => (
<Link
key={q.href}
href={q.href}
className="rounded-full border border-transparent px-3 py-1.5 text-slate-300 transition hover:border-white/10 hover:bg-white/5 hover:text-white"
>
{q.label}
</Link>
))}
</nav>
</motion.div>
</div>
</section>
);

View File

@@ -0,0 +1,75 @@
"use client";
import { ALLOWED_DONATION_USD_CENTS, BLW_TICKER } from "@/lib/exchange";
import { useEffect, useState } from "react";
type TierRow = { tierCents: number; tierUsd: number; blwCreditsAtSpot: number };
export function BlwTierTable() {
const [tiers, setTiers] = useState<TierRow[] | null>(null);
const [blwUsd, setBlwUsd] = useState<number | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let alive = true;
const load = async () => {
try {
const res = await fetch("/api/exchange/rate", { cache: "no-store" });
if (!res.ok) throw new Error("fail");
const j = await res.json();
if (!alive) return;
setTiers(j.tiers as TierRow[]);
setBlwUsd(j.blwUsd as number);
setErr(null);
} catch {
if (alive) setErr("Could not load spot rates");
}
};
load();
const id = setInterval(load, 20_000);
return () => {
alive = false;
clearInterval(id);
};
}, []);
return (
<div className="mt-8 rounded-2xl border border-white/10 bg-black/30 p-5 text-center sm:p-6">
<p className="text-xs uppercase tracking-[0.24em] text-slate-400">Wave token preview</p>
<p className="mx-auto mt-2 max-w-md text-sm text-slate-500">
Numbers refresh live for funyour actual {BLW_TICKER} for a signed-in gift is set the moment you open secure checkout.
</p>
{blwUsd !== null ? (
<p className="mt-2 font-mono text-sm text-sky-200/90">
Spot: ${blwUsd.toFixed(4)} USD / {BLW_TICKER}
</p>
) : null}
{err ? <p className="mt-3 text-sm text-rose-300">{err}</p> : null}
{tiers && tiers.length > 0 ? (
<div className="mx-auto mt-4 max-w-md overflow-x-auto">
<table className="w-full min-w-[280px] text-center text-sm">
<thead>
<tr className="border-b border-white/10 text-xs uppercase tracking-wide text-slate-500">
<th className="py-2 pr-4">Donation</th>
<th className="py-2 pl-4">~{BLW_TICKER} now</th>
</tr>
</thead>
<tbody className="text-slate-200">
{ALLOWED_DONATION_USD_CENTS.map((cents) => {
const row = tiers.find((t) => t.tierCents === cents);
return (
<tr key={cents} className="border-b border-white/5">
<td className="py-3 pr-4 font-medium">${(cents / 100).toFixed(0)}</td>
<td className="py-3 pl-4 font-mono tabular-nums">{row?.blwCreditsAtSpot ?? "—"}</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : !err ? (
<p className="mt-4 text-sm text-slate-500">Loading tier preview</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,84 @@
import Link from "next/link";
import { creditDisplayName, creditLabel, creditTicker } from "@/lib/credits-brand";
export function BwtPrinciplesSection() {
const ticker = creditTicker();
const name = creditDisplayName();
const label = creditLabel();
return (
<section
id="bwt"
className="relative scroll-mt-28 overflow-hidden border-b border-white/10 bg-[#030712] py-10"
>
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_50%_40%_at_50%_20%,rgba(56,189,248,0.06),transparent)]" />
<div className="relative mx-auto max-w-6xl px-4 text-center sm:px-6">
<p className="text-xs uppercase tracking-[0.32em] text-sky-200/75">Supporter credits</p>
<h2 className="mx-auto mt-3 max-w-4xl text-3xl font-semibold text-white sm:text-4xl">
What is {label}?
</h2>
<p className="mx-auto mt-3 max-w-3xl text-slate-400">
{name} ({ticker}) is the way we say thank you on this site after you donate while signed in. It lives in your wallet here
only meant for perks, missions, polls, initiatives, and optional games not as cash you move somewhere else.
</p>
<p className="mx-auto mt-4 max-w-3xl text-sm leading-relaxed text-slate-500">
Think of it as arcade tokens for democracy: fun to earn, satisfying to spend, and always tied to the work were trying to
fund together.
</p>
<div className="mx-auto mt-8 grid max-w-5xl gap-4 text-left md:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-6">
<h3 className="text-lg font-semibold text-white">What to expect</h3>
<ul className="mt-4 list-disc space-y-2.5 pl-5 text-sm leading-relaxed text-slate-400">
<li>Credits show up after your card gift clears we keep the books simple so everyone trusts the meter.</li>
<li>The published rate for {ticker} is part of our transparency story; it is not a tradable asset or investment.</li>
<li>Spending is recorded in your supporter wallet perks, raffles, polls, missions, initiatives, and games all pull from the same balance.</li>
<li>Questions? Open the FAQ at the bottom of the homepage we wrote it for friends and family, not lawyers.</li>
</ul>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-6">
<h3 className="text-lg font-semibold text-white">Where to spend first</h3>
<p className="mt-4 text-sm leading-relaxed text-slate-400">
If you are not sure where to begin, try a mission pledge it is the fastest way to say fund this fight. Then browse{" "}
<Link href="/initiatives" className="font-medium text-sky-300 underline-offset-4 hover:text-white hover:underline">
democratic initiatives
</Link>{" "}
to lift a neighbors idea, or open your{" "}
<Link href="/wallet" className="font-medium text-sky-300 underline-offset-4 hover:text-white hover:underline">
wallet
</Link>{" "}
when you are ready for perks and raffles.
</p>
<p className="mt-4 text-sm leading-relaxed text-slate-500">
Prefer to watch before you spend? Check the{" "}
<Link href="/raised" className="font-medium text-sky-300 underline-offset-4 hover:text-white hover:underline">
live totals board
</Link>{" "}
the same numbers that feed the homepage meter.
</p>
<div className="mt-6 flex flex-wrap gap-3">
<Link
href="/wallet"
className="inline-flex rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20"
>
Wallet
</Link>
<Link
href="/missions"
className="inline-flex rounded-full border border-white/15 px-5 py-2.5 text-sm font-medium text-slate-200 hover:bg-white/5"
>
Mission pledges
</Link>
<Link
href="/initiatives"
className="inline-flex rounded-full border border-white/15 px-5 py-2.5 text-sm font-medium text-slate-200 hover:bg-white/5"
>
Initiatives
</Link>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,82 @@
import Link from "next/link";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
const spendLinks = [
{ href: "/missions", label: "Mission pledges" },
{ href: "/initiatives", label: "Democratic initiatives" },
{ href: "/vote/next-president", label: "Straw poll" },
{ href: "/casino", label: "Games" },
{ href: "/wallet", label: "Perks & raffles" },
{ href: "/boost", label: "Movement meter" },
];
export function CreditsFlowSection() {
const t = creditTicker();
const n = creditDisplayName();
const steps = [
{
n: "1",
title: "Donate with Stripe",
body: "Pick $5, $10, $20, or $100. Card checkout is secure and counts on the public fundraising meter for everyone.",
},
{
n: "2",
title: `Earn ${t}`,
body: `Signed-in supporters receive ${n} (${t}) in their wallet after payment clears. The amount is locked at checkout — guest gifts move the meter but do not mint credits.`,
},
{
n: "3",
title: "Spend across the site",
body: `Use ${t} on missions, grassroots initiatives, polls, perks, games, and more — so your donation keeps working after checkout.`,
},
];
return (
<section
id="how-credits-work"
className="scroll-mt-28 border-b border-white/10 bg-[#040a14] py-12 sm:py-14"
>
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="mx-auto max-w-3xl text-center">
<p className="text-xs font-medium uppercase tracking-[0.28em] text-indigo-200/85">How it works</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight text-white sm:text-4xl">
Donate get {t} put it to work
</h2>
<p className="mx-auto mt-4 max-w-2xl text-base leading-relaxed text-slate-400">
{n} ({t}) are supporter credits tied to your gift not a tradable blockchain coin. They live in your wallet here
and power an interactive experience that goes far beyond a one-and-done donate button.
</p>
</div>
<div className="mt-10 grid gap-4 md:grid-cols-3">
{steps.map((s) => (
<div key={s.n} className="rounded-2xl border border-white/10 bg-white/[0.04] p-5">
<span className="flex h-10 w-10 items-center justify-center rounded-full border border-indigo-400/30 bg-indigo-500/10 font-mono text-sm font-bold text-indigo-200">
{s.n}
</span>
<p className="mt-4 text-lg font-semibold text-white">{s.title}</p>
<p className="mt-2 text-sm leading-relaxed text-slate-400">{s.body}</p>
</div>
))}
</div>
<div className="mt-10 rounded-2xl border border-indigo-500/20 bg-indigo-950/30 p-6 text-center sm:text-left">
<p className="text-sm font-medium text-white">Where {t} goes</p>
<p className="mt-2 text-sm text-slate-400">
One wallet, many surfaces pick what matters to you and see your support show up in real time.
</p>
<div className="mt-5 flex flex-wrap justify-center gap-2 sm:justify-start">
{spendLinks.map((l) => (
<Link
key={l.href}
href={l.href}
className="rounded-full border border-white/15 px-4 py-2 text-sm text-slate-300 transition hover:bg-white/5 hover:text-white"
>
{l.label}
</Link>
))}
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import { useEffect, useRef } from "react";
type Streamer = {
x: number;
y: number;
vx: number;
vy: number;
life: number;
maxLife: number;
color: string;
len: number;
angle: number;
spin: number;
size: number;
};
const COLORS = [
"#ef4444", // red
"#ef4444",
"#f8fafc", // white
"#f8fafc",
"#3b82f6", // blue
"#3b82f6",
"#60a5fa", // lighter blue
"#fca5a5", // lighter red
];
export function CursorStreamers() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const streamersRef = useRef<Streamer[]>([]);
const mouseRef = useRef({ x: -999, y: -999 });
const rafRef = useRef(0);
const frameRef = useRef(0);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if ("ontouchstart" in window) return; // skip on touch devices
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
resize();
window.addEventListener("resize", resize);
const onMove = (e: MouseEvent) => {
mouseRef.current = { x: e.clientX, y: e.clientY };
frameRef.current += 1;
// Spawn 2 streamers every other frame (not every frame — subtle)
if (frameRef.current % 2 !== 0) return;
for (let i = 0; i < 2; i++) {
const angle = Math.random() * Math.PI * 2;
const speed = 1.2 + Math.random() * 2.2;
streamersRef.current.push({
x: e.clientX + (Math.random() - 0.5) * 6,
y: e.clientY + (Math.random() - 0.5) * 6,
vx: Math.cos(angle) * speed * 0.7,
vy: Math.sin(angle) * speed - 0.6, // slight upward drift
life: 1,
maxLife: 0.55 + Math.random() * 0.6,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
len: 5 + Math.random() * 9,
angle: angle,
spin: (Math.random() - 0.5) * 0.18,
size: 1.2 + Math.random() * 1.6,
});
}
// Cap total streamers
if (streamersRef.current.length > 120) {
streamersRef.current = streamersRef.current.slice(-120);
}
};
window.addEventListener("mousemove", onMove);
const tick = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
const dt = 0.016;
streamersRef.current = streamersRef.current.filter((s) => s.life > 0);
for (const s of streamersRef.current) {
s.life -= dt / s.maxLife;
s.x += s.vx;
s.y += s.vy;
s.vy += 0.04; // gentle gravity
s.vx *= 0.985; // air drag
s.angle += s.spin;
const alpha = Math.max(0, s.life) * 0.88;
ctx.save();
ctx.globalAlpha = alpha;
ctx.translate(s.x, s.y);
ctx.rotate(s.angle);
// Draw a small rounded ribbon/streamer
ctx.beginPath();
ctx.roundRect(-s.len / 2, -s.size / 2, s.len, s.size, s.size / 2);
ctx.fillStyle = s.color;
ctx.fill();
ctx.restore();
}
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(rafRef.current);
window.removeEventListener("mousemove", onMove);
window.removeEventListener("resize", resize);
};
}, []);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ position: "fixed", inset: 0, zIndex: 20, pointerEvents: "none" }}
/>
);
}

View File

@@ -1,45 +1,50 @@
import { DonationCheckout } from "./DonationCheckout";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import { BlwTierTable } from "./BlwTierTable";
import { EmbeddedDonationCheckout } from "./EmbeddedDonationCheckout";
import { MockExchangeTicker } from "./MockExchangeTicker";
export function DonateSection({ publishableKey }: { publishableKey: string }) {
const t = creditTicker();
const n = creditDisplayName();
return (
<section id="donate" className="border-b border-white/10 py-20">
<div className="mx-auto grid max-w-6xl gap-12 px-4 lg:grid-cols-[1.1fr_0.9fr] sm:px-6">
<div>
<p className="text-xs uppercase tracking-[0.32em] text-slate-400">Secure donation</p>
<h2 className="mt-4 text-3xl font-semibold text-white sm:text-4xl">
Fixed tiers + Blue Wave (BLW) spot index.
<section id="donate" className="scroll-mt-28 border-b border-white/10 py-12">
<div className="mx-auto grid max-w-6xl gap-8 px-4 sm:px-6 lg:grid-cols-2 lg:items-stretch lg:gap-10">
<div className="flex flex-col rounded-2xl border border-white/10 bg-[#050816]/50 p-6 text-center backdrop-blur-sm sm:p-7 lg:text-center">
<p className="text-xs font-medium uppercase tracking-[0.28em] text-slate-400">Fuel the wave</p>
<h2 className="mx-auto mt-3 max-w-lg text-3xl font-semibold tracking-tight text-white sm:text-4xl">
Pick a tier · unlock {t} perks
</h2>
<p className="mt-4 max-w-xl text-slate-300">
Pick <span className="text-white">$5, $10, $20, or $100</span>. Stripe settles real dollars; BLW credits are
minted using a mock exchange rate <span className="text-white">locked when you open checkout</span>. Watch the
live index when BLW looks cheap in USD, your tier buys more BLW (and viceversa).
<p className="mx-auto mt-4 max-w-md text-[15px] leading-relaxed text-slate-300">
Chip in at <span className="text-white">$5, $10, $20, or $100</span>. Your dollars hit the live meter; signed-in
supporters get {n} ({t}) tied to the tier you lock when you hit checkout.
</p>
<div className="mt-8 grid gap-3 sm:grid-cols-3">
<div className="mx-auto mt-6 grid w-full max-w-md grid-cols-3 gap-2">
{[
["1", "Choose a tier"],
["2", "Lock BLW rate"],
["3", "Unlock wallet perks"],
["1", "Choose amount"],
["2", "Secure pay"],
["3", "Perks + meter"],
].map(([step, label]) => (
<div key={step} className="rounded-2xl border border-white/10 bg-white/5 p-4">
<p className="font-mono text-2xl font-semibold text-sky-200">{step}</p>
<p className="mt-2 text-sm text-slate-300">{label}</p>
<div key={step} className="rounded-xl border border-white/10 bg-white/5 px-3 py-3 text-center">
<p className="font-mono text-lg font-semibold text-sky-200">{step}</p>
<p className="mt-1 text-xs leading-snug text-slate-400">{label}</p>
</div>
))}
</div>
<div className="mt-8">
<div className="mt-6">
<MockExchangeTicker />
</div>
<div className="mt-8 rounded-3xl border border-white/10 bg-white/5 p-6 text-sm text-slate-300">
<p className="font-semibold text-white">Why serverconfirmed credits matter</p>
<p className="mt-2 leading-relaxed">
The browser never mints money. A Stripe webhook confirms the charge, then our ledger adds BLW units once
idempotently using the snapshot stored on the PaymentIntent.
<BlwTierTable />
<div className="mt-6 rounded-xl border border-sky-500/20 bg-sky-500/10 p-4 text-center text-sm leading-relaxed text-slate-200">
<p className="font-medium text-white">What you see is what you get</p>
<p className="mt-1.5 text-sky-100/90">
One clean checkout, one moment on the board. Stay signed in if you want {t} in your wallet for games, raffles, and
missionsguest gifts still move the needle for everyone.
</p>
</div>
</div>
<div className="rounded-[28px] border border-white/10 bg-[#050816]/80 p-6 shadow-[0_0_120px_rgba(59,130,246,0.12)] backdrop-blur-xl sm:p-8">
<DonationCheckout publishableKey={publishableKey} />
<div className="flex min-h-0 flex-col rounded-2xl border border-white/10 bg-[#050816]/90 p-6 shadow-[0_0_80px_rgba(59,130,246,0.1)] backdrop-blur-xl sm:p-7">
<EmbeddedDonationCheckout publishableKey={publishableKey} />
</div>
</div>
</section>

View File

@@ -4,14 +4,17 @@ import { ALLOWED_DONATION_USD_CENTS, BLW_DISPLAY_NAME, BLW_TICKER } from "@/lib/
import { motion } from "framer-motion";
import { loadStripe } from "@stripe/stripe-js";
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
import { LOGIN_RETURN_HOME_DONATE } from "@/lib/auth-links";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { useEffect, useMemo, useState } from "react";
function InnerCheckout({
onSucceeded,
returnUrl,
}: {
onSucceeded: () => void;
returnUrl: string;
}) {
const stripe = useStripe();
const elements = useElements();
@@ -25,7 +28,7 @@ function InnerCheckout({
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: typeof window !== "undefined" ? `${window.location.origin}/wallet` : undefined,
return_url: returnUrl,
},
redirect: "if_required",
});
@@ -70,7 +73,14 @@ export function DonationCheckout({ publishableKey }: { publishableKey: string })
const [locked, setLocked] = useState<ExchangePreview | null>(null);
const [error, setError] = useState<string | null>(null);
const [loadingIntent, setLoadingIntent] = useState(false);
const [succeeded, setSucceeded] = useState(false);
const [stripeBlockReason, setStripeBlockReason] = useState<string | null>(null);
const [donorName, setDonorName] = useState("");
const [donorEmail, setDonorEmail] = useState("");
const [optionalContactOpen, setOptionalContactOpen] = useState(false);
const [perksExplainerOpen, setPerksExplainerOpen] = useState(false);
const loggedIn = !!session?.user;
const stripePromise = useMemo(() => {
if (!publishableKey || typeof window === "undefined") return null;
@@ -78,6 +88,12 @@ export function DonationCheckout({ publishableKey }: { publishableKey: string })
return loadStripe(publishableKey);
}, [publishableKey]);
const paymentReturnUrl = useMemo(() => {
if (typeof window === "undefined") return "";
const origin = window.location.origin;
return loggedIn ? `${origin}/wallet` : `${origin}/donate/thank-you`;
}, [loggedIn]);
useEffect(() => {
if (!publishableKey || typeof window === "undefined") {
setStripeBlockReason(null);
@@ -85,7 +101,9 @@ export function DonationCheckout({ publishableKey }: { publishableKey: string })
}
if (publishableKey.startsWith("pk_live_") && window.location.protocol !== "https:") {
setStripeBlockReason("Live Stripe publishable keys require HTTPS. Use Stripe test keys for local HTTP demos.");
setStripeBlockReason(
"Secure card processing requires HTTPS. Open this site with https:// or contact the committee if this message appears in error.",
);
return;
}
@@ -115,41 +133,30 @@ export function DonationCheckout({ publishableKey }: { publishableKey: string })
useEffect(() => {
setClientSecret(null);
setLocked(null);
}, [tierCents]);
}, [tierCents, loggedIn]);
useEffect(() => {
if (loggedIn) setOptionalContactOpen(false);
}, [loggedIn]);
if (status === "loading") {
return <p className="text-sm text-slate-400">Checking your session</p>;
}
if (!session?.user) {
return (
<div className="space-y-4 rounded-2xl border border-white/10 bg-black/30 p-5 text-sm text-slate-300">
<p className="text-base text-white">
Sign in to donate. {BLW_TICKER} credits use the mock spot rate locked when you start checkout.
</p>
<div className="flex flex-wrap gap-3">
<Link
href="/login?callbackUrl=/#donate"
className="rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-5 py-2 font-semibold text-white"
>
Sign in
</Link>
<Link href="/register" className="rounded-full border border-white/20 px-5 py-2 font-semibold text-white hover:bg-white/5">
Create account
</Link>
</div>
</div>
);
return <p className="text-sm text-slate-400">Loading checkout</p>;
}
const startIntent = async () => {
setLoadingIntent(true);
setError(null);
try {
const body: Record<string, unknown> = { amountUsdCents: tierCents };
if (!loggedIn) {
if (donorName.trim()) body.donorName = donorName.trim();
if (donorEmail.trim()) body.donorEmail = donorEmail.trim();
}
const res = await fetch("/api/stripe/create-payment-intent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ amountUsdCents: tierCents }),
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) {
@@ -171,18 +178,36 @@ export function DonationCheckout({ publishableKey }: { publishableKey: string })
setLoadingIntent(false);
};
const onSucceeded = async () => {
const onSucceeded = () => {
setSucceeded(true);
setClientSecret(null);
setLocked(null);
await fetch("/api/wallet", { cache: "no-store" });
const dest = loggedIn ? "/wallet?donated=1" : "/donate/thank-you";
setTimeout(() => {
window.location.href = dest;
}, loggedIn ? 2000 : 1200);
};
if (succeeded) {
return (
<div className="space-y-4 rounded-2xl border border-emerald-500/40 bg-emerald-500/10 p-6 text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-emerald-500/20 text-2xl">
</div>
<p className="text-lg font-semibold text-white">Payment received!</p>
<p className="text-sm text-emerald-100/90">
{loggedIn
? `${BLW_TICKER} is headed to your wallet—hang tight while we finish the magic.`
: "Thank you — taking you to a quick celebration page…"}
</p>
</div>
);
}
if (!publishableKey) {
return (
<p className="rounded-2xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-100">
Add <code className="rounded bg-black/30 px-1">NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY</code> and{" "}
<code className="rounded bg-black/30 px-1">STRIPE_SECRET_KEY</code> to{" "}
<code className="rounded bg-black/30 px-1">.env</code> to process cards.
Card checkout isn&apos;t configured on this build yetcheck back soon or ask the team to flip the switch.
</p>
);
}
@@ -200,51 +225,149 @@ export function DonationCheckout({ publishableKey }: { publishableKey: string })
return (
<div className="space-y-6">
{!loggedIn ? (
<div className="rounded-2xl border border-amber-500/40 bg-amber-500/10 px-4 py-4 text-sm text-amber-50">
<p className="font-semibold text-white">Flying in as a guest?</p>
<p className="mt-2 leading-relaxed text-amber-100/90">
Your gift still lights up the public meter. Want {BLW_TICKER}, the wallet, games, and the full ride?{" "}
<Link href={LOGIN_RETURN_HOME_DONATE} className="font-semibold text-white underline">
Sign in
</Link>{" "}
or{" "}
<Link href="/register" className="font-semibold text-white underline">
join
</Link>{" "}
before you pay next time.
</p>
</div>
) : (
<p className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
Signed in as <strong className="text-white">{session?.user?.email ?? "supporter"}</strong> your tier drops fresh{" "}
{BLW_TICKER} into your wallet moments after checkout clears.
</p>
)}
{!loggedIn ? (
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black/25">
<button
type="button"
onClick={() => setOptionalContactOpen((o) => !o)}
aria-expanded={optionalContactOpen}
className="flex w-full cursor-pointer items-center justify-between gap-2 px-4 py-3 text-left text-sm font-medium text-white transition hover:bg-white/[0.06]"
>
<span>Optional name or email for receipt</span>
<span className="shrink-0 rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs tabular-nums text-slate-400">
{optionalContactOpen ? "Collapse" : "Expand"}
</span>
</button>
{optionalContactOpen ? (
<div className="border-t border-white/10 px-4 pb-4 pt-2">
<p className="text-xs leading-relaxed text-slate-500">
Optionalhelps us send a thank-you. Cards stay on the secure form below.
</p>
<label className="mt-3 block text-sm text-slate-300">
Name
<input
value={donorName}
onChange={(e) => setDonorName(e.target.value)}
autoComplete="name"
className="mt-1.5 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2.5 text-base text-white outline-none ring-sky-500/40 focus:ring"
placeholder="Optional"
maxLength={120}
/>
</label>
<label className="mt-3 block text-sm text-slate-300">
Email
<input
type="email"
value={donorEmail}
onChange={(e) => setDonorEmail(e.target.value)}
autoComplete="email"
className="mt-1.5 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2.5 text-base text-white outline-none ring-sky-500/40 focus:ring"
placeholder="Optional"
/>
</label>
</div>
) : null}
</div>
) : null}
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">Choose a tier (USD)</p>
<div className="mt-3 flex flex-wrap gap-2">
{ALLOWED_DONATION_USD_CENTS.map((cents) => (
<button
key={cents}
type="button"
onClick={() => setTierCents(cents)}
className={`rounded-full px-5 py-2 text-sm font-semibold transition ${
tierCents === cents
? "bg-white text-slate-900"
: "bg-white/5 text-slate-200 hover:bg-white/10"
}`}
>
${(cents / 100).toFixed(0)}
</button>
))}
<label htmlFor="donation-tier-select" className="text-xs font-medium uppercase tracking-[0.18em] text-slate-400">
Donation amount
</label>
<div className="mt-2 flex flex-col gap-3 sm:flex-row sm:items-center">
<select
id="donation-tier-select"
value={tierCents}
onChange={(e) => setTierCents(Number(e.target.value))}
className="w-full shrink-0 rounded-xl border border-white/15 bg-black/40 px-3 py-2.5 text-base font-medium text-white outline-none ring-sky-500/30 focus:border-sky-500/40 focus:ring sm:max-w-[200px]"
>
{ALLOWED_DONATION_USD_CENTS.map((cents) => (
<option key={cents} value={cents}>
${(cents / 100).toFixed(0)} USD
</option>
))}
</select>
<div className="flex flex-1 flex-wrap gap-2">
{ALLOWED_DONATION_USD_CENTS.map((cents) => (
<button
key={cents}
type="button"
onClick={() => setTierCents(cents)}
className={`rounded-full px-4 py-2 text-sm font-semibold transition ${
tierCents === cents ? "bg-white text-slate-900" : "bg-white/5 text-slate-200 hover:bg-white/10"
}`}
>
${(cents / 100).toFixed(0)}
</button>
))}
</div>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-slate-300">
<p className="font-medium text-white">How {BLW_TICKER} ({BLW_DISPLAY_NAME}) works</p>
<p className="mt-2 leading-relaxed text-slate-400">
<strong className="text-slate-200">{BLW_DISPLAY_NAME}</strong> ({BLW_TICKER}) is a playful mock index not real
crypto. Credits mint as whole {BLW_TICKER} units:{" "}
<code className="rounded bg-white/10 px-1">USD ÷ BLW/USD spot</code>. When the index is <em>lower</em>, each dollar
buys <em>more</em> {BLW_TICKER}; when it&apos;s higher, you receive fewer {BLW_TICKER} for the same donation. The exact
spot is <strong className="text-white">frozen</strong> when you tap &quot;Continue to secure checkout&quot;.
</p>
{spot && !locked ? (
<p className="mt-3 text-sky-200/90">
Live index (not locked yet): ${spot.blwUsd.toFixed(4)} / {BLW_TICKER} ~{spotBlwPreview ?? "—"} {BLW_TICKER} for $
{(tierCents / 100).toFixed(0)}
</p>
) : null}
{locked ? (
<div className="mt-3 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-emerald-100">
<p className="text-xs uppercase tracking-wide text-emerald-300/90">Locked for this checkout</p>
<p className="mt-1 font-mono text-base">
${locked.blwUsd.toFixed(4)} / {BLW_TICKER} · {locked.blwPerUsd.toFixed(2)} {BLW_TICKER} per $1 ·{" "}
<strong>
{locked.creditsPreview} {BLW_TICKER}
</strong>{" "}
if payment succeeds
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black/25">
<button
type="button"
onClick={() => setPerksExplainerOpen((o) => !o)}
aria-expanded={perksExplainerOpen}
className="flex w-full cursor-pointer items-center justify-between gap-2 px-4 py-3 text-left text-sm font-medium text-white transition hover:bg-white/[0.06]"
>
<span className="min-w-0 pr-2">{BLW_TICKER} perks in plain English</span>
<span className="shrink-0 rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs text-slate-400">
{perksExplainerOpen ? "Collapse" : "Expand"}
</span>
</button>
{perksExplainerOpen ? (
<div className="space-y-3 border-t border-white/10 px-4 pb-4 pt-3 text-sm leading-relaxed text-slate-400">
<p>
<strong className="text-slate-200">{BLW_DISPLAY_NAME}</strong> ({BLW_TICKER}) is the in-world juice for perksthink
arcade tokens for democracy, not something you send to a wallet app. Sign in when you pay to bank it; guests still push
the campaign meter.
</p>
{!loggedIn ? (
<p className="font-medium text-amber-200/90">
Guest gifts = <strong>0 {BLW_TICKER}</strong> in your pocket, 100% heart on the board. Want the loot? Join and check
out signed in.
</p>
) : null}
{spot && !locked && loggedIn ? (
<p className="rounded-lg border border-sky-500/20 bg-sky-500/10 px-3 py-2 font-mono text-xs text-sky-100/95 sm:text-sm">
Sneak peek (bounces until you lock): ~{spotBlwPreview ?? "—"} {BLW_TICKER} at ${(tierCents / 100).toFixed(0)}
</p>
) : null}
{locked ? (
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2.5 text-emerald-100">
<p className="text-xs font-medium uppercase tracking-wide text-emerald-300/90">Locked for this run</p>
<p className="mt-1.5 font-mono text-sm sm:text-base">
You&apos;re set for{" "}
<strong>
{locked.creditsPreview} {BLW_TICKER}
</strong>{" "}
on this amount{loggedIn ? "" : " (signed-in supporters only)"}.
</p>
</div>
) : null}
</div>
) : null}
</div>
@@ -257,9 +380,9 @@ export function DonationCheckout({ publishableKey }: { publishableKey: string })
onClick={startIntent}
className="w-full rounded-2xl bg-white/10 py-3 font-semibold text-white hover:bg-white/15 disabled:opacity-40"
>
{loadingIntent ? "Connecting to Stripe…" : "Continue to secure checkout"}
{loadingIntent ? "Opening secure checkout…" : "Continue to secure checkout"}
</motion.button>
) : stripePromise ? (
) : stripePromise && paymentReturnUrl ? (
<Elements
stripe={stripePromise}
options={{
@@ -267,13 +390,13 @@ export function DonationCheckout({ publishableKey }: { publishableKey: string })
appearance: { theme: "night", variables: { borderRadius: "12px" } },
}}
>
<InnerCheckout onSucceeded={onSucceeded} />
<InnerCheckout onSucceeded={onSucceeded} returnUrl={paymentReturnUrl} />
</Elements>
) : null}
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
<p className="text-xs leading-relaxed text-slate-500">
Donations may be subject to federal and state political fundraising rules. {BLW_DISPLAY_NAME} is a demo layer
configure real disclosures with <code className="rounded bg-black/30 px-1">DISCLAIMER_TEXT</code> before production use.
Political contributions follow applicable rules. {BLW_DISPLAY_NAME} is a supporter perk for signed-in accounts on this
sitenot cash, not transferable off-platform.
</p>
</div>
);

View File

@@ -0,0 +1,111 @@
"use client";
import { useEffect, useState } from "react";
interface Entry {
rank: number;
displayName: string;
totalUsdCents: number;
donationCount: number;
}
const RANK_STYLES = [
"from-yellow-400 to-amber-300 text-black", // 🥇
"from-slate-300 to-slate-200 text-black", // 🥈
"from-amber-700 to-amber-500 text-white", // 🥉
];
const RANK_ICONS = ["🥇", "🥈", "🥉"];
function Bar({ pct, rank }: { pct: number; rank: number }) {
const colors = ["bg-gradient-to-r from-yellow-400 to-amber-300", "bg-gradient-to-r from-slate-400 to-slate-300", "bg-gradient-to-r from-amber-700 to-amber-500"];
const color = rank <= 3 ? colors[rank - 1] : "bg-gradient-to-r from-sky-600 to-indigo-600";
return (
<div className="h-1 rounded-full bg-white/5 overflow-hidden">
<div className={`h-full rounded-full ${color} transition-[width] duration-700`} style={{ width: `${pct}%` }} />
</div>
);
}
export function DonorLeaderboard() {
const [entries, setEntries] = useState<Entry[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/leaderboard?limit=25")
.then(r => r.json())
.then(d => { setEntries(d.leaderboard ?? []); setLoading(false); });
}, []);
const maxCents = entries[0]?.totalUsdCents ?? 1;
const fmt = (cents: number) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(cents / 100);
if (loading) {
return (
<div className="space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-14 animate-pulse rounded-xl bg-white/5" />
))}
</div>
);
}
if (entries.length === 0) {
return (
<div className="rounded-xl border border-white/10 bg-white/5 p-8 text-center">
<p className="text-slate-400">Be the first on the board make a donation!</p>
</div>
);
}
return (
<div className="space-y-2">
{/* Top 3 podium */}
<div className="grid grid-cols-3 gap-3 mb-6">
{[1, 0, 2].map(idx => {
const e = entries[idx];
if (!e) return <div key={idx} />;
const pos = e.rank;
return (
<div
key={idx}
className={`relative rounded-2xl border p-4 text-center ${
pos === 1
? "border-yellow-400/40 bg-yellow-900/20 row-start-1"
: pos === 2
? "border-slate-400/30 bg-slate-800/40"
: "border-amber-700/30 bg-amber-900/20"
} ${idx === 0 ? "mt-4" : ""}`}
>
<div className="text-3xl mb-1">{RANK_ICONS[pos - 1]}</div>
<p className="font-bold text-white text-sm truncate">{e.displayName}</p>
<p className={`text-lg font-black mt-1 bg-gradient-to-r ${RANK_STYLES[pos - 1]} bg-clip-text text-transparent`}>
{fmt(e.totalUsdCents)}
</p>
<p className="text-xs text-slate-500 mt-0.5">{e.donationCount} gift{e.donationCount !== 1 ? "s" : ""}</p>
</div>
);
})}
</div>
{/* Rest of leaderboard */}
{entries.slice(3).map(e => (
<div key={e.rank} className="flex items-center gap-3 rounded-xl border border-white/5 bg-white/3 px-4 py-2.5 group hover:bg-white/5 transition-colors">
<span className="w-6 text-center text-xs font-bold text-slate-500 tabular-nums">#{e.rank}</span>
<div className="flex-1 min-w-0">
<p className="text-sm text-white font-medium truncate">{e.displayName}</p>
<Bar pct={Math.round((e.totalUsdCents / maxCents) * 100)} rank={e.rank} />
</div>
<div className="text-right shrink-0">
<p className="text-sm font-bold text-sky-300 tabular-nums">{fmt(e.totalUsdCents)}</p>
<p className="text-xs text-slate-600">{e.donationCount} gift{e.donationCount !== 1 ? "s" : ""}</p>
</div>
</div>
))}
<p className="text-center text-xs text-slate-600 pt-2">
Showing top {entries.length} donors · Updated live
</p>
</div>
);
}

View File

@@ -0,0 +1,258 @@
"use client";
import { loadStripe, type Stripe } from "@stripe/stripe-js";
import { EmbeddedCheckout, EmbeddedCheckoutProvider } from "@stripe/react-stripe-js";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ALLOWED_DONATION_USD_CENTS, BLW_DISPLAY_NAME, BLW_TICKER } from "@/lib/exchange";
import { LOGIN_RETURN_HOME_DONATE } from "@/lib/auth-links";
type CreateSessionResponse = {
clientSecret: string;
sessionId: string;
publishableKey: string;
guest: boolean;
exchange?: {
blwUsd: number;
blwPerUsd: number;
creditsPreview: number;
tierUsdCents: number;
};
};
export function EmbeddedDonationCheckout({ publishableKey }: { publishableKey: string }) {
const { data: session, status } = useSession();
const [tierCents, setTierCents] = useState<number>(1000);
const [donorName, setDonorName] = useState("");
const [donorEmail, setDonorEmail] = useState("");
const [optionalContactOpen, setOptionalContactOpen] = useState(false);
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [loadingSession, setLoadingSession] = useState(false);
const [error, setError] = useState<string | null>(null);
const [stripeBlockReason, setStripeBlockReason] = useState<string | null>(null);
const loggedIn = !!session?.user;
const sessionResolved = status !== "loading";
const stripePromise = useMemo<Promise<Stripe | null> | null>(() => {
if (!publishableKey || typeof window === "undefined") return null;
if (publishableKey.startsWith("pk_live_") && window.location.protocol !== "https:") return null;
return loadStripe(publishableKey);
}, [publishableKey]);
useEffect(() => {
if (!publishableKey || typeof window === "undefined") {
setStripeBlockReason(null);
return;
}
if (publishableKey.startsWith("pk_live_") && window.location.protocol !== "https:") {
setStripeBlockReason(
"Secure card processing requires HTTPS. Open this site with https:// or contact the committee if this message appears in error.",
);
return;
}
setStripeBlockReason(null);
}, [publishableKey]);
// Reset session when tier or auth state changes.
useEffect(() => {
setClientSecret(null);
setError(null);
}, [tierCents, loggedIn]);
const fetchClientSecret = useCallback(async (): Promise<string> => {
const body: Record<string, unknown> = { amountUsdCents: tierCents };
if (!loggedIn) {
if (donorName.trim()) body.donorName = donorName.trim();
if (donorEmail.trim()) body.donorEmail = donorEmail.trim();
}
const res = await fetch("/api/stripe/create-checkout-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = (await res.json()) as Partial<CreateSessionResponse> & { error?: string };
if (!res.ok || !data.clientSecret) {
throw new Error(data.error ?? "Could not start checkout");
}
return data.clientSecret;
}, [tierCents, loggedIn, donorName, donorEmail]);
const openCheckout = async () => {
setLoadingSession(true);
setError(null);
try {
const cs = await fetchClientSecret();
setClientSecret(cs);
} catch (e) {
setError(e instanceof Error ? e.message : "Network error");
} finally {
setLoadingSession(false);
}
};
if (!publishableKey) {
return (
<p className="rounded-2xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-100">
Card checkout isn&apos;t configured on this build yetcheck back soon or ask the team to flip the switch.
</p>
);
}
if (stripeBlockReason) {
return (
<p className="rounded-2xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-100">
{stripeBlockReason}
</p>
);
}
return (
<div className="space-y-6">
{sessionResolved && loggedIn ? (
<p className="rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
Signed in as <strong className="text-white">{session?.user?.email ?? "supporter"}</strong> your tier drops fresh{" "}
{BLW_TICKER} into your wallet moments after checkout clears.
</p>
) : (
<div className="rounded-2xl border border-amber-500/40 bg-amber-500/10 px-4 py-4 text-sm text-amber-50">
<p className="font-semibold text-white">
{sessionResolved ? "Donating as a guest" : "Choose your path"}
</p>
<p className="mt-2 leading-relaxed text-amber-100/90">
Your gift still lights up the public meter.{" "}
<Link href={LOGIN_RETURN_HOME_DONATE} className="font-semibold text-white underline">
Sign in
</Link>{" "}
or{" "}
<Link
href={`/register?callbackUrl=${encodeURIComponent("/donate")}`}
className="font-semibold text-white underline"
>
join
</Link>{" "}
first to earn {BLW_DISPLAY_NAME} ({BLW_TICKER}) or pick a tier and donate as a guest below.
</p>
</div>
)}
{!clientSecret ? (
<>
{!loggedIn ? (
<div className="overflow-hidden rounded-2xl border border-white/10 bg-black/25">
<button
type="button"
onClick={() => setOptionalContactOpen((o) => !o)}
aria-expanded={optionalContactOpen}
className="flex w-full cursor-pointer items-center justify-between gap-2 px-4 py-3 text-left text-sm font-medium text-white transition hover:bg-white/[0.06]"
>
<span>Optional name or email for receipt</span>
<span className="shrink-0 rounded-full border border-white/10 bg-white/5 px-2 py-0.5 text-xs tabular-nums text-slate-400">
{optionalContactOpen ? "Collapse" : "Expand"}
</span>
</button>
{optionalContactOpen ? (
<div className="border-t border-white/10 px-4 pb-4 pt-2">
<p className="text-xs leading-relaxed text-slate-500">
Optional Stripe also collects an email on the checkout form for the receipt.
</p>
<label className="mt-3 block text-sm text-slate-300">
Name
<input
value={donorName}
onChange={(e) => setDonorName(e.target.value)}
autoComplete="name"
className="mt-1.5 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2.5 text-base text-white outline-none ring-sky-500/40 focus:ring"
placeholder="Optional"
maxLength={120}
/>
</label>
<label className="mt-3 block text-sm text-slate-300">
Email
<input
type="email"
value={donorEmail}
onChange={(e) => setDonorEmail(e.target.value)}
autoComplete="email"
className="mt-1.5 w-full rounded-xl border border-white/10 bg-black/30 px-3 py-2.5 text-base text-white outline-none ring-sky-500/40 focus:ring"
placeholder="Optional"
/>
</label>
</div>
) : null}
</div>
) : null}
<div>
<label htmlFor="donation-tier-select" className="text-xs font-medium uppercase tracking-[0.18em] text-slate-400">
Donation amount
</label>
<div className="mt-2 flex flex-col gap-3 sm:flex-row sm:items-center">
<select
id="donation-tier-select"
value={tierCents}
onChange={(e) => setTierCents(Number(e.target.value))}
className="w-full shrink-0 rounded-xl border border-white/15 bg-black/40 px-3 py-2.5 text-base font-medium text-white outline-none ring-sky-500/30 focus:border-sky-500/40 focus:ring sm:max-w-[200px]"
>
{ALLOWED_DONATION_USD_CENTS.map((cents) => (
<option key={cents} value={cents}>
${(cents / 100).toFixed(0)} USD
</option>
))}
</select>
<div className="flex flex-1 flex-wrap gap-2">
{ALLOWED_DONATION_USD_CENTS.map((cents) => (
<button
key={cents}
type="button"
onClick={() => setTierCents(cents)}
className={`rounded-full px-4 py-2 text-sm font-semibold transition ${
tierCents === cents ? "bg-white text-slate-900" : "bg-white/5 text-slate-200 hover:bg-white/10"
}`}
>
${(cents / 100).toFixed(0)}
</button>
))}
</div>
</div>
</div>
<button
type="button"
disabled={loadingSession}
onClick={openCheckout}
className="w-full rounded-2xl bg-gradient-to-r from-sky-500 via-indigo-500 to-fuchsia-500 py-3 text-base font-semibold text-white shadow-xl shadow-indigo-500/30 disabled:opacity-50"
>
{loadingSession ? "Opening secure checkout…" : `Donate $${(tierCents / 100).toFixed(0)}`}
</button>
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
</>
) : (
<div className="space-y-3">
<div className="overflow-hidden rounded-2xl border border-white/10 bg-white">
<EmbeddedCheckoutProvider stripe={stripePromise} options={{ clientSecret }}>
<EmbeddedCheckout />
</EmbeddedCheckoutProvider>
</div>
<button
type="button"
onClick={() => {
setClientSecret(null);
setError(null);
}}
className="text-xs text-slate-400 underline-offset-2 hover:text-slate-200 hover:underline"
>
Change amount
</button>
</div>
)}
<p className="text-xs leading-relaxed text-slate-500">
Political contributions follow applicable rules. {BLW_DISPLAY_NAME} is a supporter perk for signed-in accounts on this
site not cash, not transferable off-platform.
</p>
</div>
);
}

View File

@@ -0,0 +1,127 @@
"use client";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import { appTitle } from "@/lib/public-env";
import { useMemo, useState } from "react";
function buildFaqs() {
const t = creditTicker();
const n = creditDisplayName();
const title = appTitle();
return [
{
q: `What is ${title}?`,
a: `${title} is this committees digital home for small-dollar fundraising: clear tiers, a live public meter, and supporter tools that keep people engaged after they give.`,
},
{
q: `What is ${n} (${t})?`,
a: `${n} (${t}) is the on-site recognition you earn when you donate while signed in. Spend it on perks, raffles, the straw poll, optional games, mission pledges, and democratic initiatives. The amount you receive is set at checkout for that gift.`,
},
{
q: `Is ${t} cryptocurrency?`,
a: `No. ${t} are supporter credits tied to your donation — they live in your wallet on this site and power games, pledges, and perks. They are not a tradable blockchain token or cash balance.`,
},
{
q: "Where does my donation go?",
a: "Your card payment supports the committees authorized program — the same dollars that appear on our live totals and disclosure pages.",
},
{
q: "Why do the homepage meter and /raised match?",
a: "They read the same completed contributions. The homepage refreshes on a short timer; the Raised page is the full snapshot with goal context.",
},
{
q: "What are mission pledges?",
a: `On /missions, signed-in supporters steer ${t} toward committee priorities like field organizing, voter protection, and digital rapid response. Pledges show where energy should go.`,
},
{
q: "What are democratic initiatives?",
a: `On /initiatives, each account can publish one grassroots idea. Everyone else pledges ${t} to lift the proposals they believe in — a live signal of what the community wants next.`,
},
{
q: "What is the presidential straw poll?",
a: `At /vote/next-president you can cast weighted supporter ballots using ${t}. Its for engagement and conversation — not an official election.`,
},
{
q: "Can I donate without creating an account?",
a: `Yes. Guest checkout still counts on the public meter. To earn ${n}, use the wallet, missions, initiatives, and games, sign in (or enroll) before you pay.`,
},
{
q: "Can I get a refund?",
a: "Refunds follow the committees published policy and applicable law. Reach out through the official channels listed in committee filings.",
},
{
q: "How do I volunteer?",
a: "Use the contact and volunteer routes published in the committees Statement of Organization and other authorized disclosures.",
},
];
}
function Chevron({ open }: { open: boolean }) {
return (
<span
className={`ml-3 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/[0.04] text-slate-400 transition-transform duration-300 ${open ? "rotate-180" : ""}`}
aria-hidden
>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" className="text-current">
<path d="M3.5 5.25L7 8.75l3.5-3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
);
}
export function FaqSection() {
const faqs = useMemo(() => buildFaqs(), []);
const [open, setOpen] = useState<number | null>(0);
return (
<section id="faq" className="scroll-mt-28 border-b border-white/10 bg-[#030712] py-12 sm:py-16">
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="mx-auto max-w-3xl text-center">
<p className="text-xs font-medium uppercase tracking-[0.28em] text-sky-200/80">Questions</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-white sm:text-4xl">Frequently asked</h2>
<p className="mx-auto mt-4 max-w-2xl text-sm leading-relaxed text-slate-400 sm:text-base">
Straight answers in plain language. If you need committee-specific legal wording, your treasurer and counsel can tailor
the final text this section is here so supporters never feel lost.
</p>
</div>
<div className="mx-auto mt-10 max-w-3xl space-y-2" role="list">
{faqs.map((item, i) => {
const isOpen = open === i;
const panelId = `faq-panel-${i}`;
const buttonId = `faq-button-${i}`;
return (
<div
key={item.q}
className="overflow-hidden rounded-2xl border border-white/10 bg-white/[0.03]"
role="listitem"
>
<button
id={buttonId}
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => setOpen(isOpen ? null : i)}
className="flex w-full items-start justify-between gap-3 px-4 py-4 text-left text-[15px] font-medium leading-snug text-white transition hover:bg-white/[0.04] sm:px-5 sm:text-base"
>
<span className="min-w-0 pt-0.5">{item.q}</span>
<Chevron open={isOpen} />
</button>
{isOpen ? (
<div
id={panelId}
role="region"
aria-labelledby={buttonId}
className="border-t border-white/10 px-4 pb-4 pt-2 text-sm leading-relaxed text-slate-400 sm:px-5"
>
{item.a}
</div>
) : null}
</div>
);
})}
</div>
</div>
</section>
);
}

View File

@@ -1,113 +1,189 @@
"use client";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import { motion } from "framer-motion";
import Link from "next/link";
import { HeroLiveStats } from "./HeroLiveStats";
import { MockExchangeTicker } from "./MockExchangeTicker";
import { ParticleField } from "./ParticleField";
import { SmokeWisps } from "./SmokeWisps";
const floatA = {
animate: { y: [0, -14, 0], rotate: [0, 3, 0] },
transition: { duration: 7, repeat: Infinity, ease: "easeInOut" as const },
};
export function Hero() {
const ticker = creditTicker();
const creditName = creditDisplayName();
const checklist = [
{ color: "bg-sky-400", text: `When you give while signed in, ${creditName} (${ticker}) shows up in your wallet after your gift clears.` },
{ color: "bg-emerald-300", text: "One wallet powers missions, the straw poll, perks, initiatives, and optional games — no separate logins." },
{ color: "bg-amber-300", text: "The live meter and leaderboard keep the whole community honest about whats been raised together." },
];
return (
<section className="relative overflow-hidden border-b border-white/10">
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(56,189,248,0.22),_transparent_55%),radial-gradient(ellipse_at_bottom,_rgba(168,85,247,0.18),_transparent_50%)]" />
{/* Multi-layer background radials */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_75%_55%_at_50%_-5%,rgba(56,189,248,0.26),transparent_55%),radial-gradient(ellipse_65%_50%_at_50%_95%,rgba(168,85,247,0.18),transparent_55%),radial-gradient(ellipse_40%_35%_at_50%_50%,rgba(239,68,68,0.06),transparent_60%)]" />
{/* Smoke wisps layer */}
<SmokeWisps />
{/* Star particles */}
<ParticleField />
<div className="relative mx-auto flex max-w-6xl flex-col gap-10 px-4 pb-24 pt-20 sm:px-6 lg:flex-row lg:items-end lg:justify-between">
<div className="max-w-3xl space-y-8">
<motion.p
initial={{ opacity: 0, y: 12 }}
{/* Subtle grid overlay */}
<div
aria-hidden
className="absolute inset-0 opacity-[0.025]"
style={{
backgroundImage:
"linear-gradient(rgba(255,255,255,0.4) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.4) 1px,transparent 1px)",
backgroundSize: "72px 72px",
}}
/>
<div className="relative mx-auto grid max-w-6xl grid-cols-1 items-center gap-10 px-4 pb-14 pt-12 sm:px-6 lg:grid-cols-2 lg:gap-12">
<div className="mx-auto flex w-full max-w-xl flex-col items-center space-y-5 text-center lg:mx-0 lg:max-w-none">
{/* Eyebrow badge with pulse */}
<motion.div
initial={false}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-1 text-xs uppercase tracking-[0.35em] text-sky-200/90"
className="flex w-full justify-center"
>
Democracy · Dignity · Dopamine
</motion.p>
<motion.h1
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.08, duration: 0.65 }}
className="text-balance text-4xl font-semibold leading-tight text-white sm:text-5xl lg:text-6xl"
>
Make donating feel like joining the winning room:{" "}
<span className="bg-gradient-to-r from-sky-300 via-indigo-200 to-fuchsia-300 bg-clip-text text-transparent">
instant impact, credits, perks, and action.
<span className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-1.5 text-xs uppercase tracking-[0.35em] text-sky-200/90 shadow-[0_0_24px_rgba(56,189,248,0.18)]">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-sky-400" />
</span>
Youre early welcome in
</span>
</motion.h1>
<motion.p
initial={{ opacity: 0, y: 16 }}
</motion.div>
{/* Main heading */}
<motion.h1
initial={false}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15, duration: 0.65 }}
className="text-lg text-slate-300/95"
transition={{ delay: 0.08, duration: 0.7 }}
className="text-balance text-4xl font-bold leading-[1.12] tracking-tight text-white sm:text-5xl lg:text-6xl"
>
This is a movement interface with a Stripe-backed donation core, a mock Blue Wave (BLW) supporter economy,
a wallet, rewards, raffles, impact planning, and enough momentum cues to make the next click
feel obvious.
Turn a donation into momentum.{" "}
<span className="relative inline-block">
<span className="bg-gradient-to-r from-sky-300 via-indigo-200 to-fuchsia-300 bg-clip-text text-sky-200 supports-[(-webkit-background-clip:text)]:text-transparent">
Fund the field.
</span>
{/* Shimmer underline */}
<motion.span
className="absolute -bottom-1 left-0 h-px w-full rounded-full bg-gradient-to-r from-sky-400 via-indigo-300 to-fuchsia-400"
initial={false}
animate={{ scaleX: 1, opacity: 1 }}
transition={{ delay: 0.5, duration: 0.8 }}
style={{ transformOrigin: "left" }}
/>
</span>{" "}
Make every supporter move.
</motion.h1>
<motion.p
initial={false}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.18, duration: 0.65 }}
className="text-lg leading-relaxed text-slate-300/90"
>
Small-dollar giving that feels alive: chip in, earn {creditName} ({ticker}) when you&apos;re signed in, then steer credits
toward missions, initiatives, polls, and perks. Scroll down for a simple tour or jump straight in whenever you&apos;re ready.
</motion.p>
<motion.div
initial={{ opacity: 0, y: 16 }}
<motion.p
initial={false}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.22, duration: 0.65 }}
className="flex flex-wrap gap-3"
className="text-base leading-relaxed text-slate-500"
>
First time? Start with{" "}
<Link href="/#start" className="text-sky-300 underline-offset-2 hover:underline">
the four-step welcome
</Link>{" "}
it takes under a minute to read.
</motion.p>
{/* CTA buttons */}
<motion.div
initial={false}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.26, duration: 0.65 }}
className="flex flex-wrap justify-center gap-3"
>
<Link
href="/register"
className="rounded-full bg-gradient-to-r from-sky-500 via-indigo-500 to-fuchsia-500 px-6 py-3 text-sm font-semibold text-white shadow-xl shadow-indigo-500/30"
className="group relative overflow-hidden rounded-full bg-gradient-to-r from-sky-500 via-indigo-500 to-fuchsia-500 px-7 py-3 text-sm font-semibold text-white shadow-xl shadow-indigo-500/35 transition hover:shadow-indigo-500/50"
>
Create supporter login
<span className="relative z-10">Join free</span>
<span className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/20 to-transparent transition-transform duration-500 group-hover:translate-x-full" />
</Link>
<Link
href="#donate"
className="rounded-full border border-white/20 px-6 py-3 text-sm font-semibold text-white hover:bg-white/5"
href="/donate"
className="rounded-full border border-white/20 bg-white/5 px-7 py-3 text-sm font-semibold text-white backdrop-blur-sm transition hover:border-white/35 hover:bg-white/10"
>
Fuel the field program
Donate
</Link>
<Link
href="#impact"
className="rounded-full border border-sky-300/30 bg-sky-300/10 px-6 py-3 text-sm font-semibold text-sky-100 hover:bg-sky-300/15"
href="/#start"
className="rounded-full border border-sky-300/30 bg-sky-300/8 px-7 py-3 text-sm font-semibold text-sky-100 backdrop-blur-sm transition hover:bg-sky-300/15"
>
Plan my impact
How it works
</Link>
</motion.div>
<div className="grid gap-3 text-sm text-slate-300 sm:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<p className="text-2xl font-semibold text-white">4-step</p>
<p className="mt-1 text-slate-400">donate-to-action loop</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<p className="text-2xl font-semibold text-white">BLW</p>
<p className="mt-1 text-slate-400">Blue Wave mock credits</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<p className="text-2xl font-semibold text-white">Local</p>
<p className="mt-1 text-slate-400">runs on port 8008</p>
</div>
</div>
<HeroLiveStats />
</div>
{/* Right panel — floating card */}
<motion.div
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.25, duration: 0.6 }}
className="w-full max-w-md rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_0_120px_rgba(56,189,248,0.15)] backdrop-blur-xl lg:mb-2"
{...floatA}
initial={false}
animate={{ opacity: 1, scale: 1, ...floatA.animate }}
transition={{ opacity: { delay: 0.3, duration: 0.6 }, scale: { delay: 0.3, duration: 0.6 }, ...floatA.transition }}
className="relative mx-auto w-full max-w-md rounded-3xl border border-white/12 bg-white/[0.06] p-5 text-center shadow-[0_0_140px_rgba(56,189,248,0.18),0_0_60px_rgba(168,85,247,0.12)] backdrop-blur-xl sm:p-6"
>
<p className="text-xs uppercase tracking-[0.28em] text-slate-400">Live movement pulse</p>
<p className="mt-4 text-3xl font-semibold text-white">A supporter economy that feels alive</p>
<div className="mt-5">
{/* Inner glow ring */}
<div className="pointer-events-none absolute inset-0 rounded-3xl border border-sky-400/10" />
<p className="text-xs uppercase tracking-[0.28em] text-slate-400">Why people stick around</p>
<p className="mt-3 text-2xl font-bold text-white leading-snug">
A home for supporters who want{" "}
<span className="bg-gradient-to-r from-sky-300 to-indigo-300 bg-clip-text text-transparent">
more than a receipt
</span>
</p>
<div className="mt-4">
<MockExchangeTicker />
</div>
<ul className="mt-5 space-y-3 text-sm text-slate-300">
<li className="flex gap-2">
<span className="mt-1 h-2 w-2 rounded-full bg-sky-400" />
Microvolunteer asks routed locallynot dumped into a national spam cannon.
</li>
<li className="flex gap-2">
<span className="mt-1 h-2 w-2 rounded-full bg-indigo-400" />
Donations settle through Stripe; BLW unlocks perks without touching card data twice.
</li>
<li className="flex gap-2">
<span className="mt-1 h-2 w-2 rounded-full bg-fuchsia-400" />
Built to extend into raffles, collectibles, and digital membership tiers without rewriting core flows.
</li>
<ul className="mx-auto mt-5 w-full max-w-sm space-y-3 text-sm">
{checklist.map(({ color, text }) => (
<motion.li
key={text}
whileHover={{ scale: 1.01 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
className="flex gap-2.5 text-left text-slate-300"
>
<span className={`mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full ${color}`} />
<span className="text-pretty">{text}</span>
</motion.li>
))}
</ul>
{/* Decorative corner accent */}
<div className="pointer-events-none absolute right-4 top-4 h-16 w-16 rounded-full bg-gradient-to-br from-sky-400/15 to-transparent blur-xl" />
</motion.div>
</div>
{/* Bottom fade */}
<div className="absolute bottom-0 left-0 right-0 h-14 bg-gradient-to-t from-[#030712] to-transparent" />
</section>
);
}

View File

@@ -0,0 +1,247 @@
"use client";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import { motion } from "framer-motion";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { type ReactNode, useEffect, useState } from "react";
type PublicStats = {
raisedUsd: number;
donationCount: number;
uniqueDonors: number;
};
function LiveDot({ variant }: { variant: "emerald" | "sky" | "violet" }) {
const ping =
variant === "emerald"
? "bg-emerald-400/70"
: variant === "sky"
? "bg-sky-400/70"
: "bg-fuchsia-400/70";
const solid = variant === "emerald" ? "bg-emerald-400" : variant === "sky" ? "bg-sky-400" : "bg-fuchsia-400";
return (
<span className="relative mt-1 flex h-2 w-2 shrink-0">
<span className={`absolute inline-flex h-full w-full animate-ping rounded-full opacity-75 ${ping}`} />
<span className={`relative inline-flex h-2 w-2 rounded-full ${solid}`} />
</span>
);
}
function StatShell({
accent,
dotVariant,
label,
children,
foot,
}: {
accent: "sky" | "indigo" | "fuchsia";
dotVariant: "emerald" | "sky" | "violet";
label: string;
children: ReactNode;
foot: ReactNode;
}) {
const bar = {
sky: "bg-sky-400",
indigo: "bg-indigo-400",
fuchsia: "bg-fuchsia-400",
}[accent];
const ring =
accent === "sky"
? "shadow-[0_0_20px_rgba(56,189,248,0.1)] hover:shadow-[0_0_28px_rgba(56,189,248,0.16)]"
: accent === "indigo"
? "shadow-[0_0_20px_rgba(129,140,248,0.1)] hover:shadow-[0_0_28px_rgba(129,140,248,0.16)]"
: "shadow-[0_0_20px_rgba(217,70,239,0.08)] hover:shadow-[0_0_28px_rgba(217,70,239,0.14)]";
return (
<motion.div
whileHover={{ scale: 1.01, borderColor: "rgba(148,163,184,0.25)" }}
transition={{ type: "spring", stiffness: 380, damping: 26 }}
className={`flex min-h-[148px] flex-col rounded-2xl border border-white/10 bg-white/[0.04] p-4 backdrop-blur-sm transition-shadow duration-300 ${ring}`}
>
<div className="flex items-start justify-between gap-2">
<p className="text-[15px] font-semibold leading-tight tracking-tight text-white sm:text-base">{label}</p>
<LiveDot variant={dotVariant} />
</div>
<div className="mt-3 min-h-[3.25rem] flex-1 text-sm leading-snug text-white sm:text-[15px]">{children}</div>
<div className="mt-3 border-t border-white/5 pt-3 text-xs leading-relaxed text-slate-500">{foot}</div>
<div className={`mt-auto h-0.5 w-9 rounded-full ${bar} opacity-80`} />
</motion.div>
);
}
export function HeroLiveStats() {
const { data: session } = useSession();
const ticker = creditTicker();
const creditName = creditDisplayName();
const [stats, setStats] = useState<PublicStats | null>(null);
const [rate, setRate] = useState<{ blwUsd: number; blwPerUsd: number } | null>(null);
const [wallet, setWallet] = useState<{ balance: number; infinite: boolean } | null>(null);
useEffect(() => {
let alive = true;
const load = async () => {
try {
const [sRes, rRes] = await Promise.all([
fetch("/api/public/stats", { cache: "no-store" }),
fetch("/api/exchange/rate", { cache: "no-store" }),
]);
if (sRes.ok && alive) {
const j = (await sRes.json()) as Record<string, unknown>;
setStats({
raisedUsd: typeof j.raisedUsd === "number" ? j.raisedUsd : Number(j.raisedUsd) || 0,
donationCount: typeof j.donationCount === "number" ? j.donationCount : Number(j.donationCount) || 0,
uniqueDonors: typeof j.uniqueDonors === "number" ? j.uniqueDonors : Number(j.uniqueDonors) || 0,
});
}
if (rRes.ok && alive) {
const j = (await rRes.json()) as Record<string, unknown>;
const blwUsd = typeof j.blwUsd === "number" ? j.blwUsd : Number(j.blwUsd);
const blwPerUsd = typeof j.blwPerUsd === "number" ? j.blwPerUsd : Number(j.blwPerUsd);
if (Number.isFinite(blwUsd) && Number.isFinite(blwPerUsd)) {
setRate({ blwUsd, blwPerUsd });
}
}
} catch {
/* ignore */
}
};
void load();
const id = globalThis.setInterval(() => void load(), 25_000);
return () => {
alive = false;
globalThis.clearInterval(id);
};
}, []);
useEffect(() => {
if (!session?.user?.id) {
setWallet(null);
return;
}
let alive = true;
const loadWallet = async () => {
try {
const res = await fetch("/api/wallet", { cache: "no-store" });
if (!res.ok || !alive) return;
const j = (await res.json()) as Record<string, unknown>;
setWallet({
balance: typeof j.balanceCredits === "number" ? j.balanceCredits : Number(j.balanceCredits) || 0,
infinite: !!j.infiniteCredits,
});
} catch {
/* ignore */
}
};
void loadWallet();
const wid = globalThis.setInterval(() => void loadWallet(), 22_000);
return () => {
alive = false;
globalThis.clearInterval(wid);
};
}, [session?.user?.id]);
const fmtUsd = (n: number) =>
n.toLocaleString(undefined, { style: "currency", currency: "USD", maximumFractionDigits: 0 });
return (
<motion.div
initial={false}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.38, duration: 0.6 }}
className="grid gap-3 sm:grid-cols-3"
>
<StatShell
accent="sky"
dotVariant="emerald"
label="Fixed tiers"
foot={
<div className="flex flex-col gap-1.5">
<span>Rate locks when you start checkout.</span>
<Link href="/donate" className="w-fit font-medium text-sky-300/95 hover:text-white hover:underline">
Go to donate
</Link>
</div>
}
>
<p className="font-mono text-sm tabular-nums tracking-tight text-sky-100 sm:text-[15px]">$5 · $10 · $20 · $100</p>
<p className="mt-2 text-xs font-normal text-slate-500">Four gift tiers · secure card checkout</p>
</StatShell>
<StatShell
accent="indigo"
dotVariant="sky"
label={`${ticker} spot`}
foot={
session?.user ? (
wallet?.infinite ? (
<span className="text-emerald-300/90">Admin preview · unlimited {creditName}</span>
) : (
<div className="flex flex-col gap-1.5">
<span>
Wallet:{" "}
<span className="font-mono font-medium text-indigo-200">
{wallet !== null ? `${wallet.balance.toLocaleString()} ${ticker}` : "…"}
</span>
</span>
<Link href="/wallet" className="w-fit font-medium text-indigo-300/95 hover:text-white hover:underline">
Open wallet
</Link>
</div>
)
) : (
<div className="flex flex-col gap-1.5">
<span>Sign in to show your balance here.</span>
<Link href="/login" className="w-fit font-medium text-indigo-300/95 hover:text-white hover:underline">
Sign in
</Link>
</div>
)
}
>
{rate ? (
<div>
<p className="font-mono text-lg tabular-nums tracking-tight text-white sm:text-xl">
${rate.blwUsd.toFixed(4)}{" "}
<span className="text-xs font-normal text-slate-500">USD/{ticker}</span>
</p>
<p className="mt-2 text-xs text-slate-500">
{rate.blwPerUsd.toFixed(2)} {ticker} per $1 · updates ~25s
</p>
</div>
) : (
<p className="animate-pulse text-sm text-slate-500">Loading spot</p>
)}
</StatShell>
<StatShell
accent="fuchsia"
dotVariant="violet"
label="Public totals"
foot={
stats ? (
<div className="flex flex-col gap-1.5">
<span>
{stats.donationCount.toLocaleString()} charges · {stats.uniqueDonors.toLocaleString()} donor accounts
</span>
<Link href="/raised" className="w-fit font-medium text-fuchsia-300/95 hover:text-white hover:underline">
Disclosure hall
</Link>
</div>
) : (
<span className="animate-pulse text-slate-500">Loading totals</span>
)
}
>
{stats ? (
<p className="font-mono text-lg tabular-nums text-white sm:text-xl">{fmtUsd(stats.raisedUsd)}</p>
) : (
<p className="animate-pulse text-sm text-slate-500">Loading</p>
)}
{stats ? <p className="mt-2 text-xs text-slate-500">Same verified total as the live board</p> : null}
</StatShell>
</motion.div>
);
}

View File

@@ -1,7 +1,8 @@
"use client";
import { creditTicker } from "@/lib/credits-brand";
import { motion } from "framer-motion";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
const presetAmounts = [5, 10, 20, 100];
@@ -27,36 +28,60 @@ const missions = [
];
export function ImpactPlanner() {
const t = creditTicker();
const [amount, setAmount] = useState(20);
const [volunteerHours, setVolunteerHours] = useState(3);
const [missionId, setMissionId] = useState(missions[0].id);
const [blwUsd, setBlwUsd] = useState<number | null>(null);
const selectedMission = missions.find((mission) => mission.id === missionId) ?? missions[0];
useEffect(() => {
let alive = true;
const load = async () => {
try {
const res = await fetch("/api/exchange/rate", { cache: "no-store" });
if (!res.ok) return;
const j = await res.json();
if (alive) setBlwUsd(j.blwUsd as number);
} catch { /* silent */ }
};
load();
const id = setInterval(load, 30_000);
return () => { alive = false; clearInterval(id); };
}, []);
const selectedMission = missions.find((m) => m.id === missionId) ?? missions[0];
const impact = useMemo(() => {
const intensity = selectedMission.multiplier;
const credits =
blwUsd && blwUsd > 0
? Math.floor(amount / blwUsd)
: Math.round(amount * 10);
return {
doors: Math.round(amount * 7 * intensity + volunteerHours * 22),
texts: Math.round(amount * 55 * intensity + volunteerHours * 140),
rides: Math.max(1, Math.round(amount / 18 + volunteerHours / 2)),
credits: Math.round(amount * 9.5),
credits,
};
}, [amount, selectedMission.multiplier, volunteerHours]);
}, [amount, selectedMission.multiplier, volunteerHours, blwUsd]);
return (
<section id="impact" className="border-b border-white/10 bg-[#050816] py-20">
<div className="mx-auto grid max-w-6xl gap-8 px-4 sm:px-6 lg:grid-cols-[0.95fr_1.05fr] lg:items-center">
<div>
<section id="impact" className="scroll-mt-28 border-b border-white/10 bg-[#050816] py-10">
<div className="mx-auto grid max-w-6xl gap-6 px-4 sm:px-6 lg:grid-cols-[0.95fr_1.05fr] lg:items-center">
<div className="text-center lg:text-left">
<p className="text-xs uppercase tracking-[0.32em] text-sky-200/75">Impact planner</p>
<h2 className="mt-4 text-3xl font-semibold text-white sm:text-4xl">
See the campaign machine light up before you donate.
<h2 className="mx-auto mt-3 max-w-xl text-3xl font-semibold text-white sm:text-4xl lg:mx-0">
Picture your impact before you give
</h2>
<p className="mt-4 max-w-xl text-slate-400">
Pick a mission, choose a contribution, add volunteer time, and watch the support package turn into
concrete work. The numbers are planning estimates, but the behavioral loop is real: donate, earn BLW,
redeem, recruit, repeat.
<p className="mx-auto mt-3 max-w-xl text-slate-400 lg:mx-0">
Pick a contribution size, choose a lane (field work, rights defense, or persuasion), and preview how your dollars and
volunteer hours combine before you ever open checkout. The numbers here are orientation only your real {t} posts
after you give while signed in.
</p>
<div className="mt-8 grid gap-3 sm:grid-cols-3">
<p className="mx-auto mt-3 max-w-xl text-sm text-slate-500 lg:mx-0">
When you are ready, continue to donate then come back to pledge missions, initiatives, and perks from one wallet.
</p>
<div className="mt-5 grid gap-2 sm:grid-cols-3 sm:gap-3">
{missions.map((mission) => (
<button
key={mission.id}
@@ -80,19 +105,22 @@ export function ImpactPlanner() {
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.55 }}
className="rounded-[32px] border border-white/10 bg-gradient-to-br from-white/10 via-white/5 to-sky-500/10 p-6 shadow-[0_0_120px_rgba(56,189,248,0.14)]"
className="rounded-[32px] border border-white/10 bg-gradient-to-br from-white/10 via-white/5 to-sky-500/10 p-5 shadow-[0_0_120px_rgba(56,189,248,0.14)] sm:p-6"
>
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-slate-400">Your surge package</p>
<p className="mt-2 text-3xl font-semibold text-white">${amount}</p>
</div>
<div className="rounded-2xl border border-emerald-400/30 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100">
~{impact.credits.toLocaleString()} BLW after webhook credit
~{impact.credits.toLocaleString()} {t} credits
<span className="mt-1 block text-[11px] font-normal leading-snug text-emerald-200/70">
{blwUsd ? `at live spot $${blwUsd.toFixed(4)}/${t}` : "loading live rate…"}
</span>
</div>
</div>
<div className="mt-6 flex flex-wrap gap-2">
<div className="mt-4 flex flex-wrap gap-2">
{presetAmounts.map((preset) => (
<button
key={preset}
@@ -107,7 +135,7 @@ export function ImpactPlanner() {
))}
</div>
<label className="mt-6 block text-sm font-medium text-slate-200" htmlFor="volunteer-hours">
<label className="mt-4 block text-sm font-medium text-slate-200" htmlFor="volunteer-hours">
Add volunteer hours: <span className="text-white">{volunteerHours}</span>
</label>
<input
@@ -116,11 +144,11 @@ export function ImpactPlanner() {
min="0"
max="12"
value={volunteerHours}
onChange={(event) => setVolunteerHours(Number(event.target.value))}
onChange={(e) => setVolunteerHours(Number(e.target.value))}
className="mt-3 w-full accent-sky-400"
/>
<div className="mt-8 grid gap-4 sm:grid-cols-2">
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<ImpactMetric label="Doors reached" value={impact.doors} />
<ImpactMetric label="Persuasion texts" value={impact.texts} />
<ImpactMetric label="Ride assists" value={impact.rides} />
@@ -134,7 +162,7 @@ export function ImpactPlanner() {
function ImpactMetric({ label, value, text = false }: { label: string; value: number | string; text?: boolean }) {
return (
<div className="rounded-2xl border border-white/10 bg-black/25 p-4">
<div className="rounded-2xl border border-white/10 bg-black/25 p-3 sm:p-4">
<p className="text-xs uppercase tracking-wide text-slate-500">{label}</p>
<p className={`${text ? "text-lg" : "text-3xl"} mt-2 font-semibold text-white`}>
{typeof value === "number" ? value.toLocaleString() : value}

View File

@@ -5,20 +5,34 @@ import { motion } from "framer-motion";
export function IssueGrid() {
return (
<div id="priorities" className="mx-auto grid max-w-6xl gap-6 px-4 sm:grid-cols-2 lg:grid-cols-3 sm:px-6">
<div id="priorities" className="mx-auto grid max-w-6xl gap-3 px-4 sm:grid-cols-2 lg:grid-cols-3 sm:px-6">
{issues.map((issue, idx) => (
<motion.article
key={issue.id}
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ delay: idx * 0.05, duration: 0.5 }}
className={`relative overflow-hidden rounded-3xl border border-white/10 bg-gradient-to-br ${issue.accent} p-6 shadow-[0_0_80px_rgba(56,189,248,0.08)]`}
initial={{ opacity: 0, y: 22, scale: 0.97 }}
whileInView={{ opacity: 1, y: 0, scale: 1 }}
whileHover={{ y: -4, scale: 1.02 }}
viewport={{ once: true, margin: "-60px" }}
transition={{ delay: idx * 0.07, duration: 0.5, type: "spring", stiffness: 200, damping: 20 }}
className={`relative cursor-default overflow-hidden rounded-2xl border border-white/12 bg-gradient-to-br ${issue.accent} p-4 shadow-[0_0_60px_rgba(56,189,248,0.06)] sm:p-5`}
>
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.12),_transparent_55%)]" />
<p className="text-xs uppercase tracking-[0.28em] text-slate-300/90">{issue.subtitle}</p>
<h3 className="mt-3 text-xl font-semibold text-white">{issue.title}</h3>
<p className="mt-3 text-sm leading-relaxed text-slate-200/90">{issue.body}</p>
{/* Top radial highlight */}
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(255,255,255,0.14),_transparent_55%)]" />
{/* Shimmer sweep on hover */}
<div className="pointer-events-none absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/[0.07] to-transparent transition-transform duration-700 hover:translate-x-full" />
{/* Accent dot */}
<div className="mb-4 h-1.5 w-8 rounded-full bg-gradient-to-r from-white/60 to-white/20" />
<p className="text-xs uppercase tracking-[0.28em] text-slate-200/80">{issue.subtitle}</p>
<h3 className="mt-2.5 text-lg font-bold text-white">{issue.title}</h3>
<p className="mt-3 text-sm leading-relaxed text-slate-200/85">{issue.body}</p>
{/* Bottom-right number badge */}
<div className="pointer-events-none absolute bottom-4 right-4 font-mono text-4xl font-black text-white/[0.06]">
{String(idx + 1).padStart(2, "0")}
</div>
</motion.article>
))}
</div>

Some files were not shown because too many files have changed in this diff Show More