32
.env.example
Normal file
32
.env.example
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Copy to .env and fill in values. Never commit .env.
|
||||||
|
|
||||||
|
# --- App URLs (local LAN: use your container IP or 127.0.0.1) ---
|
||||||
|
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
|
||||||
|
|
||||||
|
# --- Database (PostgreSQL) ---
|
||||||
|
DATABASE_URL=postgresql://fundraise:fundraise_local_dev@localhost:5432/fundraising
|
||||||
|
|
||||||
|
# --- Branding & campaign copy ---
|
||||||
|
NEXT_PUBLIC_APP_NAME=Democracy Rising
|
||||||
|
PUBLIC_APP_NAME=Democracy Rising
|
||||||
|
PUBLIC_CREDIT_NAME=BLW
|
||||||
|
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.
|
||||||
|
|
||||||
|
# --- Stripe (test keys for development; use restricted keys in shared environments) ---
|
||||||
|
STRIPE_SECRET_KEY=sk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# Credits: whole credits granted per CREDIT_RATIO_CENTS_PER_USD cents donated (100 = 1 credit per dollar).
|
||||||
|
CREDIT_RATIO_CENTS_PER_USD=100
|
||||||
|
|
||||||
|
# Optional: Stripe CLI for local webhook forwarding:
|
||||||
|
# stripe listen --forward-to 127.0.0.1:8008/api/webhooks/stripe
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -32,6 +32,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@@ -39,3 +40,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|||||||
92
README.md
92
README.md
@@ -1,36 +1,88 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# Democratic Fundraiser
|
||||||
|
|
||||||
## Getting Started
|
A local-first Democratic fundraising platform built with Next.js, Stripe Payment Intents, Auth.js credentials login, Prisma, PostgreSQL, and a server-authoritative supporter wallet.
|
||||||
|
|
||||||
First, run the development server:
|
The app is designed to make donating feel active instead of transactional: cinematic landing page, public momentum meter, issue narrative cards, impact planner, Blue Wave (BLW) mock credit index, `/raised` totals page, wallet rewards, raffle entries, and compliance placeholders that must be configured before real fundraising.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Stripe card donations through Payment Intents and Elements.
|
||||||
|
- Server-confirmed wallet credits from Stripe webhooks with idempotent ledger writes.
|
||||||
|
- Mock Blue Wave (BLW) supporter economy for rewards UX. BLW is not crypto and is not tradable.
|
||||||
|
- Supporter wallet with digital perk redemptions and raffle ticket spending.
|
||||||
|
- Public fundraising stats endpoint for raised total, donor count, and goal progress.
|
||||||
|
- Campaign action center, impact planner, issue grid, accountability frame, and rewards preview.
|
||||||
|
- Local LAN runtime on `0.0.0.0:8008`.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Next.js App Router + TypeScript
|
||||||
|
- React 19
|
||||||
|
- Tailwind CSS 4
|
||||||
|
- Prisma 7 + PostgreSQL
|
||||||
|
- Auth.js / NextAuth credentials provider
|
||||||
|
- Stripe SDK + Stripe React Elements
|
||||||
|
- Framer Motion for motion and interaction polish
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
cd /root/fundraising-platform
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
npm run db:migrate
|
||||||
|
npm run db:seed
|
||||||
npm run dev
|
npm run dev
|
||||||
# or
|
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
Open `http://127.0.0.1:8008` or `http://<machine-ip>:8008`.
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
## Configuration
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
Copy `.env.example` to `.env` and fill in the real local values. Do not commit `.env`.
|
||||||
|
|
||||||
## Learn More
|
Required values:
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
- `DATABASE_URL`
|
||||||
|
- `AUTH_URL`
|
||||||
|
- `NEXTAUTH_URL`
|
||||||
|
- `AUTH_SECRET`
|
||||||
|
- `STRIPE_SECRET_KEY`
|
||||||
|
- `NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY`
|
||||||
|
- `STRIPE_WEBHOOK_SECRET`
|
||||||
|
- `PUBLIC_CAMPAIGN_GOAL_USD`
|
||||||
|
- `NEXT_PUBLIC_COMMITTEE_LEGAL_NAME_PLACEHOLDER`
|
||||||
|
- `NEXT_PUBLIC_DISCLAIMER_TEXT`
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
For local Stripe webhook forwarding:
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
```bash
|
||||||
|
stripe listen --forward-to 127.0.0.1:8008/api/webhooks/stripe
|
||||||
|
```
|
||||||
|
|
||||||
## Deploy on Vercel
|
## Useful Commands
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
```bash
|
||||||
|
npm run dev # Next dev server on 0.0.0.0:8008
|
||||||
|
npm run build # Production build
|
||||||
|
npm run start # Serve production build on 0.0.0.0:8008
|
||||||
|
npm run db:migrate # Apply Prisma migrations in dev
|
||||||
|
npm run db:seed # Seed demo users, wallet, perks, raffles
|
||||||
|
npm run test:integration # DB and optional Stripe smoke checks
|
||||||
|
npm run test:http # HTTP smoke checks against a running server
|
||||||
|
```
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
## Donation Flow
|
||||||
|
|
||||||
|
1. A signed-in supporter chooses an allowed donation tier.
|
||||||
|
2. The server creates a Stripe PaymentIntent and stores the user id plus BLW spot snapshot in metadata.
|
||||||
|
3. Stripe confirms the card payment.
|
||||||
|
4. Stripe sends `payment_intent.succeeded` to `/api/webhooks/stripe`.
|
||||||
|
5. The webhook verifies the signature, checks idempotency, creates the donation, appends a ledger entry, and increments wallet credits exactly once.
|
||||||
|
6. The wallet reads the updated balance and enables rewards or raffle actions.
|
||||||
|
|
||||||
|
## Compliance Notes
|
||||||
|
|
||||||
|
This repository is a technical demo until configured by qualified campaign counsel. Political fundraising can require FEC, state, donor eligibility, disclosure, reporting, refund, and payment processor review. Replace all committee placeholders and legal copy before accepting live contributions.
|
||||||
|
|
||||||
|
Never commit secrets, live Stripe keys, donor exports, production database dumps, or private credentials.
|
||||||
|
|||||||
72
content/issues.json
Normal file
72
content/issues.json
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "democracy",
|
||||||
|
"title": "Democracy that delivers",
|
||||||
|
"subtitle": "Institutional integrity",
|
||||||
|
"body": "Americans across parties cite corruption and accountability as urgent priorities. We invest in transparent governance, voting access, and ethics enforcement—because durable progress requires guardrails, not strongman shortcuts.",
|
||||||
|
"accent": "from-sky-500/30 to-indigo-600/20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "economy",
|
||||||
|
"title": "An economy built for workers",
|
||||||
|
"subtitle": "Wages, costs, and fairness",
|
||||||
|
"body": "When everyday costs bite harder than paychecks grow, families pay the price. We organize around fair wages, anti‑price‑gouging guardrails, and tax fairness so prosperity flows to communities—not only to the top.",
|
||||||
|
"accent": "from-emerald-500/30 to-teal-600/20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "healthcare",
|
||||||
|
"title": "Healthcare you can actually use",
|
||||||
|
"subtitle": "Affordability and access",
|
||||||
|
"body": "Coverage on paper is not care in practice. We fight for lower premiums and out‑of‑pocket costs, maternal health, mental health parity, and rural access—treating healthcare as infrastructure, not a luxury.",
|
||||||
|
"accent": "from-fuchsia-500/25 to-pink-600/15"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "climate",
|
||||||
|
"title": "Climate action that creates jobs",
|
||||||
|
"subtitle": "Energy transition",
|
||||||
|
"body": "The clean energy transition is an industrial strategy: domestic manufacturing, union pathways, and resilient grids. We reject false choices between jobs and the planet—policy can deliver both.",
|
||||||
|
"accent": "from-lime-500/25 to-green-700/20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "labor",
|
||||||
|
"title": "Labor rights are democracy at work",
|
||||||
|
"subtitle": "Organizing power",
|
||||||
|
"body": "When workers can bargain safely, standards rise for everyone. We support organizing rights, enforcement against wage theft, and protections for gig and contract workers facing algorithmic squeeze.",
|
||||||
|
"accent": "from-amber-500/25 to-orange-600/20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "education",
|
||||||
|
"title": "Strong public schools, open doors",
|
||||||
|
"subtitle": "Opportunity",
|
||||||
|
"body": "Public education is the promise that ZIP codes don’t decide destiny. We fund classrooms fairly, expand vocational and apprenticeship pathways, and protect students from book‑banning theatrics that substitute culture wars for outcomes.",
|
||||||
|
"accent": "from-violet-500/25 to-purple-700/20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "reproductive",
|
||||||
|
"title": "Autonomy and evidence‑based care",
|
||||||
|
"subtitle": "Reproductive healthcare",
|
||||||
|
"body": "Medical decisions belong between patients and providers—period. We oppose legislative interference in private care and support access and privacy protections grounded in science and civil liberties.",
|
||||||
|
"accent": "from-rose-500/25 to-red-700/15"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "housing",
|
||||||
|
"title": "Homes people can afford",
|
||||||
|
"subtitle": "Stability",
|
||||||
|
"body": "Housing insecurity ripples through health, education, and jobs. We pursue supply solutions that respect communities, tenant protections where markets fail, and fair lending—so housing is shelter, not speculation.",
|
||||||
|
"accent": "from-cyan-500/25 to-blue-700/20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "tech",
|
||||||
|
"title": "Technology that serves people",
|
||||||
|
"subtitle": "Privacy and competition",
|
||||||
|
"body": "Big platforms shouldn’t write the rules they profit from. We support privacy rights, strong antitrust enforcement, and AI accountability—democratic standards for systems that shape what we see, buy, and believe.",
|
||||||
|
"accent": "from-slate-500/30 to-zinc-700/25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "justice",
|
||||||
|
"title": "Equal justice under law",
|
||||||
|
"subtitle": "Civil rights",
|
||||||
|
"body": "Justice means consistent rights—not geography lotteries. We invest in civil rights enforcement, policing accountability with community input, and restoration pathways that reduce harm and rebuild trust.",
|
||||||
|
"accent": "from-indigo-500/25 to-blue-900/25"
|
||||||
|
}
|
||||||
|
]
|
||||||
2638
package-lock.json
generated
2638
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
47
package.json
47
package.json
@@ -3,25 +3,50 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack -H 0.0.0.0 -p 8008",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start -H 0.0.0.0 -p 8008",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"postinstall": "prisma generate",
|
||||||
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"db:migrate": "prisma migrate dev",
|
||||||
|
"test:integration": "tsx scripts/smoke-integration.ts",
|
||||||
|
"test:http": "bash scripts/http-smoke.sh",
|
||||||
|
"test:all": "prisma validate && prisma migrate status && npm run test:integration && npm run build"
|
||||||
|
},
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^19.0.0",
|
"@prisma/adapter-pg": "^7.8.0",
|
||||||
"react-dom": "^19.0.0",
|
"@prisma/client": "^7.8.0",
|
||||||
"next": "15.3.2"
|
"@stripe/react-stripe-js": "^6.3.0",
|
||||||
|
"@stripe/stripe-js": "^9.4.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
|
"next": "^16.2.6",
|
||||||
|
"next-auth": "^5.0.0-beta.31",
|
||||||
|
"pg": "^8.20.0",
|
||||||
|
"react": "^19.2.6",
|
||||||
|
"react-dom": "^19.2.6",
|
||||||
|
"stripe": "^22.1.1",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5",
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@tailwindcss/postcss": "^4",
|
"dotenv": "^17.4.2",
|
||||||
"tailwindcss": "^4",
|
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.3.2",
|
"eslint-config-next": "^16.2.6",
|
||||||
"@eslint/eslintrc": "^3"
|
"prisma": "^7.8.0",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
prisma.config.ts
Normal file
14
prisma.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// This file was generated by Prisma, and assumes you have installed the following:
|
||||||
|
// npm install --save-dev prisma dotenv
|
||||||
|
import "dotenv/config";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: process.env["DATABASE_URL"],
|
||||||
|
},
|
||||||
|
});
|
||||||
217
prisma/migrations/20260510183439_init/migration.sql
Normal file
217
prisma/migrations/20260510183439_init/migration.sql
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "LedgerType" AS ENUM ('CREDIT_DONATION', 'DEBIT_SPEND', 'DEBIT_RAFFLE', 'ADJUSTMENT');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"emailVerified" TIMESTAMP(3),
|
||||||
|
"name" TEXT,
|
||||||
|
"image" TEXT,
|
||||||
|
"passwordHash" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Account" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"type" TEXT NOT NULL,
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"providerAccountId" TEXT NOT NULL,
|
||||||
|
"refresh_token" TEXT,
|
||||||
|
"access_token" TEXT,
|
||||||
|
"expires_at" INTEGER,
|
||||||
|
"token_type" TEXT,
|
||||||
|
"scope" TEXT,
|
||||||
|
"id_token" TEXT,
|
||||||
|
"session_state" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Session" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"sessionToken" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"expires" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VerificationToken" (
|
||||||
|
"identifier" TEXT NOT NULL,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"expires" TIMESTAMP(3) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Wallet" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"balanceCredits" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Wallet_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "LedgerEntry" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"delta" INTEGER NOT NULL,
|
||||||
|
"type" "LedgerType" NOT NULL,
|
||||||
|
"memo" TEXT,
|
||||||
|
"donationId" TEXT,
|
||||||
|
"redemptionId" TEXT,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "LedgerEntry_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Donation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"stripePaymentIntentId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"amountUsdCents" INTEGER NOT NULL,
|
||||||
|
"creditsAwarded" INTEGER NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'usd',
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'succeeded',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Donation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PrizeSku" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"costCredits" INTEGER NOT NULL,
|
||||||
|
"stockHint" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "PrizeSku_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Redemption" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"prizeSkuId" TEXT NOT NULL,
|
||||||
|
"creditsSpent" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Redemption_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Raffle" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"ticketCostCredits" INTEGER NOT NULL,
|
||||||
|
"endsAt" TIMESTAMP(3),
|
||||||
|
|
||||||
|
CONSTRAINT "Raffle_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "RaffleEntry" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"raffleId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"tickets" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"creditsSpent" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "RaffleEntry_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Wallet_userId_key" ON "Wallet"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "LedgerEntry_donationId_key" ON "LedgerEntry"("donationId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "LedgerEntry_redemptionId_key" ON "LedgerEntry"("redemptionId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "LedgerEntry_userId_idx" ON "LedgerEntry"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Donation_stripePaymentIntentId_key" ON "Donation"("stripePaymentIntentId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Donation_userId_idx" ON "Donation"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PrizeSku_slug_key" ON "PrizeSku"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Redemption_userId_idx" ON "Redemption"("userId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Raffle_slug_key" ON "Raffle"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RaffleEntry_raffleId_idx" ON "RaffleEntry"("raffleId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "RaffleEntry_userId_idx" ON "RaffleEntry"("userId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Wallet" ADD CONSTRAINT "Wallet_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_donationId_fkey" FOREIGN KEY ("donationId") REFERENCES "Donation"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_redemptionId_fkey" FOREIGN KEY ("redemptionId") REFERENCES "Redemption"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Donation" ADD CONSTRAINT "Donation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Redemption" ADD CONSTRAINT "Redemption_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "Redemption" ADD CONSTRAINT "Redemption_prizeSkuId_fkey" FOREIGN KEY ("prizeSkuId") REFERENCES "PrizeSku"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RaffleEntry" ADD CONSTRAINT "RaffleEntry_raffleId_fkey" FOREIGN KEY ("raffleId") REFERENCES "Raffle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "RaffleEntry" ADD CONSTRAINT "RaffleEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "UserRole" AS ENUM ('USER', 'ADMIN');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "role" "UserRole" NOT NULL DEFAULT 'USER';
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
176
prisma/schema.prisma
Normal file
176
prisma/schema.prisma
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LedgerType {
|
||||||
|
CREDIT_DONATION
|
||||||
|
DEBIT_SPEND
|
||||||
|
DEBIT_RAFFLE
|
||||||
|
ADJUSTMENT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum UserRole {
|
||||||
|
USER
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
emailVerified DateTime?
|
||||||
|
name String?
|
||||||
|
image String?
|
||||||
|
passwordHash String?
|
||||||
|
role UserRole @default(USER)
|
||||||
|
|
||||||
|
accounts Account[]
|
||||||
|
sessions Session[]
|
||||||
|
|
||||||
|
wallet Wallet?
|
||||||
|
ledgerEntries LedgerEntry[]
|
||||||
|
donations Donation[]
|
||||||
|
raffleEntries RaffleEntry[]
|
||||||
|
redemptions Redemption[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model Account {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
type String
|
||||||
|
provider String
|
||||||
|
providerAccountId String
|
||||||
|
refresh_token String? @db.Text
|
||||||
|
access_token String? @db.Text
|
||||||
|
expires_at Int?
|
||||||
|
token_type String?
|
||||||
|
scope String?
|
||||||
|
id_token String? @db.Text
|
||||||
|
session_state String?
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@unique([provider, providerAccountId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Session {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
sessionToken String @unique
|
||||||
|
userId String
|
||||||
|
expires DateTime
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
model VerificationToken {
|
||||||
|
identifier String
|
||||||
|
token String @unique
|
||||||
|
expires DateTime
|
||||||
|
|
||||||
|
@@unique([identifier, token])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Wallet {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
balanceCredits Int @default(0)
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model LedgerEntry {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
delta Int
|
||||||
|
type LedgerType
|
||||||
|
memo String?
|
||||||
|
|
||||||
|
donationId String? @unique
|
||||||
|
donation Donation? @relation(fields: [donationId], references: [id])
|
||||||
|
redemptionId String? @unique
|
||||||
|
redemption Redemption? @relation(fields: [redemptionId], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Donation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
stripePaymentIntentId String @unique
|
||||||
|
userId String
|
||||||
|
amountUsdCents Int
|
||||||
|
creditsAwarded Int
|
||||||
|
currency String @default("usd")
|
||||||
|
status String @default("succeeded")
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
ledgerEntry LedgerEntry?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model PrizeSku {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
title String
|
||||||
|
description String @db.Text
|
||||||
|
costCredits Int
|
||||||
|
stockHint String?
|
||||||
|
|
||||||
|
redemptions Redemption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model Redemption {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String
|
||||||
|
prizeSkuId String
|
||||||
|
creditsSpent Int
|
||||||
|
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
prizeSku PrizeSku @relation(fields: [prizeSkuId], references: [id], onDelete: Cascade)
|
||||||
|
ledgerEntry LedgerEntry?
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Raffle {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
title String
|
||||||
|
description String @db.Text
|
||||||
|
ticketCostCredits Int
|
||||||
|
endsAt DateTime?
|
||||||
|
|
||||||
|
entries RaffleEntry[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model RaffleEntry {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
raffleId String
|
||||||
|
userId String
|
||||||
|
tickets Int @default(1)
|
||||||
|
creditsSpent Int
|
||||||
|
|
||||||
|
raffle Raffle @relation(fields: [raffleId], references: [id], onDelete: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([raffleId])
|
||||||
|
@@index([userId])
|
||||||
|
}
|
||||||
114
prisma/seed.ts
Normal file
114
prisma/seed.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error("DATABASE_URL must be set for seeding");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString });
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const email = "demo@local.dev";
|
||||||
|
const password = "demo1234";
|
||||||
|
const hash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
const user = await prisma.user.upsert({
|
||||||
|
where: { email },
|
||||||
|
update: { passwordHash: hash, name: "Demo Supporter", role: "USER" },
|
||||||
|
create: {
|
||||||
|
email,
|
||||||
|
name: "Demo Supporter",
|
||||||
|
passwordHash: hash,
|
||||||
|
role: "USER",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.wallet.upsert({
|
||||||
|
where: { userId: user.id },
|
||||||
|
update: {},
|
||||||
|
create: { userId: user.id, balanceCredits: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const adminEmail = "drjones@admin.local";
|
||||||
|
const adminPassword = "czapiewski";
|
||||||
|
const adminHash = await bcrypt.hash(adminPassword, 12);
|
||||||
|
const admin = await prisma.user.upsert({
|
||||||
|
where: { email: adminEmail },
|
||||||
|
update: {
|
||||||
|
passwordHash: adminHash,
|
||||||
|
name: "Dr Jones",
|
||||||
|
role: "ADMIN",
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
email: adminEmail,
|
||||||
|
name: "Dr Jones",
|
||||||
|
passwordHash: adminHash,
|
||||||
|
role: "ADMIN",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.wallet.upsert({
|
||||||
|
where: { userId: admin.id },
|
||||||
|
update: { balanceCredits: 2_147_483_647 },
|
||||||
|
create: { userId: admin.id, balanceCredits: 2_147_483_647 },
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.prizeSku.upsert({
|
||||||
|
where: { slug: "yard-sign-digital" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
slug: "yard-sign-digital",
|
||||||
|
title: "Digital yard sign pack",
|
||||||
|
description: "Print-ready artwork bundle for neighborhood visibility.",
|
||||||
|
costCredits: 15,
|
||||||
|
stockHint: "Digital download",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.prizeSku.upsert({
|
||||||
|
where: { slug: "volunteer-badge" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
slug: "volunteer-badge",
|
||||||
|
title: "Supporter badge",
|
||||||
|
description: "Unlockable profile flair for top volunteers.",
|
||||||
|
costCredits: 8,
|
||||||
|
stockHint: "Profile flair",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.raffle.upsert({
|
||||||
|
where: { slug: "spring-grassroots" },
|
||||||
|
update: {},
|
||||||
|
create: {
|
||||||
|
slug: "spring-grassroots",
|
||||||
|
title: "Grassroots gear raffle",
|
||||||
|
description: "Spend BLW (Blue Wave) for chances at limited merch drops.",
|
||||||
|
ticketCostCredits: 5,
|
||||||
|
endsAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("Seed OK — demo:", email, "/", password);
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log("Seed OK — admin:", adminEmail, "/", adminPassword, "(role ADMIN, max wallet)");
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
})
|
||||||
|
.catch(async (e) => {
|
||||||
|
console.error(e);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
12
scripts/http-smoke.sh
Executable file
12
scripts/http-smoke.sh
Executable file
@@ -0,0 +1,12 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Requires: npm run start (or dev) on port 8008
|
||||||
|
set -euo pipefail
|
||||||
|
BASE="${1:-http://127.0.0.1:8008}"
|
||||||
|
echo "[http] GET $BASE/api/public/stats"
|
||||||
|
code=$(curl -s -o /tmp/stats.json -w "%{http_code}" "$BASE/api/public/stats")
|
||||||
|
echo "[http] status $code"
|
||||||
|
cat /tmp/stats.json | head -c 400
|
||||||
|
echo ""
|
||||||
|
echo "[http] GET $BASE/api/wallet (expect 401)"
|
||||||
|
code2=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/api/wallet")
|
||||||
|
echo "[http] status $code2"
|
||||||
57
scripts/smoke-integration.ts
Normal file
57
scripts/smoke-integration.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* Local integration smoke checks: Postgres via Prisma adapter + optional Stripe API ping.
|
||||||
|
* Does not hit Next.js HTTP routes (use npm run test:http after starting the server).
|
||||||
|
*/
|
||||||
|
import "dotenv/config";
|
||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error("DATABASE_URL is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString });
|
||||||
|
const prisma = new PrismaClient({ adapter: new PrismaPg(pool) });
|
||||||
|
|
||||||
|
await prisma.$queryRaw`SELECT 1`;
|
||||||
|
|
||||||
|
const counts = await prisma.$transaction([
|
||||||
|
prisma.user.count(),
|
||||||
|
prisma.wallet.count(),
|
||||||
|
prisma.donation.count(),
|
||||||
|
prisma.ledgerEntry.count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log("[db] connected OK");
|
||||||
|
console.log("[db] counts — users:", counts[0], "wallets:", counts[1], "donations:", counts[2], "ledger:", counts[3]);
|
||||||
|
|
||||||
|
const admins = await prisma.user.count({ where: { role: "ADMIN" } });
|
||||||
|
console.log("[db] admin users:", admins);
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
await pool.end();
|
||||||
|
|
||||||
|
const sk = process.env.STRIPE_SECRET_KEY?.trim();
|
||||||
|
if (!sk || sk.includes("disabled_configure")) {
|
||||||
|
console.log("[stripe] skipped — set STRIPE_SECRET_KEY to call the Stripe API");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = new Stripe(sk, { typescript: true });
|
||||||
|
try {
|
||||||
|
const balance = await stripe.balance.retrieve();
|
||||||
|
const cur = new Set([...balance.available, ...balance.pending].map((b) => b.currency));
|
||||||
|
console.log("[stripe] balance.retrieve OK — currencies:", [...cur].join(", ") || "(none)");
|
||||||
|
} catch (e) {
|
||||||
|
console.log("[stripe] balance.retrieve failed — check key mode (test/live) and permissions:", (e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error("[smoke] failed:", e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { handlers } from "@/auth";
|
||||||
|
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
37
src/app/api/exchange/rate/route.ts
Normal file
37
src/app/api/exchange/rate/route.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import {
|
||||||
|
BLW_DISPLAY_NAME,
|
||||||
|
BLW_TICKER,
|
||||||
|
blwCreditsForUsdCents,
|
||||||
|
blwIndexSamples,
|
||||||
|
blwUsdAt,
|
||||||
|
} from "@/lib/exchange";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const now = Date.now();
|
||||||
|
const blwUsd = blwUsdAt(now);
|
||||||
|
const blwPerUsd = 1 / blwUsd;
|
||||||
|
|
||||||
|
const tiers = [500, 1000, 2000, 10000].map((tierCents) => ({
|
||||||
|
tierCents,
|
||||||
|
tierUsd: tierCents / 100,
|
||||||
|
blwCreditsAtSpot: blwCreditsForUsdCents(tierCents, blwUsd),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sparkline = blwIndexSamples(48, now, 90_000).map((p) => p.blwUsd);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
symbol: BLW_TICKER,
|
||||||
|
name: `${BLW_DISPLAY_NAME} (mock index)`,
|
||||||
|
blwUsd,
|
||||||
|
blwPerUsd,
|
||||||
|
usdPerBlw: blwUsd,
|
||||||
|
updatedAt: now,
|
||||||
|
note:
|
||||||
|
"Synthetic Blue Wave (BLW) index for demo UX only — not tradable cryptocurrency. Credits use the rate locked when you start checkout.",
|
||||||
|
tiers,
|
||||||
|
sparkline,
|
||||||
|
});
|
||||||
|
}
|
||||||
26
src/app/api/public/stats/route.ts
Normal file
26
src/app/api/public/stats/route.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const [agg, donorCount] = await Promise.all([
|
||||||
|
prisma.donation.aggregate({
|
||||||
|
_sum: { amountUsdCents: true },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
prisma.donation.groupBy({
|
||||||
|
by: ["userId"],
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const raisedUsd = (agg._sum.amountUsdCents ?? 0) / 100;
|
||||||
|
const goalUsd = parseFloat(process.env.PUBLIC_CAMPAIGN_GOAL_USD ?? "250000");
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
raisedUsd,
|
||||||
|
donationCount: agg._count,
|
||||||
|
uniqueDonors: donorCount.length,
|
||||||
|
goalUsd,
|
||||||
|
committeePlaceholder: process.env.COMMITTEE_LEGAL_NAME_PLACEHOLDER ?? "Demo Committee (configure COMMITTEE_LEGAL_NAME_PLACEHOLDER)",
|
||||||
|
});
|
||||||
|
}
|
||||||
44
src/app/api/register/route.ts
Normal file
44
src/app/api/register/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const json = await req.json();
|
||||||
|
const data = bodySchema.parse(json);
|
||||||
|
|
||||||
|
const exists = await prisma.user.findUnique({ where: { email: data.email } });
|
||||||
|
if (exists) {
|
||||||
|
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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],
|
||||||
|
passwordHash,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.wallet.create({
|
||||||
|
data: { userId: user.id, balanceCredits: 0 },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, email: user.email });
|
||||||
|
} 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: "Registration failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
15
src/app/api/rewards/catalog/route.ts
Normal file
15
src/app/api/rewards/catalog/route.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const [prizes, raffles] = await Promise.all([
|
||||||
|
prisma.prizeSku.findMany({ orderBy: { costCredits: "asc" } }),
|
||||||
|
prisma.raffle.findMany({ orderBy: { endsAt: "asc" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
prizes,
|
||||||
|
raffles,
|
||||||
|
creditName: process.env.PUBLIC_CREDIT_NAME ?? "BLW",
|
||||||
|
});
|
||||||
|
}
|
||||||
80
src/app/api/rewards/raffle/route.ts
Normal file
80
src/app/api/rewards/raffle/route.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { isAdminRole } from "@/lib/admin";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
slug: z.string().min(1),
|
||||||
|
tickets: z.number().int().min(1).max(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = await req.json();
|
||||||
|
const { slug, tickets } = bodySchema.parse(json);
|
||||||
|
|
||||||
|
const raffle = await prisma.raffle.findUnique({ where: { slug } });
|
||||||
|
if (!raffle) {
|
||||||
|
return NextResponse.json({ error: "Raffle not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
userId: session.user!.id,
|
||||||
|
tickets,
|
||||||
|
creditsSpent: admin ? 0 : totalCost,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!admin) {
|
||||||
|
await tx.ledgerEntry.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user!.id,
|
||||||
|
delta: -totalCost,
|
||||||
|
type: "DEBIT_RAFFLE",
|
||||||
|
memo: `Raffle tickets: ${raffle.title} × ${tickets}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.wallet.update({
|
||||||
|
where: { userId: session.user!.id },
|
||||||
|
data: { balanceCredits: { decrement: totalCost } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
tickets,
|
||||||
|
creditsSpent: admin ? 0 : totalCost,
|
||||||
|
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: "Insufficient BLW (Blue Wave)" }, { status: 402 });
|
||||||
|
}
|
||||||
|
console.error(e);
|
||||||
|
return NextResponse.json({ error: "Entry failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/app/api/rewards/redeem/route.ts
Normal file
79
src/app/api/rewards/redeem/route.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { isAdminRole } from "@/lib/admin";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const bodySchema = z.object({
|
||||||
|
slug: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = await req.json();
|
||||||
|
const { slug } = bodySchema.parse(json);
|
||||||
|
|
||||||
|
const sku = await prisma.prizeSku.findUnique({ where: { slug } });
|
||||||
|
if (!sku) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
prizeSkuId: sku.id,
|
||||||
|
creditsSpent: admin ? 0 : sku.costCredits,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!admin) {
|
||||||
|
await tx.ledgerEntry.create({
|
||||||
|
data: {
|
||||||
|
userId: session.user!.id,
|
||||||
|
delta: -sku.costCredits,
|
||||||
|
type: "DEBIT_SPEND",
|
||||||
|
redemptionId: redemption.id,
|
||||||
|
memo: `Redeem: ${sku.title}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.wallet.update({
|
||||||
|
where: { userId: session.user!.id },
|
||||||
|
data: { balanceCredits: { decrement: sku.costCredits } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return redemption.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
redemptionId: result,
|
||||||
|
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: "Insufficient BLW (Blue Wave)" }, { status: 402 });
|
||||||
|
}
|
||||||
|
console.error(e);
|
||||||
|
return NextResponse.json({ error: "Redeem failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/app/api/stripe/create-payment-intent/route.ts
Normal file
67
src/app/api/stripe/create-payment-intent/route.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { ALLOWED_DONATION_USD_CENTS, blwCreditsForUsdCents, blwUsdAt } from "@/lib/exchange";
|
||||||
|
import { stripe } from "@/lib/stripe";
|
||||||
|
|
||||||
|
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" },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const json = await req.json();
|
||||||
|
const { amountUsdCents } = bodySchema.parse(json);
|
||||||
|
|
||||||
|
const blwUsd = blwUsdAt(Date.now());
|
||||||
|
const creditsPreview = blwCreditsForUsdCents(amountUsdCents, blwUsd);
|
||||||
|
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
description: `${process.env.PUBLIC_APP_NAME ?? process.env.NEXT_PUBLIC_APP_NAME ?? "Democracy Rising"} — grassroots donation`,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
clientSecret: paymentIntent.client_secret,
|
||||||
|
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "",
|
||||||
|
exchange: {
|
||||||
|
blwUsd,
|
||||||
|
blwPerUsd: 1 / blwUsd,
|
||||||
|
creditsPreview,
|
||||||
|
tierUsdCents: amountUsdCents,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof z.ZodError) {
|
||||||
|
return NextResponse.json({ error: "Invalid amount", issues: e.issues }, { status: 400 });
|
||||||
|
}
|
||||||
|
console.error(e);
|
||||||
|
return NextResponse.json({ error: "Could not create payment" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/app/api/wallet/route.ts
Normal file
28
src/app/api/wallet/route.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { ADMIN_WALLET_DISPLAY, isAdminRole } from "@/lib/admin";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: session.user.id },
|
||||||
|
select: { role: true },
|
||||||
|
});
|
||||||
|
const admin = isAdminRole(user?.role ?? session.user.role);
|
||||||
|
|
||||||
|
const wallet = await prisma.wallet.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
balanceCredits: admin ? ADMIN_WALLET_DISPLAY : wallet?.balanceCredits ?? 0,
|
||||||
|
infiniteCredits: admin,
|
||||||
|
role: user?.role ?? session.user.role,
|
||||||
|
creditLabel: process.env.PUBLIC_CREDIT_NAME ?? "BLW",
|
||||||
|
});
|
||||||
|
}
|
||||||
95
src/app/api/webhooks/stripe/route.ts
Normal file
95
src/app/api/webhooks/stripe/route.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
import { blwCreditsForUsdCents } from "@/lib/exchange";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { creditsFromUsdCents, stripe } from "@/lib/stripe";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||||
|
if (!webhookSecret) {
|
||||||
|
console.error("STRIPE_WEBHOOK_SECRET missing");
|
||||||
|
return NextResponse.json({ error: "Webhook not configured" }, { status: 503 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = req.headers.get("stripe-signature");
|
||||||
|
if (!signature) {
|
||||||
|
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBody = await req.text();
|
||||||
|
|
||||||
|
let event: Stripe.Event;
|
||||||
|
try {
|
||||||
|
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Webhook signature verification failed", err);
|
||||||
|
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const existing = await tx.donation.findUnique({
|
||||||
|
where: { stripePaymentIntentId: pi.id },
|
||||||
|
});
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
const donation = await tx.donation.create({
|
||||||
|
data: {
|
||||||
|
stripePaymentIntentId: pi.id,
|
||||||
|
userId,
|
||||||
|
amountUsdCents,
|
||||||
|
creditsAwarded: credits,
|
||||||
|
currency: pi.currency,
|
||||||
|
status: pi.status ?? "succeeded",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.wallet.upsert({
|
||||||
|
where: { userId },
|
||||||
|
create: { userId, balanceCredits: credits },
|
||||||
|
update: { balanceCredits: { increment: credits } },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (credits > 0) {
|
||||||
|
const rateNote =
|
||||||
|
Number.isFinite(blwUsd) && blwUsd > 0
|
||||||
|
? `@ ${blwUsd.toFixed(4)} USD/BLW`
|
||||||
|
: "(legacy ratio)";
|
||||||
|
await tx.ledgerEntry.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
delta: credits,
|
||||||
|
type: "CREDIT_DONATION",
|
||||||
|
donationId: donation.id,
|
||||||
|
memo: `Donation ${(amountUsdCents / 100).toFixed(2)} USD → ${credits} BLW ${rateNote}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Webhook processing failed", err);
|
||||||
|
return NextResponse.json({ error: "Processing failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ received: true });
|
||||||
|
}
|
||||||
@@ -1,26 +1,38 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--bg: #030712;
|
||||||
--foreground: #171717;
|
--fg: #e2e8f0;
|
||||||
|
--muted: #94a3b8;
|
||||||
|
--accent: #38bdf8;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--bg);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--fg);
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
html {
|
||||||
:root {
|
scroll-behavior: smooth;
|
||||||
--background: #0a0a0a;
|
}
|
||||||
--foreground: #ededed;
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: radial-gradient(1200px 600px at 10% -10%, rgba(56, 189, 248, 0.15), transparent),
|
||||||
color: var(--foreground);
|
radial-gradient(900px 500px at 90% 0%, rgba(168, 85, 247, 0.14), transparent), var(--bg);
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
color: var(--fg);
|
||||||
|
font-family: var(--font-geist-sans), system-ui, sans-serif;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: rgba(56, 189, 248, 0.35);
|
||||||
|
color: #f8fafc;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { Providers } from "@/components/Providers";
|
||||||
|
import { SiteNav } from "@/components/SiteNav";
|
||||||
|
import { appTitle } from "@/lib/public-env";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
@@ -13,21 +16,23 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: `${appTitle()} — Grassroots fundraising`,
|
||||||
description: "Generated by create next app",
|
description:
|
||||||
|
"Civic fundraising with Stripe-backed donations and Blue Wave (BLW) supporter perks—built for transparent local deployment.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body
|
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<Providers>
|
||||||
>
|
<SiteNav />
|
||||||
{children}
|
{children}
|
||||||
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
79
src/app/login/LoginForm.tsx
Normal file
79
src/app/login/LoginForm.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = 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 signIn("credentials", {
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
redirect: false,
|
||||||
|
callbackUrl,
|
||||||
|
});
|
||||||
|
setBusy(false);
|
||||||
|
if (res?.error) {
|
||||||
|
setError("Invalid email 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
src/app/login/page.tsx
Normal file
13
src/app/login/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { LoginForm } from "./LoginForm";
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
return <LoginForm callbackUrl={callbackUrl} />;
|
||||||
|
}
|
||||||
130
src/app/page.tsx
130
src/app/page.tsx
@@ -1,103 +1,41 @@
|
|||||||
import Image from "next/image";
|
import { ActionCenter } from "@/components/ActionCenter";
|
||||||
|
import { DonateSection } from "@/components/DonateSection";
|
||||||
|
import { Hero } from "@/components/Hero";
|
||||||
|
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 { SupporterFeed } from "@/components/SupporterFeed";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
const publishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "";
|
||||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
|
|
||||||
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={180}
|
|
||||||
height={38}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
|
||||||
<li className="mb-2 tracking-[-.01em]">
|
|
||||||
Get started by editing{" "}
|
|
||||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
|
|
||||||
src/app/page.tsx
|
|
||||||
</code>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li className="tracking-[-.01em]">
|
|
||||||
Save and see your changes instantly.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
|
|
||||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
return (
|
||||||
<a
|
<main>
|
||||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
|
<Hero />
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<SupporterFeed />
|
||||||
target="_blank"
|
<ProgressSection />
|
||||||
rel="noopener noreferrer"
|
<ImpactPlanner />
|
||||||
>
|
<ActionCenter />
|
||||||
<Image
|
<OppositionSection />
|
||||||
className="dark:invert"
|
<section className="py-16">
|
||||||
src="/vercel.svg"
|
<div className="mx-auto mb-12 max-w-6xl px-4 sm:px-6">
|
||||||
alt="Vercel logomark"
|
<p className="text-xs uppercase tracking-[0.32em] text-slate-400">National priorities</p>
|
||||||
width={20}
|
<h2 className="mt-3 text-3xl font-semibold text-white sm:text-4xl">
|
||||||
height={20}
|
Policy lanes rooted in 2026 voter reality
|
||||||
/>
|
</h2>
|
||||||
Deploy now
|
<p className="mt-4 max-w-3xl text-slate-400">
|
||||||
</a>
|
Messaging modules below are data-informed drafts—swap copy without touching core flows by editing{" "}
|
||||||
<a
|
<code className="rounded bg-white/10 px-2 py-0.5 text-sm text-slate-200">content/issues.json</code>.
|
||||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
|
</p>
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Read our docs
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
<IssueGrid />
|
||||||
|
</section>
|
||||||
|
<RewardsPreview />
|
||||||
|
<DonateSection publishableKey={publishableKey} />
|
||||||
|
<SiteFooter />
|
||||||
</main>
|
</main>
|
||||||
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/file.svg"
|
|
||||||
alt="File icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Learn
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/window.svg"
|
|
||||||
alt="Window icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Examples
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
|
||||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
aria-hidden
|
|
||||||
src="/globe.svg"
|
|
||||||
alt="Globe icon"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Go to nextjs.org →
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
89
src/app/raised/page.tsx
Normal file
89
src/app/raised/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
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.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RaisedPage() {
|
||||||
|
const [agg, donorRows] = await Promise.all([
|
||||||
|
prisma.donation.aggregate({
|
||||||
|
_sum: { amountUsdCents: true },
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
prisma.donation.groupBy({
|
||||||
|
by: ["userId"],
|
||||||
|
_count: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const raisedUsd = (agg._sum.amountUsdCents ?? 0) / 100;
|
||||||
|
const donationCount = agg._count;
|
||||||
|
const uniqueDonors = donorRows.length;
|
||||||
|
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">
|
||||||
|
<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="mt-4 text-lg text-slate-400">
|
||||||
|
Sum of successful donations processed through Stripe for this app ({appTitle()}). Updates as webhooks confirm
|
||||||
|
payments.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<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="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"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-10">
|
||||||
|
<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>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-3 overflow-hidden rounded-full border border-white/10 bg-black/40">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-gradient-to-r from-sky-400 via-indigo-400 to-fuchsia-400 transition-[width]"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-center text-xs text-slate-500">{pct}% of demo goal</p>
|
||||||
|
</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="mt-2 leading-relaxed">
|
||||||
|
This total reflects <code className="rounded bg-black/30 px-1">Donation</code> rows created by the Stripe webhook
|
||||||
|
only—only processed charges count. Configure committee reporting separately for compliance.
|
||||||
|
</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">
|
||||||
|
Donate
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/app/register/page.tsx
Normal file
90
src/app/register/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
src/app/wallet/WalletActions.tsx
Normal file
208
src/app/wallet/WalletActions.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { PrizeSku, Raffle } from "@prisma/client";
|
||||||
|
import { usdValueOfBlwCredits } from "@/lib/exchange";
|
||||||
|
import { signOut } from "next-auth/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
initialBalance: number;
|
||||||
|
infiniteCredits?: boolean;
|
||||||
|
creditName: string;
|
||||||
|
prizes: PrizeSku[];
|
||||||
|
raffles: Raffle[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WalletActions({
|
||||||
|
initialBalance,
|
||||||
|
infiniteCredits: initialInfinite,
|
||||||
|
creditName,
|
||||||
|
prizes,
|
||||||
|
raffles,
|
||||||
|
}: Props) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [balance, setBalance] = useState(initialBalance);
|
||||||
|
const [infiniteCredits, setInfiniteCredits] = useState(!!initialInfinite);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [blwUsd, setBlwUsd] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (infiniteCredits) return;
|
||||||
|
let alive = true;
|
||||||
|
const tick = 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 {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = setInterval(tick, 15_000);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [infiniteCredits]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
const res = await fetch("/api/rewards/redeem", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ slug }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setBusy(null);
|
||||||
|
if (!res.ok) {
|
||||||
|
setMessage(data.error ?? "Could not redeem");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMessage("Redeemed — fulfillment details are stubbed for now.");
|
||||||
|
await refreshBalance();
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const raffle = async (slug: string, tickets: number) => {
|
||||||
|
setBusy(`raffle:${slug}`);
|
||||||
|
setMessage(null);
|
||||||
|
const res = await fetch("/api/rewards/raffle", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ slug, tickets }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
setBusy(null);
|
||||||
|
if (!res.ok) {
|
||||||
|
setMessage(data.error ?? "Could not enter raffle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMessage(`Entered raffle — ${data.tickets} ticket(s).`);
|
||||||
|
await refreshBalance();
|
||||||
|
router.refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-10">
|
||||||
|
<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>
|
||||||
|
<p className="mt-2 text-4xl font-semibold text-white">
|
||||||
|
{infiniteCredits ? (
|
||||||
|
<>
|
||||||
|
<span className="tabular-nums">∞</span>{" "}
|
||||||
|
<span className="text-lg font-normal text-slate-400">{creditName}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{balance.toLocaleString()}{" "}
|
||||||
|
<span className="text-lg font-normal text-slate-400">{creditName}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{!infiniteCredits && portfolioUsd !== null && blwUsd !== null ? (
|
||||||
|
<p className="mt-3 text-sm text-slate-400">
|
||||||
|
Mock mark‑to‑market:{" "}
|
||||||
|
<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>
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{infiniteCredits ? (
|
||||||
|
<p className="mt-3 text-sm text-amber-200/90">Admin QA mode — portfolio index hidden.</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
) : 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 perks—swap 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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/app/wallet/page.tsx
Normal file
50
src/app/wallet/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
import { isAdminRole } from "@/lib/admin";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { WalletActions } from "./WalletActions";
|
||||||
|
|
||||||
|
export default async function WalletPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
redirect("/login?callbackUrl=/wallet");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
const [dbUser, wallet, prizes, raffles] = await Promise.all([
|
||||||
|
prisma.user.findUnique({ where: { id: userId }, select: { role: true } }),
|
||||||
|
prisma.wallet.findUnique({ where: { userId } }),
|
||||||
|
prisma.prizeSku.findMany({ orderBy: { costCredits: "asc" } }),
|
||||||
|
prisma.raffle.findMany({ orderBy: { endsAt: "asc" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const admin = isAdminRole(dbUser?.role ?? session.user.role);
|
||||||
|
const creditName = process.env.PUBLIC_CREDIT_NAME ?? "BLW";
|
||||||
|
|
||||||
|
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
|
||||||
|
ledger—swap 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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/auth.ts
Normal file
58
src/auth.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
const credentialsSchema = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||||
|
trustHost: true,
|
||||||
|
session: { strategy: "jwt", maxAge: 30 * 24 * 60 * 60 },
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
name: "Credentials",
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
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 } });
|
||||||
|
if (!user?.passwordHash) return null;
|
||||||
|
|
||||||
|
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||||
|
if (!ok) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name ?? undefined,
|
||||||
|
role: user.role,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
callbacks: {
|
||||||
|
jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.id = user.id;
|
||||||
|
token.role = (user as { role: string }).role;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
session({ session, token }) {
|
||||||
|
if (session.user) {
|
||||||
|
session.user.id = (token.id as string) ?? (token.sub as string);
|
||||||
|
session.user.role = (token.role as "USER" | "ADMIN") ?? "USER";
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
87
src/components/ActionCenter.tsx
Normal file
87
src/components/ActionCenter.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const actions = [
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Spend credits",
|
||||||
|
eyebrow: "Rewards",
|
||||||
|
body: "Redeem digital perks or enter raffles from the wallet once donations settle.",
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Bring the receipts",
|
||||||
|
eyebrow: "Trust",
|
||||||
|
body: "Point donors to aggregate totals on /raised, wallet ledger behavior, and compliance stubs before asking again.",
|
||||||
|
href: "/raised",
|
||||||
|
cta: "Show the loop",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
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>
|
||||||
|
</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="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")}
|
||||||
|
</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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/DonateSection.tsx
Normal file
47
src/components/DonateSection.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { DonationCheckout } from "./DonationCheckout";
|
||||||
|
import { MockExchangeTicker } from "./MockExchangeTicker";
|
||||||
|
|
||||||
|
export function DonateSection({ publishableKey }: { publishableKey: string }) {
|
||||||
|
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.
|
||||||
|
</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 vice‑versa).
|
||||||
|
</p>
|
||||||
|
<div className="mt-8 grid gap-3 sm:grid-cols-3">
|
||||||
|
{[
|
||||||
|
["1", "Choose a tier"],
|
||||||
|
["2", "Lock BLW rate"],
|
||||||
|
["3", "Unlock wallet perks"],
|
||||||
|
].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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-8">
|
||||||
|
<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 server‑confirmed 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.
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
280
src/components/DonationCheckout.tsx
Normal file
280
src/components/DonationCheckout.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ALLOWED_DONATION_USD_CENTS, BLW_DISPLAY_NAME, BLW_TICKER } from "@/lib/exchange";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { loadStripe } from "@stripe/stripe-js";
|
||||||
|
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
function InnerCheckout({
|
||||||
|
onSucceeded,
|
||||||
|
}: {
|
||||||
|
onSucceeded: () => void;
|
||||||
|
}) {
|
||||||
|
const stripe = useStripe();
|
||||||
|
const elements = useElements();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handle = async () => {
|
||||||
|
if (!stripe || !elements) return;
|
||||||
|
setBusy(true);
|
||||||
|
setMessage(null);
|
||||||
|
const { error } = await stripe.confirmPayment({
|
||||||
|
elements,
|
||||||
|
confirmParams: {
|
||||||
|
return_url: typeof window !== "undefined" ? `${window.location.origin}/wallet` : undefined,
|
||||||
|
},
|
||||||
|
redirect: "if_required",
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
setMessage(error.message ?? "Payment failed");
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSucceeded();
|
||||||
|
setBusy(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PaymentElement />
|
||||||
|
{message ? <p className="text-sm text-rose-300">{message}</p> : null}
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
disabled={busy || !stripe}
|
||||||
|
onClick={handle}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{busy ? "Processing…" : "Complete donation"}
|
||||||
|
</motion.button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExchangePreview = {
|
||||||
|
blwUsd: number;
|
||||||
|
blwPerUsd: number;
|
||||||
|
creditsPreview: number;
|
||||||
|
tierUsdCents: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DonationCheckout({ publishableKey }: { publishableKey: string }) {
|
||||||
|
const { data: session, status } = useSession();
|
||||||
|
const [tierCents, setTierCents] = useState<number>(1000);
|
||||||
|
const [spot, setSpot] = useState<{ blwUsd: number; blwPerUsd: number } | null>(null);
|
||||||
|
const [clientSecret, setClientSecret] = useState<string | null>(null);
|
||||||
|
const [locked, setLocked] = useState<ExchangePreview | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loadingIntent, setLoadingIntent] = useState(false);
|
||||||
|
const [stripeBlockReason, setStripeBlockReason] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const stripePromise = useMemo(() => {
|
||||||
|
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("Live Stripe publishable keys require HTTPS. Use Stripe test keys for local HTTP demos.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStripeBlockReason(null);
|
||||||
|
}, [publishableKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/exchange/rate", { cache: "no-store" });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const j = await res.json();
|
||||||
|
if (alive) setSpot({ blwUsd: j.blwUsd, blwPerUsd: j.blwPerUsd });
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = setInterval(tick, 15_000);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setClientSecret(null);
|
||||||
|
setLocked(null);
|
||||||
|
}, [tierCents]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startIntent = async () => {
|
||||||
|
setLoadingIntent(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/stripe/create-payment-intent", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ amountUsdCents: tierCents }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
setError("Your session expired — please sign in again.");
|
||||||
|
} else {
|
||||||
|
setError(data.error ?? "Could not start payment");
|
||||||
|
}
|
||||||
|
setLoadingIntent(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setClientSecret(data.clientSecret);
|
||||||
|
if (data.exchange) {
|
||||||
|
setLocked(data.exchange as ExchangePreview);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError("Network error");
|
||||||
|
}
|
||||||
|
setLoadingIntent(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSucceeded = async () => {
|
||||||
|
setClientSecret(null);
|
||||||
|
setLocked(null);
|
||||||
|
await fetch("/api/wallet", { cache: "no-store" });
|
||||||
|
};
|
||||||
|
|
||||||
|
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.
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const spotBlwPreview =
|
||||||
|
spot && !locked ? Math.floor((tierCents / 100) / spot.blwUsd) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</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's higher, you receive fewer {BLW_TICKER} for the same donation. The exact
|
||||||
|
spot is <strong className="text-white">frozen</strong> when you tap "Continue to secure checkout".
|
||||||
|
</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
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!clientSecret ? (
|
||||||
|
<motion.button
|
||||||
|
type="button"
|
||||||
|
whileTap={{ scale: 0.98 }}
|
||||||
|
disabled={loadingIntent}
|
||||||
|
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"}
|
||||||
|
</motion.button>
|
||||||
|
) : stripePromise ? (
|
||||||
|
<Elements
|
||||||
|
stripe={stripePromise}
|
||||||
|
options={{
|
||||||
|
clientSecret,
|
||||||
|
appearance: { theme: "night", variables: { borderRadius: "12px" } },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<InnerCheckout onSucceeded={onSucceeded} />
|
||||||
|
</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.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
src/components/Hero.tsx
Normal file
113
src/components/Hero.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { MockExchangeTicker } from "./MockExchangeTicker";
|
||||||
|
import { ParticleField } from "./ParticleField";
|
||||||
|
|
||||||
|
export function Hero() {
|
||||||
|
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%)]" />
|
||||||
|
<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 }}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
</motion.h1>
|
||||||
|
<motion.p
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.15, duration: 0.65 }}
|
||||||
|
className="text-lg text-slate-300/95"
|
||||||
|
>
|
||||||
|
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.
|
||||||
|
</motion.p>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.22, duration: 0.65 }}
|
||||||
|
className="flex flex-wrap 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"
|
||||||
|
>
|
||||||
|
Create supporter login
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
Fuel the field program
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
Plan my impact
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
<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">
|
||||||
|
<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" />
|
||||||
|
Micro‑volunteer asks routed locally—not 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>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
src/components/ImpactPlanner.tsx
Normal file
144
src/components/ImpactPlanner.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
const presetAmounts = [5, 10, 20, 100];
|
||||||
|
|
||||||
|
const missions = [
|
||||||
|
{
|
||||||
|
id: "field",
|
||||||
|
label: "Field sprint",
|
||||||
|
multiplier: 1.2,
|
||||||
|
description: "Funds doors, phones, rides, and local volunteer materials.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "digital",
|
||||||
|
label: "Digital rapid response",
|
||||||
|
multiplier: 1.05,
|
||||||
|
description: "Boosts explainers, creator clips, texting, and persuasion follow-up.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "protection",
|
||||||
|
label: "Democracy defense",
|
||||||
|
multiplier: 0.9,
|
||||||
|
description: "Supports poll access, voter assistance, legal readiness, and watchdog work.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ImpactPlanner() {
|
||||||
|
const [amount, setAmount] = useState(20);
|
||||||
|
const [volunteerHours, setVolunteerHours] = useState(3);
|
||||||
|
const [missionId, setMissionId] = useState(missions[0].id);
|
||||||
|
|
||||||
|
const selectedMission = missions.find((mission) => mission.id === missionId) ?? missions[0];
|
||||||
|
|
||||||
|
const impact = useMemo(() => {
|
||||||
|
const intensity = selectedMission.multiplier;
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}, [amount, selectedMission.multiplier, volunteerHours]);
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<div className="mt-8 grid gap-3 sm:grid-cols-3">
|
||||||
|
{missions.map((mission) => (
|
||||||
|
<button
|
||||||
|
key={mission.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMissionId(mission.id)}
|
||||||
|
className={`rounded-2xl border p-4 text-left transition ${
|
||||||
|
missionId === mission.id
|
||||||
|
? "border-sky-300/60 bg-sky-400/15 text-white shadow-[0_0_40px_rgba(56,189,248,0.14)]"
|
||||||
|
: "border-white/10 bg-white/5 text-slate-300 hover:bg-white/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold">{mission.label}</span>
|
||||||
|
<span className="mt-2 block text-xs leading-relaxed text-slate-400">{mission.description}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 18 }}
|
||||||
|
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)]"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<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
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex flex-wrap gap-2">
|
||||||
|
{presetAmounts.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAmount(preset)}
|
||||||
|
className={`rounded-full px-4 py-2 text-sm font-semibold ${
|
||||||
|
amount === preset ? "bg-white text-slate-950" : "bg-white/10 text-white hover:bg-white/15"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
${preset}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="mt-6 block text-sm font-medium text-slate-200" htmlFor="volunteer-hours">
|
||||||
|
Add volunteer hours: <span className="text-white">{volunteerHours}</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="volunteer-hours"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="12"
|
||||||
|
value={volunteerHours}
|
||||||
|
onChange={(event) => setVolunteerHours(Number(event.target.value))}
|
||||||
|
className="mt-3 w-full accent-sky-400"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-4 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} />
|
||||||
|
<ImpactMetric label="Mission focus" value={selectedMission.label} text />
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
src/components/IssueGrid.tsx
Normal file
26
src/components/IssueGrid.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import issues from "../../content/issues.json";
|
||||||
|
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">
|
||||||
|
{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)]`}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</motion.article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
src/components/MockExchangeTicker.tsx
Normal file
80
src/components/MockExchangeTicker.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { BLW_TICKER } from "@/lib/exchange";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type RatePayload = {
|
||||||
|
symbol: string;
|
||||||
|
blwUsd: number;
|
||||||
|
blwPerUsd: number;
|
||||||
|
updatedAt: number;
|
||||||
|
note: string;
|
||||||
|
sparkline: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MockExchangeTicker() {
|
||||||
|
const [data, setData] = useState<RatePayload | 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("rate fetch failed");
|
||||||
|
const json = (await res.json()) as RatePayload;
|
||||||
|
if (alive) {
|
||||||
|
setData(json);
|
||||||
|
setErr(null);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (alive) setErr("Index unavailable");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const id = setInterval(load, 15_000);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (err || !data) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-slate-400">
|
||||||
|
{err ?? `Loading mock ${BLW_TICKER} index…`}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const min = Math.min(...data.sparkline);
|
||||||
|
const max = Math.max(...data.sparkline);
|
||||||
|
const norm = (v: number) => (max === min ? 0.5 : (v - min) / (max - min));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-sky-500/25 bg-gradient-to-br from-sky-500/10 to-indigo-900/30 px-4 py-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.28em] text-sky-200/80">
|
||||||
|
{data.symbol} · Blue Wave · mock spot
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 font-mono text-2xl font-semibold text-white">${data.blwUsd.toFixed(4)} USD / BLW</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-300">
|
||||||
|
≈ {data.blwPerUsd.toFixed(2)} BLW per $1 USD <span className="text-slate-500">(index moves over time)</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-14 w-40 items-end gap-px">
|
||||||
|
{data.sparkline.map((v, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex-1 rounded-t bg-gradient-to-t from-sky-600/80 to-cyan-300/90"
|
||||||
|
style={{ height: `${12 + norm(v) * 44}px` }}
|
||||||
|
title={`${v.toFixed(4)}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-xs leading-relaxed text-slate-400">{data.note}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/components/OppositionSection.tsx
Normal file
51
src/components/OppositionSection.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
export function OppositionSection() {
|
||||||
|
return (
|
||||||
|
<section id="accountability" className="border-b border-white/10 py-16">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 16 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true }}
|
||||||
|
transition={{ duration: 0.55 }}
|
||||||
|
className="relative overflow-hidden rounded-[32px] border border-rose-500/25 bg-gradient-to-br from-rose-500/15 via-slate-900/80 to-indigo-900/60 p-8 sm:p-12"
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(248,113,113,0.28),_transparent_55%)]" />
|
||||||
|
<p className="text-xs uppercase tracking-[0.32em] text-rose-100/80">Accountability frame</p>
|
||||||
|
<h2 className="mt-4 max-w-3xl text-3xl font-semibold text-white sm:text-4xl">
|
||||||
|
A professional alternative to MAGA chaos—not a mirror of it.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-6 max-w-3xl text-base leading-relaxed text-slate-100/90">
|
||||||
|
The current Republican leadership—including Donald Trump—has normalized corruption-as-branding,
|
||||||
|
weaponized public office against opponents, and treated democratic guardrails as inconveniences.
|
||||||
|
This platform exists to fund organizing that restores transparency, protects elections, and proves
|
||||||
|
that policy victories beat performative cruelty.
|
||||||
|
</p>
|
||||||
|
<ul className="mt-8 grid gap-4 text-sm text-slate-100/90 sm:grid-cols-2">
|
||||||
|
<li className="rounded-2xl border border-white/10 bg-black/30 p-4">
|
||||||
|
<span className="font-semibold text-white">Institutions over impunity:</span>{" "}
|
||||||
|
independent oversight, ethics enforcement, and a politics that punishes self-dealing—not rewards
|
||||||
|
it.
|
||||||
|
</li>
|
||||||
|
<li className="rounded-2xl border border-white/10 bg-black/30 p-4">
|
||||||
|
<span className="font-semibold text-white">Rights over regression:</span>{" "}
|
||||||
|
defending voting access, reproductive healthcare autonomy, and civil liberties from partisan
|
||||||
|
capture.
|
||||||
|
</li>
|
||||||
|
<li className="rounded-2xl border border-white/10 bg-black/30 p-4">
|
||||||
|
<span className="font-semibold text-white">Evidence over conspiracy:</span>{" "}
|
||||||
|
climate action, public health readiness, and tech accountability grounded in science and law.
|
||||||
|
</li>
|
||||||
|
<li className="rounded-2xl border border-white/10 bg-black/30 p-4">
|
||||||
|
<span className="font-semibold text-white">Solidarity over scapegoating:</span>{" "}
|
||||||
|
economic fairness that lifts workers without feeding the politics of division.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/components/ParticleField.tsx
Normal file
76
src/components/ParticleField.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
type Particle = { x: number; y: number; vx: number; vy: number; r: number; a: number };
|
||||||
|
|
||||||
|
export function ParticleField() {
|
||||||
|
const ref = useRef<HTMLCanvasElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = ref.current;
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
const reduced =
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
if (reduced) return;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) return;
|
||||||
|
|
||||||
|
let raf = 0;
|
||||||
|
let particles: Particle[] = [];
|
||||||
|
|
||||||
|
const resize = () => {
|
||||||
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||||
|
const { clientWidth, clientHeight } = canvas;
|
||||||
|
canvas.width = clientWidth * dpr;
|
||||||
|
canvas.height = clientHeight * dpr;
|
||||||
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||||
|
|
||||||
|
const count = Math.floor((clientWidth * clientHeight) / 22000);
|
||||||
|
particles = Array.from({ length: Math.max(24, Math.min(count, 120)) }, () => ({
|
||||||
|
x: Math.random() * clientWidth,
|
||||||
|
y: Math.random() * clientHeight,
|
||||||
|
vx: (Math.random() - 0.5) * 0.35,
|
||||||
|
vy: (Math.random() - 0.5) * 0.35,
|
||||||
|
r: Math.random() * 1.6 + 0.4,
|
||||||
|
a: Math.random() * 0.45 + 0.12,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const { clientWidth: w, clientHeight: h } = canvas;
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
for (const p of particles) {
|
||||||
|
p.x += p.vx;
|
||||||
|
p.y += p.vy;
|
||||||
|
if (p.x < 0 || p.x > w) p.vx *= -1;
|
||||||
|
if (p.y < 0 || p.y > h) p.vy *= -1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.fillStyle = `rgba(56,189,248,${p.a})`;
|
||||||
|
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
|
||||||
|
resize();
|
||||||
|
window.addEventListener("resize", resize);
|
||||||
|
raf = requestAnimationFrame(tick);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
window.removeEventListener("resize", resize);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<canvas
|
||||||
|
ref={ref}
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute inset-0 h-full w-full opacity-70"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/components/ProgressSection.tsx
Normal file
84
src/components/ProgressSection.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion, useSpring, useTransform } from "framer-motion";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type Stats = {
|
||||||
|
raisedUsd: number;
|
||||||
|
goalUsd: number;
|
||||||
|
donationCount: number;
|
||||||
|
uniqueDonors: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProgressSection() {
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = async () => {
|
||||||
|
const res = await fetch("/api/public/stats", { cache: "no-store" });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = await res.json();
|
||||||
|
if (alive) setStats(data);
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const id = setInterval(load, 30000);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goal = stats?.goalUsd ?? 250000;
|
||||||
|
const pct = stats ? Math.min(100, Math.round((stats.raisedUsd / goal) * 100)) : 0;
|
||||||
|
const spring = useSpring(pct, { stiffness: 120, damping: 20 });
|
||||||
|
const width = useTransform(spring, (v) => `${v}%`);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
spring.set(pct);
|
||||||
|
}, [pct, spring]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="border-b border-white/10 bg-gradient-to-b from-[#030712] to-[#050b1f] py-16">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||||
|
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.32em] text-slate-400">Grassroots meter</p>
|
||||||
|
<h2 className="mt-3 text-3xl font-semibold text-white sm:text-4xl">Momentum you can see</h2>
|
||||||
|
<p className="mt-3 max-w-xl text-slate-400">
|
||||||
|
Every dollar is a choice about whose voice counts. We publish aggregate totals so supporters can
|
||||||
|
feel the collective lift—without gamifying human dignity.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4 text-sm text-slate-200 sm:grid-cols-3">
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-slate-400">Raised</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-white">
|
||||||
|
{stats ? `$${stats.raisedUsd.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-slate-400">Donations</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-white">{stats?.donationCount ?? "—"}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-slate-400">Supporters</p>
|
||||||
|
<p className="mt-2 text-2xl font-semibold text-white">{stats?.uniqueDonors ?? "—"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-10">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>$0</span>
|
||||||
|
<span>
|
||||||
|
Goal ${stats?.goalUsd?.toLocaleString() ?? "—"} (demo target via PUBLIC_CAMPAIGN_GOAL_USD)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 h-4 overflow-hidden rounded-full border border-white/10 bg-black/40">
|
||||||
|
<motion.div style={{ width }} className="h-full rounded-full bg-gradient-to-r from-sky-400 via-indigo-400 to-fuchsia-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
src/components/Providers.tsx
Normal file
7
src/components/Providers.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
|
|
||||||
|
export function Providers({ children }: { children: React.ReactNode }) {
|
||||||
|
return <SessionProvider>{children}</SessionProvider>;
|
||||||
|
}
|
||||||
53
src/components/RewardsPreview.tsx
Normal file
53
src/components/RewardsPreview.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
const perks = [
|
||||||
|
{
|
||||||
|
title: "Digital yard sign pack",
|
||||||
|
cost: "15 BLW",
|
||||||
|
body: "Printable, shareable visibility assets for people who want to do more than click donate.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Supporter badge",
|
||||||
|
cost: "8 BLW",
|
||||||
|
body: "Profile flair for the early crew, useful for future leaderboards and volunteer recognition.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Grassroots gear raffle",
|
||||||
|
cost: "5 BLW / ticket",
|
||||||
|
body: "A lightweight proof of the rewards engine: spend credits, record the ledger, refresh the wallet.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function RewardsPreview() {
|
||||||
|
return (
|
||||||
|
<section className="border-b border-white/10 bg-gradient-to-b from-[#030712] to-[#08111f] py-20">
|
||||||
|
<div className="mx-auto grid max-w-6xl gap-8 px-4 sm:px-6 lg:grid-cols-[0.85fr_1.15fr]">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.32em] text-fuchsia-200/75">Supporter economy</p>
|
||||||
|
<h2 className="mt-4 text-3xl font-semibold text-white sm:text-4xl">
|
||||||
|
Give people a reason to come back after the receipt.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-slate-400">
|
||||||
|
Donations create BLW (Blue Wave) credits only after Stripe confirms payment. The wallet turns that proof into perks,
|
||||||
|
raffles, and future campaign experiences.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/wallet"
|
||||||
|
className="mt-8 inline-flex rounded-full bg-gradient-to-r from-fuchsia-500 to-sky-500 px-6 py-3 text-sm font-semibold text-white shadow-xl shadow-fuchsia-500/20"
|
||||||
|
>
|
||||||
|
Explore the wallet
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{perks.map((perk) => (
|
||||||
|
<article key={perk.title} className="rounded-3xl border border-white/10 bg-black/25 p-5">
|
||||||
|
<p className="text-xs uppercase tracking-[0.22em] text-fuchsia-200/80">{perk.cost}</p>
|
||||||
|
<h3 className="mt-4 text-lg font-semibold text-white">{perk.title}</h3>
|
||||||
|
<p className="mt-3 text-sm leading-relaxed text-slate-400">{perk.body}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/components/SiteFooter.tsx
Normal file
22
src/components/SiteFooter.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export function SiteFooter() {
|
||||||
|
const disclaimer =
|
||||||
|
process.env.NEXT_PUBLIC_DISCLAIMER_TEXT ??
|
||||||
|
process.env.DISCLAIMER_TEXT ??
|
||||||
|
"This application is a technical demonstration for local deployment. It is not legal or FEC advice. Configure committee disclosures before accepting live contributions.";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-white/10 bg-[#020617] py-12">
|
||||||
|
<div className="mx-auto flex max-w-6xl flex-col gap-6 px-4 text-sm text-slate-500 sm:px-6">
|
||||||
|
<p className="leading-relaxed">{disclaimer}</p>
|
||||||
|
<p className="text-xs text-slate-600">
|
||||||
|
Committee placeholder:{" "}
|
||||||
|
<span className="text-slate-400">
|
||||||
|
{process.env.NEXT_PUBLIC_COMMITTEE_LEGAL_NAME_PLACEHOLDER ??
|
||||||
|
process.env.COMMITTEE_LEGAL_NAME_PLACEHOLDER ??
|
||||||
|
"Configure COMMITTEE_LEGAL_NAME_PLACEHOLDER"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/components/SiteNav.tsx
Normal file
61
src/components/SiteNav.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { appTitle } from "@/lib/public-env";
|
||||||
|
|
||||||
|
export async function SiteNav() {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="sticky top-0 z-50 border-b border-white/10 bg-[#030712]/80 backdrop-blur-xl">
|
||||||
|
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4 sm:px-6">
|
||||||
|
<Link href="/" className="group flex items-baseline gap-2">
|
||||||
|
<span className="bg-gradient-to-r from-sky-300 via-indigo-300 to-fuchsia-300 bg-clip-text text-xl font-semibold tracking-tight text-transparent">
|
||||||
|
{appTitle()}
|
||||||
|
</span>
|
||||||
|
<span className="hidden text-xs uppercase tracking-[0.28em] text-slate-500 sm:inline">
|
||||||
|
Grassroots fund
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<nav className="flex items-center gap-3 text-sm text-slate-200">
|
||||||
|
<Link className="hidden rounded-full px-3 py-1.5 hover:bg-white/5 md:inline-flex" href="/#impact">
|
||||||
|
Impact
|
||||||
|
</Link>
|
||||||
|
<Link className="hidden rounded-full px-3 py-1.5 hover:bg-white/5 md:inline-flex" href="/#actions">
|
||||||
|
Actions
|
||||||
|
</Link>
|
||||||
|
<Link className="rounded-full px-3 py-1.5 hover:bg-white/5" href="/raised">
|
||||||
|
Raised
|
||||||
|
</Link>
|
||||||
|
<Link className="rounded-full px-3 py-1.5 hover:bg-white/5" href="/#priorities">
|
||||||
|
Priorities
|
||||||
|
</Link>
|
||||||
|
<Link className="rounded-full px-3 py-1.5 hover:bg-white/5" href="/#donate">
|
||||||
|
Donate
|
||||||
|
</Link>
|
||||||
|
{session?.user ? (
|
||||||
|
<>
|
||||||
|
<Link
|
||||||
|
className="rounded-full bg-white/10 px-4 py-2 font-medium text-white hover:bg-white/15"
|
||||||
|
href="/wallet"
|
||||||
|
>
|
||||||
|
Wallet
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link className="rounded-full px-3 py-1.5 hover:bg-white/5" href="/login">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className="rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-4 py-2 font-semibold text-white shadow-lg shadow-sky-500/25"
|
||||||
|
href="/register"
|
||||||
|
>
|
||||||
|
Join
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
src/components/SupporterFeed.tsx
Normal file
39
src/components/SupporterFeed.tsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
|
||||||
|
const feed = [
|
||||||
|
"A nurse in Phoenix just turned $20 into a five-ticket raffle push.",
|
||||||
|
"A student organizer in Madison unlocked the digital yard sign pack.",
|
||||||
|
"A retired teacher in Atlanta recruited four first-time monthly donors.",
|
||||||
|
"A union steward in Detroit paired a $50 gift with a Saturday canvass.",
|
||||||
|
"A parent in Raleigh sent the healthcare card to a neighborhood chat.",
|
||||||
|
"A volunteer in Las Vegas used BLW credits to enter the gear drop.",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SupporterFeed() {
|
||||||
|
return (
|
||||||
|
<section className="overflow-hidden border-b border-white/10 bg-[#020617] py-6">
|
||||||
|
<div className="mx-auto flex max-w-6xl items-center gap-4 px-4 sm:px-6">
|
||||||
|
<p className="shrink-0 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs uppercase tracking-[0.22em] text-sky-200">
|
||||||
|
Live spark
|
||||||
|
</p>
|
||||||
|
<div className="relative min-w-0 flex-1 overflow-hidden">
|
||||||
|
<motion.div
|
||||||
|
className="flex w-max gap-8 text-sm text-slate-300"
|
||||||
|
animate={{ x: ["0%", "-50%"] }}
|
||||||
|
transition={{ duration: 38, repeat: Infinity, ease: "linear" }}
|
||||||
|
>
|
||||||
|
{[...feed, ...feed].map((item, index) => (
|
||||||
|
<span key={`${item}-${index}`} className="whitespace-nowrap">
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 left-0 w-12 bg-gradient-to-r from-[#020617] to-transparent" />
|
||||||
|
<div className="pointer-events-none absolute inset-y-0 right-0 w-12 bg-gradient-to-l from-[#020617] to-transparent" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/lib/admin.ts
Normal file
6
src/lib/admin.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export function isAdminRole(role: string | undefined): boolean {
|
||||||
|
return role === "ADMIN";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display / API: treat admins as having unlimited credits for local QA (balance is not decremented on spend). */
|
||||||
|
export const ADMIN_WALLET_DISPLAY = 2_147_483_647;
|
||||||
52
src/lib/exchange.ts
Normal file
52
src/lib/exchange.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Self-contained mock "Blue Wave" (BLW) spot index for UX only — not a blockchain asset.
|
||||||
|
* USD donation → BLW credits use the snapshot rate from PaymentIntent creation (server-authoritative).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const BLW_DISPLAY_NAME = "Blue Wave";
|
||||||
|
export const BLW_TICKER = "BLW";
|
||||||
|
|
||||||
|
/** Allowed Stripe amounts in USD cents — keep fundraising tiers simple. */
|
||||||
|
export const ALLOWED_DONATION_USD_CENTS = [500, 1000, 2000, 10000] as const;
|
||||||
|
|
||||||
|
export type AllowedTierCents = (typeof ALLOWED_DONATION_USD_CENTS)[number];
|
||||||
|
|
||||||
|
export function isAllowedDonationTier(cents: number): cents is AllowedTierCents {
|
||||||
|
return (ALLOWED_DONATION_USD_CENTS as readonly number[]).includes(cents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Synthetic USD price of 1 BLW (oscillates smoothly over ~45 min). */
|
||||||
|
export function blwUsdAt(nowMs = Date.now()): number {
|
||||||
|
const BASE = 0.1;
|
||||||
|
const AMP = 0.035;
|
||||||
|
const PERIOD_MS = 45 * 60 * 1000;
|
||||||
|
const raw = BASE + AMP * Math.sin((nowMs / PERIOD_MS) * 2 * Math.PI);
|
||||||
|
return Math.round(raw * 1_000_000) / 1_000_000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BLW credits minted for a USD donation at the given BLW/USD spot (integer BLW units). */
|
||||||
|
export function blwCreditsForUsdCents(usdCents: number, blwUsd: number): number {
|
||||||
|
const usd = usdCents / 100;
|
||||||
|
if (blwUsd <= 0 || usd <= 0) return 0;
|
||||||
|
return Math.max(0, Math.floor(usd / blwUsd));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Approximate USD value of holdings given BLW balance (same integer as wallet credits) at current index. */
|
||||||
|
export function usdValueOfBlwCredits(credits: number, blwUsd: number): number {
|
||||||
|
if (credits <= 0 || blwUsd <= 0) return 0;
|
||||||
|
return Math.round(credits * blwUsd * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sample points for a tiny sparkline (client-only visualization). */
|
||||||
|
export function blwIndexSamples(
|
||||||
|
pointCount: number,
|
||||||
|
nowMs = Date.now(),
|
||||||
|
stepMs = 60_000,
|
||||||
|
): { t: number; blwUsd: number }[] {
|
||||||
|
const out: { t: number; blwUsd: number }[] = [];
|
||||||
|
for (let i = pointCount - 1; i >= 0; i--) {
|
||||||
|
const t = nowMs - i * stepMs;
|
||||||
|
out.push({ t, blwUsd: blwUsdAt(t) });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
35
src/lib/prisma.ts
Normal file
35
src/lib/prisma.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
if (!connectionString) {
|
||||||
|
throw new Error("DATABASE_URL is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
pgPool: Pool | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pool =
|
||||||
|
globalForPrisma.pgPool ??
|
||||||
|
new Pool({
|
||||||
|
connectionString,
|
||||||
|
max: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
globalForPrisma.pgPool = pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adapter = new PrismaPg(pool);
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
adapter,
|
||||||
|
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||||
3
src/lib/public-env.ts
Normal file
3
src/lib/public-env.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function appTitle(): string {
|
||||||
|
return process.env.NEXT_PUBLIC_APP_NAME ?? process.env.PUBLIC_APP_NAME ?? "Democracy Rising";
|
||||||
|
}
|
||||||
16
src/lib/stripe.ts
Normal file
16
src/lib/stripe.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Stripe from "stripe";
|
||||||
|
|
||||||
|
/** SDK requires a string; real charges require a valid key from env (see create-payment-intent guard). */
|
||||||
|
const stripeSecretKey =
|
||||||
|
process.env.STRIPE_SECRET_KEY?.trim() ||
|
||||||
|
"sk_test_disabled_configure_STRIPE_SECRET_KEY";
|
||||||
|
|
||||||
|
/** Pin Stripe API version via SDK default; set STRIPE_API_VERSION only when upgrading SDK intentionally. */
|
||||||
|
export const stripe = new Stripe(stripeSecretKey, {
|
||||||
|
typescript: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function creditsFromUsdCents(amountUsdCents: number): number {
|
||||||
|
const ratio = Math.max(1, parseInt(process.env.CREDIT_RATIO_CENTS_PER_USD ?? "100", 10) || 100);
|
||||||
|
return Math.floor(amountUsdCents / ratio);
|
||||||
|
}
|
||||||
15
src/middleware.ts
Normal file
15
src/middleware.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { auth } from "@/auth";
|
||||||
|
|
||||||
|
export default auth((req) => {
|
||||||
|
const path = req.nextUrl.pathname;
|
||||||
|
if (!req.auth && path.startsWith("/wallet")) {
|
||||||
|
const url = req.nextUrl.clone();
|
||||||
|
url.pathname = "/login";
|
||||||
|
url.searchParams.set("callbackUrl", path);
|
||||||
|
return Response.redirect(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/wallet/:path*"],
|
||||||
|
};
|
||||||
14
src/types/next-auth.d.ts
vendored
Normal file
14
src/types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import type { DefaultSession } from "next-auth";
|
||||||
|
|
||||||
|
declare module "next-auth" {
|
||||||
|
interface Session {
|
||||||
|
user: DefaultSession["user"] & { id: string; role: "USER" | "ADMIN" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module "next-auth/jwt" {
|
||||||
|
interface JWT {
|
||||||
|
id?: string;
|
||||||
|
role?: "USER" | "ADMIN";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -11,7 +15,7 @@
|
|||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "react-jsx",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
@@ -19,9 +23,19 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"include": [
|
||||||
"exclude": ["node_modules"]
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user