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

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`}
>
{children}
<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>
</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>
<IssueGrid />
</section>
<RewardsPreview />
<DonateSection publishableKey={publishableKey} />
<SiteFooter />
</main>
);
}

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