first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-05-10 19:20:03 +00:00
parent 1050bb39ec
commit a3f08242fe
58 changed files with 5803 additions and 261 deletions

32
.env.example Normal file
View 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
View File

@@ -32,6 +32,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
# vercel
.vercel
@@ -39,3 +40,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
/src/generated/prisma

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
20

View File

@@ -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
cd /root/fundraising-platform
npm install
cp .env.example .env
npm run db:migrate
npm run db:seed
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.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
For local Stripe webhook forwarding:
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
View 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, antipricegouging 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 outofpocket 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 dont decide destiny. We fund classrooms fairly, expand vocational and apprenticeship pathways, and protect students from bookbanning theatrics that substitute culture wars for outcomes.",
"accent": "from-violet-500/25 to-purple-700/20"
},
{
"id": "reproductive",
"title": "Autonomy and evidencebased 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 shouldnt 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

File diff suppressed because it is too large Load Diff

View File

@@ -3,25 +3,50 @@
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"dev": "next dev --turbopack -H 0.0.0.0 -p 8008",
"build": "next build",
"start": "next start",
"lint": "next lint"
"start": "next start -H 0.0.0.0 -p 8008",
"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": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"next": "15.3.2"
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@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": {
"typescript": "^5",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@tailwindcss/postcss": "^4",
"tailwindcss": "^4",
"dotenv": "^17.4.2",
"eslint": "^9",
"eslint-config-next": "15.3.2",
"@eslint/eslintrc": "^3"
"eslint-config-next": "^16.2.6",
"prisma": "^7.8.0",
"tailwindcss": "^4",
"tsx": "^4.21.0",
"typescript": "^5"
}
}

14
prisma.config.ts Normal file
View 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"],
},
});

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

View File

@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('USER', 'ADMIN');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "role" "UserRole" NOT NULL DEFAULT 'USER';

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

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

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

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

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

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

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

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

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

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

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

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

View File

@@ -1,26 +1,38 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
--bg: #030712;
--fg: #e2e8f0;
--muted: #94a3b8;
--accent: #38bdf8;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-background: var(--bg);
--color-foreground: var(--fg);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
html {
scroll-behavior: smooth;
}
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
background: radial-gradient(1200px 600px at 10% -10%, rgba(56, 189, 248, 0.15), transparent),
radial-gradient(900px 500px at 90% 0%, rgba(168, 85, 247, 0.14), transparent), var(--bg);
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;
}

View File

@@ -1,6 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/Providers";
import { SiteNav } from "@/components/SiteNav";
import { appTitle } from "@/lib/public-env";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -13,21 +16,23 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: `${appTitle()} — Grassroots fundraising`,
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,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<Providers>
<SiteNav />
{children}
</Providers>
</body>
</html>
);

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

View File

@@ -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() {
return (
<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>
const publishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "";
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
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"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
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]"
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>
return (
<main>
<Hero />
<SupporterFeed />
<ProgressSection />
<ImpactPlanner />
<ActionCenter />
<OppositionSection />
<section className="py-16">
<div className="mx-auto mb-12 max-w-6xl px-4 sm:px-6">
<p className="text-xs uppercase tracking-[0.32em] text-slate-400">National priorities</p>
<h2 className="mt-3 text-3xl font-semibold text-white sm:text-4xl">
Policy lanes rooted in 2026 voter reality
</h2>
<p className="mt-4 max-w-3xl text-slate-400">
Messaging modules below are data-informed draftsswap copy without touching core flows by editing{" "}
<code className="rounded bg-white/10 px-2 py-0.5 text-sm text-slate-200">content/issues.json</code>.
</p>
</div>
<IssueGrid />
</section>
<RewardsPreview />
<DonateSection publishableKey={publishableKey} />
<SiteFooter />
</main>
<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
View 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
onlyonly 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
View 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>
);
}

View 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 marktomarket:{" "}
<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 perksswap SKUs for real merchandise integrations later.
</p>
<div className="mt-6 grid gap-4 md:grid-cols-2">
{prizes.map((p) => (
<div key={p.id} className="rounded-2xl border border-white/10 bg-black/30 p-5">
<h3 className="text-lg font-semibold text-white">{p.title}</h3>
<p className="mt-2 text-sm text-slate-400">{p.description}</p>
<p className="mt-4 text-sm text-slate-300">
Cost: <span className="font-semibold text-white">{p.costCredits}</span> credits
</p>
<button
type="button"
disabled={busy !== null}
onClick={() => redeem(p.slug)}
className="mt-4 w-full rounded-xl bg-white/10 py-2 text-sm font-semibold text-white hover:bg-white/15 disabled:opacity-40"
>
{busy === `redeem:${p.slug}` ? "Working…" : "Redeem"}
</button>
</div>
))}
</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
View 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
ledgerswap SKUs for production fulfillment when ready.
</p>
{admin ? (
<p className="mt-4 rounded-2xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">
Signed in as <strong className="text-white">ADMIN</strong> unlimited credits for QA (spends do not
debit your wallet).
</p>
) : null}
<div className="mt-10">
<WalletActions
initialBalance={wallet?.balanceCredits ?? 0}
infiniteCredits={admin}
creditName={creditName}
prizes={prizes}
raffles={raffles}
/>
</div>
</div>
);
}

58
src/auth.ts Normal file
View 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;
},
},
});

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

View 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 viceversa).
</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 serverconfirmed credits matter</p>
<p className="mt-2 leading-relaxed">
The browser never mints money. A Stripe webhook confirms the charge, then our ledger adds BLW units once
idempotently using the snapshot stored on the PaymentIntent.
</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>
);
}

View 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&apos;s higher, you receive fewer {BLW_TICKER} for the same donation. The exact
spot is <strong className="text-white">frozen</strong> when you tap &quot;Continue to secure checkout&quot;.
</p>
{spot && !locked ? (
<p className="mt-3 text-sky-200/90">
Live index (not locked yet): ${spot.blwUsd.toFixed(4)} / {BLW_TICKER} ~{spotBlwPreview ?? "—"} {BLW_TICKER} for $
{(tierCents / 100).toFixed(0)}
</p>
) : null}
{locked ? (
<div className="mt-3 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-emerald-100">
<p className="text-xs uppercase tracking-wide text-emerald-300/90">Locked for this checkout</p>
<p className="mt-1 font-mono text-base">
${locked.blwUsd.toFixed(4)} / {BLW_TICKER} · {locked.blwPerUsd.toFixed(2)} {BLW_TICKER} per $1 ·{" "}
<strong>
{locked.creditsPreview} {BLW_TICKER}
</strong>{" "}
if payment succeeds
</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
View 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" />
Microvolunteer asks routed locallynot dumped into a national spam cannon.
</li>
<li className="flex gap-2">
<span className="mt-1 h-2 w-2 rounded-full bg-indigo-400" />
Donations settle through Stripe; BLW unlocks perks without touching card data twice.
</li>
<li className="flex gap-2">
<span className="mt-1 h-2 w-2 rounded-full bg-fuchsia-400" />
Built to extend into raffles, collectibles, and digital membership tiers without rewriting core flows.
</li>
</ul>
</motion.div>
</div>
</section>
);
}

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

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

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

View 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 chaosnot a mirror of it.
</h2>
<p className="mt-6 max-w-3xl text-base leading-relaxed text-slate-100/90">
The current Republican leadershipincluding Donald Trumphas 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-dealingnot 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>
);
}

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

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

View File

@@ -0,0 +1,7 @@
"use client";
import { SessionProvider } from "next-auth/react";
export function Providers({ children }: { children: React.ReactNode }) {
return <SessionProvider>{children}</SessionProvider>;
}

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

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

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

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

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,9 +23,19 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}