3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
37
src/app/api/exchange/rate/route.ts
Normal file
37
src/app/api/exchange/rate/route.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
BLW_DISPLAY_NAME,
|
||||
BLW_TICKER,
|
||||
blwCreditsForUsdCents,
|
||||
blwIndexSamples,
|
||||
blwUsdAt,
|
||||
} from "@/lib/exchange";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
const now = Date.now();
|
||||
const blwUsd = blwUsdAt(now);
|
||||
const blwPerUsd = 1 / blwUsd;
|
||||
|
||||
const tiers = [500, 1000, 2000, 10000].map((tierCents) => ({
|
||||
tierCents,
|
||||
tierUsd: tierCents / 100,
|
||||
blwCreditsAtSpot: blwCreditsForUsdCents(tierCents, blwUsd),
|
||||
}));
|
||||
|
||||
const sparkline = blwIndexSamples(48, now, 90_000).map((p) => p.blwUsd);
|
||||
|
||||
return NextResponse.json({
|
||||
symbol: BLW_TICKER,
|
||||
name: `${BLW_DISPLAY_NAME} (mock index)`,
|
||||
blwUsd,
|
||||
blwPerUsd,
|
||||
usdPerBlw: blwUsd,
|
||||
updatedAt: now,
|
||||
note:
|
||||
"Synthetic Blue Wave (BLW) index for demo UX only — not tradable cryptocurrency. Credits use the rate locked when you start checkout.",
|
||||
tiers,
|
||||
sparkline,
|
||||
});
|
||||
}
|
||||
26
src/app/api/public/stats/route.ts
Normal file
26
src/app/api/public/stats/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const [agg, donorCount] = await Promise.all([
|
||||
prisma.donation.aggregate({
|
||||
_sum: { amountUsdCents: true },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.donation.groupBy({
|
||||
by: ["userId"],
|
||||
_count: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const raisedUsd = (agg._sum.amountUsdCents ?? 0) / 100;
|
||||
const goalUsd = parseFloat(process.env.PUBLIC_CAMPAIGN_GOAL_USD ?? "250000");
|
||||
|
||||
return NextResponse.json({
|
||||
raisedUsd,
|
||||
donationCount: agg._count,
|
||||
uniqueDonors: donorCount.length,
|
||||
goalUsd,
|
||||
committeePlaceholder: process.env.COMMITTEE_LEGAL_NAME_PLACEHOLDER ?? "Demo Committee (configure COMMITTEE_LEGAL_NAME_PLACEHOLDER)",
|
||||
});
|
||||
}
|
||||
44
src/app/api/register/route.ts
Normal file
44
src/app/api/register/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const bodySchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
name: z.string().min(1).max(120).optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const json = await req.json();
|
||||
const data = bodySchema.parse(json);
|
||||
|
||||
const exists = await prisma.user.findUnique({ where: { email: data.email } });
|
||||
if (exists) {
|
||||
return NextResponse.json({ error: "An account with this email already exists." }, { status: 409 });
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(data.password, 12);
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: data.email,
|
||||
name: data.name ?? data.email.split("@")[0],
|
||||
passwordHash,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.wallet.create({
|
||||
data: { userId: user.id, balanceCredits: 0 },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, email: user.email });
|
||||
} catch (e) {
|
||||
if (e instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: "Invalid input", issues: e.issues }, { status: 400 });
|
||||
}
|
||||
console.error(e);
|
||||
return NextResponse.json({ error: "Registration failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
15
src/app/api/rewards/catalog/route.ts
Normal file
15
src/app/api/rewards/catalog/route.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const [prizes, raffles] = await Promise.all([
|
||||
prisma.prizeSku.findMany({ orderBy: { costCredits: "asc" } }),
|
||||
prisma.raffle.findMany({ orderBy: { endsAt: "asc" } }),
|
||||
]);
|
||||
|
||||
return NextResponse.json({
|
||||
prizes,
|
||||
raffles,
|
||||
creditName: process.env.PUBLIC_CREDIT_NAME ?? "BLW",
|
||||
});
|
||||
}
|
||||
80
src/app/api/rewards/raffle/route.ts
Normal file
80
src/app/api/rewards/raffle/route.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/auth";
|
||||
import { isAdminRole } from "@/lib/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const bodySchema = z.object({
|
||||
slug: z.string().min(1),
|
||||
tickets: z.number().int().min(1).max(50),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const json = await req.json();
|
||||
const { slug, tickets } = bodySchema.parse(json);
|
||||
|
||||
const raffle = await prisma.raffle.findUnique({ where: { slug } });
|
||||
if (!raffle) {
|
||||
return NextResponse.json({ error: "Raffle not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const totalCost = raffle.ticketCostCredits * tickets;
|
||||
const admin = isAdminRole(session.user!.role);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (!admin) {
|
||||
const wallet = await tx.wallet.findUnique({ where: { userId: session.user!.id } });
|
||||
if (!wallet || wallet.balanceCredits < totalCost) {
|
||||
throw new Error("INSUFFICIENT_CREDITS");
|
||||
}
|
||||
}
|
||||
|
||||
await tx.raffleEntry.create({
|
||||
data: {
|
||||
raffleId: raffle.id,
|
||||
userId: session.user!.id,
|
||||
tickets,
|
||||
creditsSpent: admin ? 0 : totalCost,
|
||||
},
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
await tx.ledgerEntry.create({
|
||||
data: {
|
||||
userId: session.user!.id,
|
||||
delta: -totalCost,
|
||||
type: "DEBIT_RAFFLE",
|
||||
memo: `Raffle tickets: ${raffle.title} × ${tickets}`,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.wallet.update({
|
||||
where: { userId: session.user!.id },
|
||||
data: { balanceCredits: { decrement: totalCost } },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
tickets,
|
||||
creditsSpent: admin ? 0 : totalCost,
|
||||
adminBypass: admin,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: "Invalid input", issues: e.issues }, { status: 400 });
|
||||
}
|
||||
if (e instanceof Error && e.message === "INSUFFICIENT_CREDITS") {
|
||||
return NextResponse.json({ error: "Insufficient BLW (Blue Wave)" }, { status: 402 });
|
||||
}
|
||||
console.error(e);
|
||||
return NextResponse.json({ error: "Entry failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
79
src/app/api/rewards/redeem/route.ts
Normal file
79
src/app/api/rewards/redeem/route.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/auth";
|
||||
import { isAdminRole } from "@/lib/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const bodySchema = z.object({
|
||||
slug: z.string().min(1),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const json = await req.json();
|
||||
const { slug } = bodySchema.parse(json);
|
||||
|
||||
const sku = await prisma.prizeSku.findUnique({ where: { slug } });
|
||||
if (!sku) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const admin = isAdminRole(session.user!.role);
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
if (!admin) {
|
||||
const wallet = await tx.wallet.findUnique({ where: { userId: session.user!.id } });
|
||||
if (!wallet || wallet.balanceCredits < sku.costCredits) {
|
||||
throw new Error("INSUFFICIENT_CREDITS");
|
||||
}
|
||||
}
|
||||
|
||||
const redemption = await tx.redemption.create({
|
||||
data: {
|
||||
userId: session.user!.id,
|
||||
prizeSkuId: sku.id,
|
||||
creditsSpent: admin ? 0 : sku.costCredits,
|
||||
},
|
||||
});
|
||||
|
||||
if (!admin) {
|
||||
await tx.ledgerEntry.create({
|
||||
data: {
|
||||
userId: session.user!.id,
|
||||
delta: -sku.costCredits,
|
||||
type: "DEBIT_SPEND",
|
||||
redemptionId: redemption.id,
|
||||
memo: `Redeem: ${sku.title}`,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.wallet.update({
|
||||
where: { userId: session.user!.id },
|
||||
data: { balanceCredits: { decrement: sku.costCredits } },
|
||||
});
|
||||
}
|
||||
|
||||
return redemption.id;
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
redemptionId: result,
|
||||
adminBypass: admin,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: "Invalid input", issues: e.issues }, { status: 400 });
|
||||
}
|
||||
if (e instanceof Error && e.message === "INSUFFICIENT_CREDITS") {
|
||||
return NextResponse.json({ error: "Insufficient BLW (Blue Wave)" }, { status: 402 });
|
||||
}
|
||||
console.error(e);
|
||||
return NextResponse.json({ error: "Redeem failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
67
src/app/api/stripe/create-payment-intent/route.ts
Normal file
67
src/app/api/stripe/create-payment-intent/route.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { auth } from "@/auth";
|
||||
import { ALLOWED_DONATION_USD_CENTS, blwCreditsForUsdCents, blwUsdAt } from "@/lib/exchange";
|
||||
import { stripe } from "@/lib/stripe";
|
||||
|
||||
const bodySchema = z.object({
|
||||
amountUsdCents: z.number().int().refine(
|
||||
(n): n is (typeof ALLOWED_DONATION_USD_CENTS)[number] =>
|
||||
(ALLOWED_DONATION_USD_CENTS as readonly number[]).includes(n),
|
||||
{ message: "Allowed tiers only: $5, $10, $20, $100" },
|
||||
),
|
||||
});
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const sk = process.env.STRIPE_SECRET_KEY?.trim();
|
||||
if (!sk || sk.includes("disabled_configure")) {
|
||||
return NextResponse.json(
|
||||
{ error: "Stripe is not configured. Set STRIPE_SECRET_KEY in .env." },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const json = await req.json();
|
||||
const { amountUsdCents } = bodySchema.parse(json);
|
||||
|
||||
const blwUsd = blwUsdAt(Date.now());
|
||||
const creditsPreview = blwCreditsForUsdCents(amountUsdCents, blwUsd);
|
||||
|
||||
const paymentIntent = await stripe.paymentIntents.create({
|
||||
amount: amountUsdCents,
|
||||
currency: "usd",
|
||||
automatic_payment_methods: { enabled: true },
|
||||
metadata: {
|
||||
userId: session.user.id,
|
||||
purpose: "donation",
|
||||
blwUsdSnapshot: blwUsd.toFixed(6),
|
||||
tierCents: String(amountUsdCents),
|
||||
expectedCredits: String(creditsPreview),
|
||||
},
|
||||
description: `${process.env.PUBLIC_APP_NAME ?? process.env.NEXT_PUBLIC_APP_NAME ?? "Democracy Rising"} — grassroots donation`,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
clientSecret: paymentIntent.client_secret,
|
||||
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "",
|
||||
exchange: {
|
||||
blwUsd,
|
||||
blwPerUsd: 1 / blwUsd,
|
||||
creditsPreview,
|
||||
tierUsdCents: amountUsdCents,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof z.ZodError) {
|
||||
return NextResponse.json({ error: "Invalid amount", issues: e.issues }, { status: 400 });
|
||||
}
|
||||
console.error(e);
|
||||
return NextResponse.json({ error: "Could not create payment" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
28
src/app/api/wallet/route.ts
Normal file
28
src/app/api/wallet/route.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/auth";
|
||||
import { ADMIN_WALLET_DISPLAY, isAdminRole } from "@/lib/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export async function GET() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { role: true },
|
||||
});
|
||||
const admin = isAdminRole(user?.role ?? session.user.role);
|
||||
|
||||
const wallet = await prisma.wallet.findUnique({
|
||||
where: { userId: session.user.id },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
balanceCredits: admin ? ADMIN_WALLET_DISPLAY : wallet?.balanceCredits ?? 0,
|
||||
infiniteCredits: admin,
|
||||
role: user?.role ?? session.user.role,
|
||||
creditLabel: process.env.PUBLIC_CREDIT_NAME ?? "BLW",
|
||||
});
|
||||
}
|
||||
95
src/app/api/webhooks/stripe/route.ts
Normal file
95
src/app/api/webhooks/stripe/route.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type Stripe from "stripe";
|
||||
import { blwCreditsForUsdCents } from "@/lib/exchange";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { creditsFromUsdCents, stripe } from "@/lib/stripe";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
|
||||
if (!webhookSecret) {
|
||||
console.error("STRIPE_WEBHOOK_SECRET missing");
|
||||
return NextResponse.json({ error: "Webhook not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
const signature = req.headers.get("stripe-signature");
|
||||
if (!signature) {
|
||||
return NextResponse.json({ error: "Missing signature" }, { status: 400 });
|
||||
}
|
||||
|
||||
const rawBody = await req.text();
|
||||
|
||||
let event: Stripe.Event;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
|
||||
} catch (err) {
|
||||
console.error("Webhook signature verification failed", err);
|
||||
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (event.type === "payment_intent.succeeded") {
|
||||
const pi = event.data.object as Stripe.PaymentIntent;
|
||||
const userId = pi.metadata?.userId;
|
||||
if (!userId) {
|
||||
console.warn("payment_intent.succeeded without userId metadata", pi.id);
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
|
||||
const amountUsdCents = pi.amount_received ?? pi.amount;
|
||||
|
||||
const blwSnap = pi.metadata?.blwUsdSnapshot ?? pi.metadata?.mtkUsdSnapshot;
|
||||
const blwUsd = blwSnap ? parseFloat(blwSnap) : NaN;
|
||||
const credits =
|
||||
Number.isFinite(blwUsd) && blwUsd > 0
|
||||
? blwCreditsForUsdCents(amountUsdCents, blwUsd)
|
||||
: creditsFromUsdCents(amountUsdCents);
|
||||
|
||||
try {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.donation.findUnique({
|
||||
where: { stripePaymentIntentId: pi.id },
|
||||
});
|
||||
if (existing) return;
|
||||
|
||||
const donation = await tx.donation.create({
|
||||
data: {
|
||||
stripePaymentIntentId: pi.id,
|
||||
userId,
|
||||
amountUsdCents,
|
||||
creditsAwarded: credits,
|
||||
currency: pi.currency,
|
||||
status: pi.status ?? "succeeded",
|
||||
},
|
||||
});
|
||||
|
||||
await tx.wallet.upsert({
|
||||
where: { userId },
|
||||
create: { userId, balanceCredits: credits },
|
||||
update: { balanceCredits: { increment: credits } },
|
||||
});
|
||||
|
||||
if (credits > 0) {
|
||||
const rateNote =
|
||||
Number.isFinite(blwUsd) && blwUsd > 0
|
||||
? `@ ${blwUsd.toFixed(4)} USD/BLW`
|
||||
: "(legacy ratio)";
|
||||
await tx.ledgerEntry.create({
|
||||
data: {
|
||||
userId,
|
||||
delta: credits,
|
||||
type: "CREDIT_DONATION",
|
||||
donationId: donation.id,
|
||||
memo: `Donation ${(amountUsdCents / 100).toFixed(2)} USD → ${credits} BLW ${rateNote}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Webhook processing failed", err);
|
||||
return NextResponse.json({ error: "Processing failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ received: true });
|
||||
}
|
||||
@@ -1,26 +1,38 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
: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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
79
src/app/login/LoginForm.tsx
Normal file
79
src/app/login/LoginForm.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export function LoginForm({ callbackUrl }: { callbackUrl: string }) {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const res = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
callbackUrl,
|
||||
});
|
||||
setBusy(false);
|
||||
if (res?.error) {
|
||||
setError("Invalid email or password.");
|
||||
return;
|
||||
}
|
||||
router.push(callbackUrl);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[70vh] max-w-lg flex-col justify-center px-4 py-16 sm:px-6">
|
||||
<h1 className="text-3xl font-semibold text-white">Sign in</h1>
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
Demo account from seed: <code className="rounded bg-white/10 px-2 py-0.5">demo@local.dev</code> /{" "}
|
||||
<code className="rounded bg-white/10 px-2 py-0.5">demo1234</code>
|
||||
</p>
|
||||
<form onSubmit={submit} className="mt-8 space-y-4">
|
||||
<label className="block text-sm text-slate-300">
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-2 w-full rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-white outline-none ring-sky-500/40 focus:ring"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-slate-300">
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-2 w-full rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-white outline-none ring-sky-500/40 focus:ring"
|
||||
/>
|
||||
</label>
|
||||
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="w-full rounded-2xl bg-gradient-to-r from-sky-500 to-indigo-500 py-3 font-semibold text-white shadow-lg shadow-indigo-500/25 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Signing in…" : "Continue"}
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-6 text-sm text-slate-400">
|
||||
No account?{" "}
|
||||
<Link className="text-sky-300 hover:underline" href="/register">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
src/app/login/page.tsx
Normal file
13
src/app/login/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { LoginForm } from "./LoginForm";
|
||||
|
||||
export default async function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ callbackUrl?: string | string[] }>;
|
||||
}) {
|
||||
const sp = await searchParams;
|
||||
const raw = sp.callbackUrl;
|
||||
const callbackUrl = typeof raw === "string" ? raw : "/wallet";
|
||||
|
||||
return <LoginForm callbackUrl={callbackUrl} />;
|
||||
}
|
||||
132
src/app/page.tsx
132
src/app/page.tsx
@@ -1,103 +1,41 @@
|
||||
import Image from "next/image";
|
||||
import { ActionCenter } from "@/components/ActionCenter";
|
||||
import { DonateSection } from "@/components/DonateSection";
|
||||
import { Hero } from "@/components/Hero";
|
||||
import { ImpactPlanner } from "@/components/ImpactPlanner";
|
||||
import { IssueGrid } from "@/components/IssueGrid";
|
||||
import { OppositionSection } from "@/components/OppositionSection";
|
||||
import { ProgressSection } from "@/components/ProgressSection";
|
||||
import { RewardsPreview } from "@/components/RewardsPreview";
|
||||
import { SiteFooter } from "@/components/SiteFooter";
|
||||
import { SupporterFeed } from "@/components/SupporterFeed";
|
||||
|
||||
export default function Home() {
|
||||
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 drafts—swap 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
89
src/app/raised/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import Link from "next/link";
|
||||
import { appTitle } from "@/lib/public-env";
|
||||
|
||||
export const metadata = {
|
||||
title: `Dollars raised — ${appTitle()}`,
|
||||
description: "Live totals from confirmed Stripe donations in this deployment.",
|
||||
};
|
||||
|
||||
export default async function RaisedPage() {
|
||||
const [agg, donorRows] = await Promise.all([
|
||||
prisma.donation.aggregate({
|
||||
_sum: { amountUsdCents: true },
|
||||
_count: true,
|
||||
}),
|
||||
prisma.donation.groupBy({
|
||||
by: ["userId"],
|
||||
_count: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const raisedUsd = (agg._sum.amountUsdCents ?? 0) / 100;
|
||||
const donationCount = agg._count;
|
||||
const uniqueDonors = donorRows.length;
|
||||
const goalUsd = parseFloat(process.env.PUBLIC_CAMPAIGN_GOAL_USD ?? "250000");
|
||||
const pct = goalUsd > 0 ? Math.min(100, Math.round((raisedUsd / goalUsd) * 100)) : 0;
|
||||
|
||||
const formatted = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(raisedUsd);
|
||||
|
||||
return (
|
||||
<main className="min-h-[70vh] border-b border-white/10 py-16">
|
||||
<div className="mx-auto max-w-3xl px-4 sm:px-6">
|
||||
<p className="text-xs uppercase tracking-[0.32em] text-sky-200/80">Transparency</p>
|
||||
<h1 className="mt-4 text-4xl font-semibold text-white sm:text-5xl">Total dollars raised</h1>
|
||||
<p className="mt-4 text-lg text-slate-400">
|
||||
Sum of successful donations processed through Stripe for this app ({appTitle()}). Updates as webhooks confirm
|
||||
payments.
|
||||
</p>
|
||||
|
||||
<div className="mt-12 rounded-[28px] border border-white/10 bg-gradient-to-br from-sky-500/15 via-indigo-900/40 to-fuchsia-900/30 p-8 shadow-[0_0_100px_rgba(56,189,248,0.12)]">
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-slate-400">Confirmed via Stripe</p>
|
||||
<p className="mt-4 font-mono text-5xl font-semibold tracking-tight text-white sm:text-6xl">{formatted}</p>
|
||||
<p className="mt-6 flex flex-wrap gap-6 text-sm text-slate-300">
|
||||
<span>
|
||||
<strong className="text-white">{donationCount}</strong> donation{donationCount === 1 ? "" : "s"}
|
||||
</span>
|
||||
<span>
|
||||
<strong className="text-white">{uniqueDonors}</strong> supporter{uniqueDonors === 1 ? "" : "s"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-10">
|
||||
<div className="flex justify-between text-xs text-slate-500">
|
||||
<span>$0</span>
|
||||
<span>
|
||||
Goal {new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(goalUsd)}{" "}
|
||||
<span className="text-slate-600">(PUBLIC_CAMPAIGN_GOAL_USD)</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-3 overflow-hidden rounded-full border border-white/10 bg-black/40">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-sky-400 via-indigo-400 to-fuchsia-400 transition-[width]"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-center text-xs text-slate-500">{pct}% of demo goal</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 rounded-2xl border border-white/10 bg-white/5 p-6 text-sm text-slate-400">
|
||||
<p className="font-medium text-white">Note</p>
|
||||
<p className="mt-2 leading-relaxed">
|
||||
This total reflects <code className="rounded bg-black/30 px-1">Donation</code> rows created by the Stripe webhook
|
||||
only—only processed charges count. Configure committee reporting separately for compliance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 flex flex-wrap gap-4">
|
||||
<Link href="/#donate" className="rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-6 py-3 text-sm font-semibold text-white">
|
||||
Donate
|
||||
</Link>
|
||||
<Link href="/" className="rounded-full border border-white/15 px-6 py-3 text-sm font-semibold text-white hover:bg-white/5">
|
||||
Back home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
90
src/app/register/page.tsx
Normal file
90
src/app/register/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
export default function RegisterPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const res = await fetch("/api/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password, name }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Could not register");
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
await signIn("credentials", { email, password, redirect: false });
|
||||
router.push("/wallet");
|
||||
router.refresh();
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[70vh] max-w-lg flex-col justify-center px-4 py-16 sm:px-6">
|
||||
<h1 className="text-3xl font-semibold text-white">Create supporter login</h1>
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
Password must be at least 8 characters. Your wallet is created automatically.
|
||||
</p>
|
||||
<form onSubmit={submit} className="mt-8 space-y-4">
|
||||
<label className="block text-sm text-slate-300">
|
||||
Display name
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="mt-2 w-full rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-white outline-none ring-sky-500/40 focus:ring"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-slate-300">
|
||||
Email
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="mt-2 w-full rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-white outline-none ring-sky-500/40 focus:ring"
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm text-slate-300">
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="mt-2 w-full rounded-xl border border-white/10 bg-black/30 px-4 py-3 text-white outline-none ring-sky-500/40 focus:ring"
|
||||
/>
|
||||
</label>
|
||||
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="w-full rounded-2xl bg-gradient-to-r from-fuchsia-500 to-indigo-500 py-3 font-semibold text-white shadow-lg shadow-fuchsia-500/25 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Creating…" : "Create account"}
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-6 text-sm text-slate-400">
|
||||
Already joined?{" "}
|
||||
<Link className="text-sky-300 hover:underline" href="/login">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
src/app/wallet/WalletActions.tsx
Normal file
208
src/app/wallet/WalletActions.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
"use client";
|
||||
|
||||
import type { PrizeSku, Raffle } from "@prisma/client";
|
||||
import { usdValueOfBlwCredits } from "@/lib/exchange";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
initialBalance: number;
|
||||
infiniteCredits?: boolean;
|
||||
creditName: string;
|
||||
prizes: PrizeSku[];
|
||||
raffles: Raffle[];
|
||||
};
|
||||
|
||||
export function WalletActions({
|
||||
initialBalance,
|
||||
infiniteCredits: initialInfinite,
|
||||
creditName,
|
||||
prizes,
|
||||
raffles,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const [balance, setBalance] = useState(initialBalance);
|
||||
const [infiniteCredits, setInfiniteCredits] = useState(!!initialInfinite);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [blwUsd, setBlwUsd] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (infiniteCredits) return;
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/exchange/rate", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const j = await res.json();
|
||||
if (alive) setBlwUsd(j.blwUsd as number);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, 15_000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, [infiniteCredits]);
|
||||
|
||||
const portfolioUsd =
|
||||
!infiniteCredits && blwUsd !== null ? usdValueOfBlwCredits(balance, blwUsd) : null;
|
||||
|
||||
const refreshBalance = async () => {
|
||||
const res = await fetch("/api/wallet", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setBalance(data.balanceCredits ?? 0);
|
||||
setInfiniteCredits(!!data.infiniteCredits);
|
||||
};
|
||||
|
||||
const redeem = async (slug: string) => {
|
||||
setBusy(`redeem:${slug}`);
|
||||
setMessage(null);
|
||||
const res = await fetch("/api/rewards/redeem", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ slug }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setBusy(null);
|
||||
if (!res.ok) {
|
||||
setMessage(data.error ?? "Could not redeem");
|
||||
return;
|
||||
}
|
||||
setMessage("Redeemed — fulfillment details are stubbed for now.");
|
||||
await refreshBalance();
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const raffle = async (slug: string, tickets: number) => {
|
||||
setBusy(`raffle:${slug}`);
|
||||
setMessage(null);
|
||||
const res = await fetch("/api/rewards/raffle", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ slug, tickets }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setBusy(null);
|
||||
if (!res.ok) {
|
||||
setMessage(data.error ?? "Could not enter raffle");
|
||||
return;
|
||||
}
|
||||
setMessage(`Entered raffle — ${data.tickets} ticket(s).`);
|
||||
await refreshBalance();
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 rounded-3xl border border-white/10 bg-white/5 p-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-slate-400">Wallet balance</p>
|
||||
<p className="mt-2 text-4xl font-semibold text-white">
|
||||
{infiniteCredits ? (
|
||||
<>
|
||||
<span className="tabular-nums">∞</span>{" "}
|
||||
<span className="text-lg font-normal text-slate-400">{creditName}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{balance.toLocaleString()}{" "}
|
||||
<span className="text-lg font-normal text-slate-400">{creditName}</span>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{!infiniteCredits && portfolioUsd !== null && blwUsd !== null ? (
|
||||
<p className="mt-3 text-sm text-slate-400">
|
||||
Mock mark‑to‑market:{" "}
|
||||
<span className="font-semibold text-emerald-300/95">
|
||||
≈ ${portfolioUsd.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} USD
|
||||
</span>{" "}
|
||||
at ${blwUsd.toFixed(4)} / BLW <span className="text-slate-500">(index moves — not cash)</span>
|
||||
</p>
|
||||
) : null}
|
||||
{infiniteCredits ? (
|
||||
<p className="mt-3 text-sm text-amber-200/90">Admin QA mode — portfolio index hidden.</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
className="rounded-full border border-white/15 px-4 py-2 text-sm text-slate-200 hover:bg-white/5"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message ? (
|
||||
<p className="rounded-2xl border border-sky-500/30 bg-sky-500/10 px-4 py-3 text-sm text-sky-100">{message}</p>
|
||||
) : null}
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-white">Digital perks (stub catalog)</h2>
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
Spend credits on placeholder perks—swap SKUs for real merchandise integrations later.
|
||||
</p>
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
{prizes.map((p) => (
|
||||
<div key={p.id} className="rounded-2xl border border-white/10 bg-black/30 p-5">
|
||||
<h3 className="text-lg font-semibold text-white">{p.title}</h3>
|
||||
<p className="mt-2 text-sm text-slate-400">{p.description}</p>
|
||||
<p className="mt-4 text-sm text-slate-300">
|
||||
Cost: <span className="font-semibold text-white">{p.costCredits}</span> credits
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => redeem(p.slug)}
|
||||
className="mt-4 w-full rounded-xl bg-white/10 py-2 text-sm font-semibold text-white hover:bg-white/15 disabled:opacity-40"
|
||||
>
|
||||
{busy === `redeem:${p.slug}` ? "Working…" : "Redeem"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold text-white">Raffles</h2>
|
||||
<div className="mt-6 space-y-4">
|
||||
{raffles.map((r) => (
|
||||
<div key={r.id} className="flex flex-col gap-3 rounded-2xl border border-white/10 bg-black/30 p-5 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{r.title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-400">{r.description}</p>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
Ticket cost: {r.ticketCostCredits} credits · Ends{" "}
|
||||
{r.endsAt ? new Date(r.endsAt).toLocaleDateString() : "TBD"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => raffle(r.slug, 1)}
|
||||
className="rounded-xl bg-gradient-to-r from-sky-500 to-indigo-500 px-4 py-2 text-sm font-semibold text-white disabled:opacity-40"
|
||||
>
|
||||
{busy === `raffle:${r.slug}` ? "…" : "Buy 1 ticket"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy !== null}
|
||||
onClick={() => raffle(r.slug, 5)}
|
||||
className="rounded-xl border border-white/15 px-4 py-2 text-sm text-white hover:bg-white/5 disabled:opacity-40"
|
||||
>
|
||||
Buy 5
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/app/wallet/page.tsx
Normal file
50
src/app/wallet/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { auth } from "@/auth";
|
||||
import { isAdminRole } from "@/lib/admin";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
import { WalletActions } from "./WalletActions";
|
||||
|
||||
export default async function WalletPage() {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
redirect("/login?callbackUrl=/wallet");
|
||||
}
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
const [dbUser, wallet, prizes, raffles] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { id: userId }, select: { role: true } }),
|
||||
prisma.wallet.findUnique({ where: { userId } }),
|
||||
prisma.prizeSku.findMany({ orderBy: { costCredits: "asc" } }),
|
||||
prisma.raffle.findMany({ orderBy: { endsAt: "asc" } }),
|
||||
]);
|
||||
|
||||
const admin = isAdminRole(dbUser?.role ?? session.user.role);
|
||||
const creditName = process.env.PUBLIC_CREDIT_NAME ?? "BLW";
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-16 sm:px-6">
|
||||
<p className="text-xs uppercase tracking-[0.32em] text-slate-400">Supporter wallet</p>
|
||||
<h1 className="mt-3 text-3xl font-semibold text-white sm:text-4xl">Your Blue Wave (BLW)</h1>
|
||||
<p className="mt-4 max-w-2xl text-slate-400">
|
||||
BLW accrues after Stripe confirms a donation via webhook. This page exercises redemption and raffle flows against the
|
||||
ledger—swap SKUs for production fulfillment when ready.
|
||||
</p>
|
||||
{admin ? (
|
||||
<p className="mt-4 rounded-2xl border border-amber-500/40 bg-amber-500/10 px-4 py-3 text-sm text-amber-100">
|
||||
Signed in as <strong className="text-white">ADMIN</strong> — unlimited credits for QA (spends do not
|
||||
debit your wallet).
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-10">
|
||||
<WalletActions
|
||||
initialBalance={wallet?.balanceCredits ?? 0}
|
||||
infiniteCredits={admin}
|
||||
creditName={creditName}
|
||||
prizes={prizes}
|
||||
raffles={raffles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/auth.ts
Normal file
58
src/auth.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
const credentialsSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export const { handlers, auth, signIn, signOut } = NextAuth({
|
||||
trustHost: true,
|
||||
session: { strategy: "jwt", maxAge: 30 * 24 * 60 * 60 },
|
||||
providers: [
|
||||
Credentials({
|
||||
name: "Credentials",
|
||||
credentials: {
|
||||
email: { label: "Email", type: "email" },
|
||||
password: { label: "Password", type: "password" },
|
||||
},
|
||||
async authorize(raw) {
|
||||
const parsed = credentialsSchema.safeParse(raw);
|
||||
if (!parsed.success) return null;
|
||||
|
||||
const { email, password } = parsed.data;
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user?.passwordHash) return null;
|
||||
|
||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!ok) return null;
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name ?? undefined,
|
||||
role: user.role,
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
jwt({ token, user }) {
|
||||
if (user) {
|
||||
token.id = user.id;
|
||||
token.role = (user as { role: string }).role;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
session({ session, token }) {
|
||||
if (session.user) {
|
||||
session.user.id = (token.id as string) ?? (token.sub as string);
|
||||
session.user.role = (token.role as "USER" | "ADMIN") ?? "USER";
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
});
|
||||
87
src/components/ActionCenter.tsx
Normal file
87
src/components/ActionCenter.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import Link from "next/link";
|
||||
|
||||
const actions = [
|
||||
{
|
||||
title: "Donate and lock BLW",
|
||||
eyebrow: "Money",
|
||||
body: "Start checkout, freeze the mock spot rate, and let the Stripe webhook credit your supporter wallet.",
|
||||
href: "/#donate",
|
||||
cta: "Donate now",
|
||||
},
|
||||
{
|
||||
title: "Spend credits",
|
||||
eyebrow: "Rewards",
|
||||
body: "Redeem digital perks or enter raffles from the wallet once donations settle.",
|
||||
href: "/wallet",
|
||||
cta: "Open wallet",
|
||||
},
|
||||
{
|
||||
title: "Recruit three people",
|
||||
eyebrow: "Network",
|
||||
body: "Use the issue cards as a conversation script, then pull friends into the donation and action loop.",
|
||||
href: "/#priorities",
|
||||
cta: "Pick an issue",
|
||||
},
|
||||
{
|
||||
title: "Plan a mini-sprint",
|
||||
eyebrow: "Field",
|
||||
body: "Use the impact planner to pair dollars with hours and decide where to focus the next local push.",
|
||||
href: "/#impact",
|
||||
cta: "Build a plan",
|
||||
},
|
||||
{
|
||||
title: "Run accountability messaging",
|
||||
eyebrow: "Narrative",
|
||||
body: "Frame the contrast around corruption, rights, evidence, and solidarity without cheap shots.",
|
||||
href: "/#accountability",
|
||||
cta: "Read the frame",
|
||||
},
|
||||
{
|
||||
title: "Bring the receipts",
|
||||
eyebrow: "Trust",
|
||||
body: "Point donors to aggregate totals on /raised, wallet ledger behavior, and compliance stubs before asking again.",
|
||||
href: "/raised",
|
||||
cta: "Show the loop",
|
||||
},
|
||||
];
|
||||
|
||||
export function ActionCenter() {
|
||||
return (
|
||||
<section id="actions" className="border-b border-white/10 py-20">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.32em] text-slate-400">More things to do</p>
|
||||
<h2 className="mt-4 max-w-3xl text-3xl font-semibold text-white sm:text-4xl">
|
||||
Turn a donation page into a supporter playground.
|
||||
</h2>
|
||||
</div>
|
||||
<p className="max-w-xl text-sm leading-relaxed text-slate-400">
|
||||
The best fundraising experience gives people immediate next steps. This hub keeps the supporter moving
|
||||
from money to identity, then from identity to action.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{actions.map((action, index) => (
|
||||
<Link
|
||||
key={action.title}
|
||||
href={action.href}
|
||||
className="group rounded-3xl border border-white/10 bg-white/[0.04] p-6 transition hover:-translate-y-1 hover:border-sky-300/40 hover:bg-white/[0.07] hover:shadow-[0_0_70px_rgba(56,189,248,0.12)]"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-xs uppercase tracking-[0.26em] text-sky-200/80">{action.eyebrow}</p>
|
||||
<span className="rounded-full border border-white/10 px-2 py-1 font-mono text-xs text-slate-500">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mt-4 text-xl font-semibold text-white">{action.title}</h3>
|
||||
<p className="mt-3 text-sm leading-relaxed text-slate-400">{action.body}</p>
|
||||
<p className="mt-6 text-sm font-semibold text-sky-200 group-hover:text-white">{action.cta} →</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
47
src/components/DonateSection.tsx
Normal file
47
src/components/DonateSection.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { DonationCheckout } from "./DonationCheckout";
|
||||
import { MockExchangeTicker } from "./MockExchangeTicker";
|
||||
|
||||
export function DonateSection({ publishableKey }: { publishableKey: string }) {
|
||||
return (
|
||||
<section id="donate" className="border-b border-white/10 py-20">
|
||||
<div className="mx-auto grid max-w-6xl gap-12 px-4 lg:grid-cols-[1.1fr_0.9fr] sm:px-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.32em] text-slate-400">Secure donation</p>
|
||||
<h2 className="mt-4 text-3xl font-semibold text-white sm:text-4xl">
|
||||
Fixed tiers + Blue Wave (BLW) spot index.
|
||||
</h2>
|
||||
<p className="mt-4 max-w-xl text-slate-300">
|
||||
Pick <span className="text-white">$5, $10, $20, or $100</span>. Stripe settles real dollars; BLW credits are
|
||||
minted using a mock exchange rate <span className="text-white">locked when you open checkout</span>. Watch the
|
||||
live index — when BLW looks “cheap” in USD, your tier buys more BLW (and vice‑versa).
|
||||
</p>
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-3">
|
||||
{[
|
||||
["1", "Choose a tier"],
|
||||
["2", "Lock BLW rate"],
|
||||
["3", "Unlock wallet perks"],
|
||||
].map(([step, label]) => (
|
||||
<div key={step} className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<p className="font-mono text-2xl font-semibold text-sky-200">{step}</p>
|
||||
<p className="mt-2 text-sm text-slate-300">{label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<MockExchangeTicker />
|
||||
</div>
|
||||
<div className="mt-8 rounded-3xl border border-white/10 bg-white/5 p-6 text-sm text-slate-300">
|
||||
<p className="font-semibold text-white">Why server‑confirmed credits matter</p>
|
||||
<p className="mt-2 leading-relaxed">
|
||||
The browser never “mints” money. A Stripe webhook confirms the charge, then our ledger adds BLW units once —
|
||||
idempotently — using the snapshot stored on the PaymentIntent.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-[28px] border border-white/10 bg-[#050816]/80 p-6 shadow-[0_0_120px_rgba(59,130,246,0.12)] backdrop-blur-xl sm:p-8">
|
||||
<DonationCheckout publishableKey={publishableKey} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
280
src/components/DonationCheckout.tsx
Normal file
280
src/components/DonationCheckout.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
"use client";
|
||||
|
||||
import { ALLOWED_DONATION_USD_CENTS, BLW_DISPLAY_NAME, BLW_TICKER } from "@/lib/exchange";
|
||||
import { motion } from "framer-motion";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
|
||||
import Link from "next/link";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
function InnerCheckout({
|
||||
onSucceeded,
|
||||
}: {
|
||||
onSucceeded: () => void;
|
||||
}) {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
const handle = async () => {
|
||||
if (!stripe || !elements) return;
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
const { error } = await stripe.confirmPayment({
|
||||
elements,
|
||||
confirmParams: {
|
||||
return_url: typeof window !== "undefined" ? `${window.location.origin}/wallet` : undefined,
|
||||
},
|
||||
redirect: "if_required",
|
||||
});
|
||||
if (error) {
|
||||
setMessage(error.message ?? "Payment failed");
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
onSucceeded();
|
||||
setBusy(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PaymentElement />
|
||||
{message ? <p className="text-sm text-rose-300">{message}</p> : null}
|
||||
<motion.button
|
||||
type="button"
|
||||
whileTap={{ scale: 0.98 }}
|
||||
disabled={busy || !stripe}
|
||||
onClick={handle}
|
||||
className="w-full rounded-2xl bg-gradient-to-r from-sky-500 via-indigo-500 to-fuchsia-500 py-3 text-base font-semibold text-white shadow-xl shadow-indigo-500/30 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Processing…" : "Complete donation"}
|
||||
</motion.button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ExchangePreview = {
|
||||
blwUsd: number;
|
||||
blwPerUsd: number;
|
||||
creditsPreview: number;
|
||||
tierUsdCents: number;
|
||||
};
|
||||
|
||||
export function DonationCheckout({ publishableKey }: { publishableKey: string }) {
|
||||
const { data: session, status } = useSession();
|
||||
const [tierCents, setTierCents] = useState<number>(1000);
|
||||
const [spot, setSpot] = useState<{ blwUsd: number; blwPerUsd: number } | null>(null);
|
||||
const [clientSecret, setClientSecret] = useState<string | null>(null);
|
||||
const [locked, setLocked] = useState<ExchangePreview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingIntent, setLoadingIntent] = useState(false);
|
||||
const [stripeBlockReason, setStripeBlockReason] = useState<string | null>(null);
|
||||
|
||||
const stripePromise = useMemo(() => {
|
||||
if (!publishableKey || typeof window === "undefined") return null;
|
||||
if (publishableKey.startsWith("pk_live_") && window.location.protocol !== "https:") return null;
|
||||
return loadStripe(publishableKey);
|
||||
}, [publishableKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!publishableKey || typeof window === "undefined") {
|
||||
setStripeBlockReason(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (publishableKey.startsWith("pk_live_") && window.location.protocol !== "https:") {
|
||||
setStripeBlockReason("Live Stripe publishable keys require HTTPS. Use Stripe test keys for local HTTP demos.");
|
||||
return;
|
||||
}
|
||||
|
||||
setStripeBlockReason(null);
|
||||
}, [publishableKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/exchange/rate", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const j = await res.json();
|
||||
if (alive) setSpot({ blwUsd: j.blwUsd, blwPerUsd: j.blwPerUsd });
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
tick();
|
||||
const id = setInterval(tick, 15_000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setClientSecret(null);
|
||||
setLocked(null);
|
||||
}, [tierCents]);
|
||||
|
||||
if (status === "loading") {
|
||||
return <p className="text-sm text-slate-400">Checking your session…</p>;
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
return (
|
||||
<div className="space-y-4 rounded-2xl border border-white/10 bg-black/30 p-5 text-sm text-slate-300">
|
||||
<p className="text-base text-white">
|
||||
Sign in to donate. {BLW_TICKER} credits use the mock spot rate locked when you start checkout.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="/login?callbackUrl=/#donate"
|
||||
className="rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-5 py-2 font-semibold text-white"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
<Link href="/register" className="rounded-full border border-white/20 px-5 py-2 font-semibold text-white hover:bg-white/5">
|
||||
Create account
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const startIntent = async () => {
|
||||
setLoadingIntent(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/stripe/create-payment-intent", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ amountUsdCents: tierCents }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
if (res.status === 401) {
|
||||
setError("Your session expired — please sign in again.");
|
||||
} else {
|
||||
setError(data.error ?? "Could not start payment");
|
||||
}
|
||||
setLoadingIntent(false);
|
||||
return;
|
||||
}
|
||||
setClientSecret(data.clientSecret);
|
||||
if (data.exchange) {
|
||||
setLocked(data.exchange as ExchangePreview);
|
||||
}
|
||||
} catch {
|
||||
setError("Network error");
|
||||
}
|
||||
setLoadingIntent(false);
|
||||
};
|
||||
|
||||
const onSucceeded = async () => {
|
||||
setClientSecret(null);
|
||||
setLocked(null);
|
||||
await fetch("/api/wallet", { cache: "no-store" });
|
||||
};
|
||||
|
||||
if (!publishableKey) {
|
||||
return (
|
||||
<p className="rounded-2xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-100">
|
||||
Add <code className="rounded bg-black/30 px-1">NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY</code> and{" "}
|
||||
<code className="rounded bg-black/30 px-1">STRIPE_SECRET_KEY</code> to{" "}
|
||||
<code className="rounded bg-black/30 px-1">.env</code> to process cards.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (stripeBlockReason) {
|
||||
return (
|
||||
<p className="rounded-2xl border border-amber-500/40 bg-amber-500/10 p-4 text-sm text-amber-100">
|
||||
{stripeBlockReason}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const spotBlwPreview =
|
||||
spot && !locked ? Math.floor((tierCents / 100) / spot.blwUsd) : null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">Choose a tier (USD)</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{ALLOWED_DONATION_USD_CENTS.map((cents) => (
|
||||
<button
|
||||
key={cents}
|
||||
type="button"
|
||||
onClick={() => setTierCents(cents)}
|
||||
className={`rounded-full px-5 py-2 text-sm font-semibold transition ${
|
||||
tierCents === cents
|
||||
? "bg-white text-slate-900"
|
||||
: "bg-white/5 text-slate-200 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
${(cents / 100).toFixed(0)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-black/25 px-4 py-3 text-sm text-slate-300">
|
||||
<p className="font-medium text-white">How {BLW_TICKER} ({BLW_DISPLAY_NAME}) works</p>
|
||||
<p className="mt-2 leading-relaxed text-slate-400">
|
||||
<strong className="text-slate-200">{BLW_DISPLAY_NAME}</strong> (“{BLW_TICKER}”) is a playful mock index — not real
|
||||
crypto. Credits mint as whole {BLW_TICKER} units:{" "}
|
||||
<code className="rounded bg-white/10 px-1">USD ÷ BLW/USD spot</code>. When the index is <em>lower</em>, each dollar
|
||||
buys <em>more</em> {BLW_TICKER}; when it's higher, you receive fewer {BLW_TICKER} for the same donation. The exact
|
||||
spot is <strong className="text-white">frozen</strong> when you tap "Continue to secure checkout".
|
||||
</p>
|
||||
{spot && !locked ? (
|
||||
<p className="mt-3 text-sky-200/90">
|
||||
Live index (not locked yet): ${spot.blwUsd.toFixed(4)} / {BLW_TICKER} → ~{spotBlwPreview ?? "—"} {BLW_TICKER} for $
|
||||
{(tierCents / 100).toFixed(0)}
|
||||
</p>
|
||||
) : null}
|
||||
{locked ? (
|
||||
<div className="mt-3 rounded-xl border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-emerald-100">
|
||||
<p className="text-xs uppercase tracking-wide text-emerald-300/90">Locked for this checkout</p>
|
||||
<p className="mt-1 font-mono text-base">
|
||||
${locked.blwUsd.toFixed(4)} / {BLW_TICKER} · {locked.blwPerUsd.toFixed(2)} {BLW_TICKER} per $1 ·{" "}
|
||||
<strong>
|
||||
{locked.creditsPreview} {BLW_TICKER}
|
||||
</strong>{" "}
|
||||
if payment succeeds
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!clientSecret ? (
|
||||
<motion.button
|
||||
type="button"
|
||||
whileTap={{ scale: 0.98 }}
|
||||
disabled={loadingIntent}
|
||||
onClick={startIntent}
|
||||
className="w-full rounded-2xl bg-white/10 py-3 font-semibold text-white hover:bg-white/15 disabled:opacity-40"
|
||||
>
|
||||
{loadingIntent ? "Connecting to Stripe…" : "Continue to secure checkout"}
|
||||
</motion.button>
|
||||
) : stripePromise ? (
|
||||
<Elements
|
||||
stripe={stripePromise}
|
||||
options={{
|
||||
clientSecret,
|
||||
appearance: { theme: "night", variables: { borderRadius: "12px" } },
|
||||
}}
|
||||
>
|
||||
<InnerCheckout onSucceeded={onSucceeded} />
|
||||
</Elements>
|
||||
) : null}
|
||||
{error ? <p className="text-sm text-rose-300">{error}</p> : null}
|
||||
<p className="text-xs leading-relaxed text-slate-500">
|
||||
Donations may be subject to federal and state political fundraising rules. {BLW_DISPLAY_NAME} is a demo layer —
|
||||
configure real disclosures with <code className="rounded bg-black/30 px-1">DISCLAIMER_TEXT</code> before production use.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
src/components/Hero.tsx
Normal file
113
src/components/Hero.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import Link from "next/link";
|
||||
import { MockExchangeTicker } from "./MockExchangeTicker";
|
||||
import { ParticleField } from "./ParticleField";
|
||||
|
||||
export function Hero() {
|
||||
return (
|
||||
<section className="relative overflow-hidden border-b border-white/10">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_rgba(56,189,248,0.22),_transparent_55%),radial-gradient(ellipse_at_bottom,_rgba(168,85,247,0.18),_transparent_50%)]" />
|
||||
<ParticleField />
|
||||
<div className="relative mx-auto flex max-w-6xl flex-col gap-10 px-4 pb-24 pt-20 sm:px-6 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-3xl space-y-8">
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-4 py-1 text-xs uppercase tracking-[0.35em] text-sky-200/90"
|
||||
>
|
||||
Democracy · Dignity · Dopamine
|
||||
</motion.p>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.08, duration: 0.65 }}
|
||||
className="text-balance text-4xl font-semibold leading-tight text-white sm:text-5xl lg:text-6xl"
|
||||
>
|
||||
Make donating feel like joining the winning room:{" "}
|
||||
<span className="bg-gradient-to-r from-sky-300 via-indigo-200 to-fuchsia-300 bg-clip-text text-transparent">
|
||||
instant impact, credits, perks, and action.
|
||||
</span>
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15, duration: 0.65 }}
|
||||
className="text-lg text-slate-300/95"
|
||||
>
|
||||
This is a movement interface with a Stripe-backed donation core, a mock Blue Wave (BLW) supporter economy,
|
||||
a wallet, rewards, raffles, impact planning, and enough momentum cues to make the next click
|
||||
feel obvious.
|
||||
</motion.p>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.22, duration: 0.65 }}
|
||||
className="flex flex-wrap gap-3"
|
||||
>
|
||||
<Link
|
||||
href="/register"
|
||||
className="rounded-full bg-gradient-to-r from-sky-500 via-indigo-500 to-fuchsia-500 px-6 py-3 text-sm font-semibold text-white shadow-xl shadow-indigo-500/30"
|
||||
>
|
||||
Create supporter login
|
||||
</Link>
|
||||
<Link
|
||||
href="#donate"
|
||||
className="rounded-full border border-white/20 px-6 py-3 text-sm font-semibold text-white hover:bg-white/5"
|
||||
>
|
||||
Fuel the field program
|
||||
</Link>
|
||||
<Link
|
||||
href="#impact"
|
||||
className="rounded-full border border-sky-300/30 bg-sky-300/10 px-6 py-3 text-sm font-semibold text-sky-100 hover:bg-sky-300/15"
|
||||
>
|
||||
Plan my impact
|
||||
</Link>
|
||||
</motion.div>
|
||||
<div className="grid gap-3 text-sm text-slate-300 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<p className="text-2xl font-semibold text-white">4-step</p>
|
||||
<p className="mt-1 text-slate-400">donate-to-action loop</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<p className="text-2xl font-semibold text-white">BLW</p>
|
||||
<p className="mt-1 text-slate-400">Blue Wave mock credits</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<p className="text-2xl font-semibold text-white">Local</p>
|
||||
<p className="mt-1 text-slate-400">runs on port 8008</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.25, duration: 0.6 }}
|
||||
className="w-full max-w-md rounded-3xl border border-white/10 bg-white/5 p-6 shadow-[0_0_120px_rgba(56,189,248,0.15)] backdrop-blur-xl lg:mb-2"
|
||||
>
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-slate-400">Live movement pulse</p>
|
||||
<p className="mt-4 text-3xl font-semibold text-white">A supporter economy that feels alive</p>
|
||||
<div className="mt-5">
|
||||
<MockExchangeTicker />
|
||||
</div>
|
||||
<ul className="mt-5 space-y-3 text-sm text-slate-300">
|
||||
<li className="flex gap-2">
|
||||
<span className="mt-1 h-2 w-2 rounded-full bg-sky-400" />
|
||||
Micro‑volunteer asks routed locally—not dumped into a national spam cannon.
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="mt-1 h-2 w-2 rounded-full bg-indigo-400" />
|
||||
Donations settle through Stripe; BLW unlocks perks without touching card data twice.
|
||||
</li>
|
||||
<li className="flex gap-2">
|
||||
<span className="mt-1 h-2 w-2 rounded-full bg-fuchsia-400" />
|
||||
Built to extend into raffles, collectibles, and digital membership tiers without rewriting core flows.
|
||||
</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
144
src/components/ImpactPlanner.tsx
Normal file
144
src/components/ImpactPlanner.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
const presetAmounts = [5, 10, 20, 100];
|
||||
|
||||
const missions = [
|
||||
{
|
||||
id: "field",
|
||||
label: "Field sprint",
|
||||
multiplier: 1.2,
|
||||
description: "Funds doors, phones, rides, and local volunteer materials.",
|
||||
},
|
||||
{
|
||||
id: "digital",
|
||||
label: "Digital rapid response",
|
||||
multiplier: 1.05,
|
||||
description: "Boosts explainers, creator clips, texting, and persuasion follow-up.",
|
||||
},
|
||||
{
|
||||
id: "protection",
|
||||
label: "Democracy defense",
|
||||
multiplier: 0.9,
|
||||
description: "Supports poll access, voter assistance, legal readiness, and watchdog work.",
|
||||
},
|
||||
];
|
||||
|
||||
export function ImpactPlanner() {
|
||||
const [amount, setAmount] = useState(20);
|
||||
const [volunteerHours, setVolunteerHours] = useState(3);
|
||||
const [missionId, setMissionId] = useState(missions[0].id);
|
||||
|
||||
const selectedMission = missions.find((mission) => mission.id === missionId) ?? missions[0];
|
||||
|
||||
const impact = useMemo(() => {
|
||||
const intensity = selectedMission.multiplier;
|
||||
return {
|
||||
doors: Math.round(amount * 7 * intensity + volunteerHours * 22),
|
||||
texts: Math.round(amount * 55 * intensity + volunteerHours * 140),
|
||||
rides: Math.max(1, Math.round(amount / 18 + volunteerHours / 2)),
|
||||
credits: Math.round(amount * 9.5),
|
||||
};
|
||||
}, [amount, selectedMission.multiplier, volunteerHours]);
|
||||
|
||||
return (
|
||||
<section id="impact" className="border-b border-white/10 bg-[#050816] py-20">
|
||||
<div className="mx-auto grid max-w-6xl gap-8 px-4 sm:px-6 lg:grid-cols-[0.95fr_1.05fr] lg:items-center">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.32em] text-sky-200/75">Impact planner</p>
|
||||
<h2 className="mt-4 text-3xl font-semibold text-white sm:text-4xl">
|
||||
See the campaign machine light up before you donate.
|
||||
</h2>
|
||||
<p className="mt-4 max-w-xl text-slate-400">
|
||||
Pick a mission, choose a contribution, add volunteer time, and watch the support package turn into
|
||||
concrete work. The numbers are planning estimates, but the behavioral loop is real: donate, earn BLW,
|
||||
redeem, recruit, repeat.
|
||||
</p>
|
||||
<div className="mt-8 grid gap-3 sm:grid-cols-3">
|
||||
{missions.map((mission) => (
|
||||
<button
|
||||
key={mission.id}
|
||||
type="button"
|
||||
onClick={() => setMissionId(mission.id)}
|
||||
className={`rounded-2xl border p-4 text-left transition ${
|
||||
missionId === mission.id
|
||||
? "border-sky-300/60 bg-sky-400/15 text-white shadow-[0_0_40px_rgba(56,189,248,0.14)]"
|
||||
: "border-white/10 bg-white/5 text-slate-300 hover:bg-white/10"
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-semibold">{mission.label}</span>
|
||||
<span className="mt-2 block text-xs leading-relaxed text-slate-400">{mission.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ duration: 0.55 }}
|
||||
className="rounded-[32px] border border-white/10 bg-gradient-to-br from-white/10 via-white/5 to-sky-500/10 p-6 shadow-[0_0_120px_rgba(56,189,248,0.14)]"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-slate-400">Your surge package</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-white">${amount}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-emerald-400/30 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-100">
|
||||
~{impact.credits.toLocaleString()} BLW after webhook credit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-2">
|
||||
{presetAmounts.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => setAmount(preset)}
|
||||
className={`rounded-full px-4 py-2 text-sm font-semibold ${
|
||||
amount === preset ? "bg-white text-slate-950" : "bg-white/10 text-white hover:bg-white/15"
|
||||
}`}
|
||||
>
|
||||
${preset}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<label className="mt-6 block text-sm font-medium text-slate-200" htmlFor="volunteer-hours">
|
||||
Add volunteer hours: <span className="text-white">{volunteerHours}</span>
|
||||
</label>
|
||||
<input
|
||||
id="volunteer-hours"
|
||||
type="range"
|
||||
min="0"
|
||||
max="12"
|
||||
value={volunteerHours}
|
||||
onChange={(event) => setVolunteerHours(Number(event.target.value))}
|
||||
className="mt-3 w-full accent-sky-400"
|
||||
/>
|
||||
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2">
|
||||
<ImpactMetric label="Doors reached" value={impact.doors} />
|
||||
<ImpactMetric label="Persuasion texts" value={impact.texts} />
|
||||
<ImpactMetric label="Ride assists" value={impact.rides} />
|
||||
<ImpactMetric label="Mission focus" value={selectedMission.label} text />
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ImpactMetric({ label, value, text = false }: { label: string; value: number | string; text?: boolean }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/25 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">{label}</p>
|
||||
<p className={`${text ? "text-lg" : "text-3xl"} mt-2 font-semibold text-white`}>
|
||||
{typeof value === "number" ? value.toLocaleString() : value}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/components/IssueGrid.tsx
Normal file
26
src/components/IssueGrid.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import issues from "../../content/issues.json";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export function IssueGrid() {
|
||||
return (
|
||||
<div id="priorities" className="mx-auto grid max-w-6xl gap-6 px-4 sm:grid-cols-2 lg:grid-cols-3 sm:px-6">
|
||||
{issues.map((issue, idx) => (
|
||||
<motion.article
|
||||
key={issue.id}
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: "-80px" }}
|
||||
transition={{ delay: idx * 0.05, duration: 0.5 }}
|
||||
className={`relative overflow-hidden rounded-3xl border border-white/10 bg-gradient-to-br ${issue.accent} p-6 shadow-[0_0_80px_rgba(56,189,248,0.08)]`}
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(255,255,255,0.12),_transparent_55%)]" />
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-slate-300/90">{issue.subtitle}</p>
|
||||
<h3 className="mt-3 text-xl font-semibold text-white">{issue.title}</h3>
|
||||
<p className="mt-3 text-sm leading-relaxed text-slate-200/90">{issue.body}</p>
|
||||
</motion.article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
src/components/MockExchangeTicker.tsx
Normal file
80
src/components/MockExchangeTicker.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { BLW_TICKER } from "@/lib/exchange";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type RatePayload = {
|
||||
symbol: string;
|
||||
blwUsd: number;
|
||||
blwPerUsd: number;
|
||||
updatedAt: number;
|
||||
note: string;
|
||||
sparkline: number[];
|
||||
};
|
||||
|
||||
export function MockExchangeTicker() {
|
||||
const [data, setData] = useState<RatePayload | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/exchange/rate", { cache: "no-store" });
|
||||
if (!res.ok) throw new Error("rate fetch failed");
|
||||
const json = (await res.json()) as RatePayload;
|
||||
if (alive) {
|
||||
setData(json);
|
||||
setErr(null);
|
||||
}
|
||||
} catch {
|
||||
if (alive) setErr("Index unavailable");
|
||||
}
|
||||
};
|
||||
load();
|
||||
const id = setInterval(load, 15_000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (err || !data) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/30 px-4 py-3 text-sm text-slate-400">
|
||||
{err ?? `Loading mock ${BLW_TICKER} index…`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const min = Math.min(...data.sparkline);
|
||||
const max = Math.max(...data.sparkline);
|
||||
const norm = (v: number) => (max === min ? 0.5 : (v - min) / (max - min));
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-sky-500/25 bg-gradient-to-br from-sky-500/10 to-indigo-900/30 px-4 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.28em] text-sky-200/80">
|
||||
{data.symbol} · Blue Wave · mock spot
|
||||
</p>
|
||||
<p className="mt-2 font-mono text-2xl font-semibold text-white">${data.blwUsd.toFixed(4)} USD / BLW</p>
|
||||
<p className="mt-1 text-sm text-slate-300">
|
||||
≈ {data.blwPerUsd.toFixed(2)} BLW per $1 USD <span className="text-slate-500">(index moves over time)</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex h-14 w-40 items-end gap-px">
|
||||
{data.sparkline.map((v, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex-1 rounded-t bg-gradient-to-t from-sky-600/80 to-cyan-300/90"
|
||||
style={{ height: `${12 + norm(v) * 44}px` }}
|
||||
title={`${v.toFixed(4)}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-xs leading-relaxed text-slate-400">{data.note}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/components/OppositionSection.tsx
Normal file
51
src/components/OppositionSection.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export function OppositionSection() {
|
||||
return (
|
||||
<section id="accountability" className="border-b border-white/10 py-16">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.55 }}
|
||||
className="relative overflow-hidden rounded-[32px] border border-rose-500/25 bg-gradient-to-br from-rose-500/15 via-slate-900/80 to-indigo-900/60 p-8 sm:p-12"
|
||||
>
|
||||
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(248,113,113,0.28),_transparent_55%)]" />
|
||||
<p className="text-xs uppercase tracking-[0.32em] text-rose-100/80">Accountability frame</p>
|
||||
<h2 className="mt-4 max-w-3xl text-3xl font-semibold text-white sm:text-4xl">
|
||||
A professional alternative to MAGA chaos—not a mirror of it.
|
||||
</h2>
|
||||
<p className="mt-6 max-w-3xl text-base leading-relaxed text-slate-100/90">
|
||||
The current Republican leadership—including Donald Trump—has normalized corruption-as-branding,
|
||||
weaponized public office against opponents, and treated democratic guardrails as inconveniences.
|
||||
This platform exists to fund organizing that restores transparency, protects elections, and proves
|
||||
that policy victories beat performative cruelty.
|
||||
</p>
|
||||
<ul className="mt-8 grid gap-4 text-sm text-slate-100/90 sm:grid-cols-2">
|
||||
<li className="rounded-2xl border border-white/10 bg-black/30 p-4">
|
||||
<span className="font-semibold text-white">Institutions over impunity:</span>{" "}
|
||||
independent oversight, ethics enforcement, and a politics that punishes self-dealing—not rewards
|
||||
it.
|
||||
</li>
|
||||
<li className="rounded-2xl border border-white/10 bg-black/30 p-4">
|
||||
<span className="font-semibold text-white">Rights over regression:</span>{" "}
|
||||
defending voting access, reproductive healthcare autonomy, and civil liberties from partisan
|
||||
capture.
|
||||
</li>
|
||||
<li className="rounded-2xl border border-white/10 bg-black/30 p-4">
|
||||
<span className="font-semibold text-white">Evidence over conspiracy:</span>{" "}
|
||||
climate action, public health readiness, and tech accountability grounded in science and law.
|
||||
</li>
|
||||
<li className="rounded-2xl border border-white/10 bg-black/30 p-4">
|
||||
<span className="font-semibold text-white">Solidarity over scapegoating:</span>{" "}
|
||||
economic fairness that lifts workers without feeding the politics of division.
|
||||
</li>
|
||||
</ul>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
76
src/components/ParticleField.tsx
Normal file
76
src/components/ParticleField.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
type Particle = { x: number; y: number; vx: number; vy: number; r: number; a: number };
|
||||
|
||||
export function ParticleField() {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = ref.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const reduced =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduced) return;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
let raf = 0;
|
||||
let particles: Particle[] = [];
|
||||
|
||||
const resize = () => {
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const { clientWidth, clientHeight } = canvas;
|
||||
canvas.width = clientWidth * dpr;
|
||||
canvas.height = clientHeight * dpr;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
const count = Math.floor((clientWidth * clientHeight) / 22000);
|
||||
particles = Array.from({ length: Math.max(24, Math.min(count, 120)) }, () => ({
|
||||
x: Math.random() * clientWidth,
|
||||
y: Math.random() * clientHeight,
|
||||
vx: (Math.random() - 0.5) * 0.35,
|
||||
vy: (Math.random() - 0.5) * 0.35,
|
||||
r: Math.random() * 1.6 + 0.4,
|
||||
a: Math.random() * 0.45 + 0.12,
|
||||
}));
|
||||
};
|
||||
|
||||
const tick = () => {
|
||||
const { clientWidth: w, clientHeight: h } = canvas;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
for (const p of particles) {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
if (p.x < 0 || p.x > w) p.vx *= -1;
|
||||
if (p.y < 0 || p.y > h) p.vy *= -1;
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = `rgba(56,189,248,${p.a})`;
|
||||
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
raf = requestAnimationFrame(tick);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(raf);
|
||||
window.removeEventListener("resize", resize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={ref}
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-0 h-full w-full opacity-70"
|
||||
/>
|
||||
);
|
||||
}
|
||||
84
src/components/ProgressSection.tsx
Normal file
84
src/components/ProgressSection.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useSpring, useTransform } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Stats = {
|
||||
raisedUsd: number;
|
||||
goalUsd: number;
|
||||
donationCount: number;
|
||||
uniqueDonors: number;
|
||||
};
|
||||
|
||||
export function ProgressSection() {
|
||||
const [stats, setStats] = useState<Stats | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
const res = await fetch("/api/public/stats", { cache: "no-store" });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (alive) setStats(data);
|
||||
};
|
||||
load();
|
||||
const id = setInterval(load, 30000);
|
||||
return () => {
|
||||
alive = false;
|
||||
clearInterval(id);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const goal = stats?.goalUsd ?? 250000;
|
||||
const pct = stats ? Math.min(100, Math.round((stats.raisedUsd / goal) * 100)) : 0;
|
||||
const spring = useSpring(pct, { stiffness: 120, damping: 20 });
|
||||
const width = useTransform(spring, (v) => `${v}%`);
|
||||
|
||||
useEffect(() => {
|
||||
spring.set(pct);
|
||||
}, [pct, spring]);
|
||||
|
||||
return (
|
||||
<section className="border-b border-white/10 bg-gradient-to-b from-[#030712] to-[#050b1f] py-16">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6">
|
||||
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.32em] text-slate-400">Grassroots meter</p>
|
||||
<h2 className="mt-3 text-3xl font-semibold text-white sm:text-4xl">Momentum you can see</h2>
|
||||
<p className="mt-3 max-w-xl text-slate-400">
|
||||
Every dollar is a choice about whose voice counts. We publish aggregate totals so supporters can
|
||||
feel the collective lift—without gamifying human dignity.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm text-slate-200 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Raised</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-white">
|
||||
{stats ? `$${stats.raisedUsd.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : "—"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Donations</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-white">{stats?.donationCount ?? "—"}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-400">Supporters</p>
|
||||
<p className="mt-2 text-2xl font-semibold text-white">{stats?.uniqueDonors ?? "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-10">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>$0</span>
|
||||
<span>
|
||||
Goal ${stats?.goalUsd?.toLocaleString() ?? "—"} (demo target via PUBLIC_CAMPAIGN_GOAL_USD)
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 h-4 overflow-hidden rounded-full border border-white/10 bg-black/40">
|
||||
<motion.div style={{ width }} className="h-full rounded-full bg-gradient-to-r from-sky-400 via-indigo-400 to-fuchsia-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
7
src/components/Providers.tsx
Normal file
7
src/components/Providers.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>;
|
||||
}
|
||||
53
src/components/RewardsPreview.tsx
Normal file
53
src/components/RewardsPreview.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import Link from "next/link";
|
||||
|
||||
const perks = [
|
||||
{
|
||||
title: "Digital yard sign pack",
|
||||
cost: "15 BLW",
|
||||
body: "Printable, shareable visibility assets for people who want to do more than click donate.",
|
||||
},
|
||||
{
|
||||
title: "Supporter badge",
|
||||
cost: "8 BLW",
|
||||
body: "Profile flair for the early crew, useful for future leaderboards and volunteer recognition.",
|
||||
},
|
||||
{
|
||||
title: "Grassroots gear raffle",
|
||||
cost: "5 BLW / ticket",
|
||||
body: "A lightweight proof of the rewards engine: spend credits, record the ledger, refresh the wallet.",
|
||||
},
|
||||
];
|
||||
|
||||
export function RewardsPreview() {
|
||||
return (
|
||||
<section className="border-b border-white/10 bg-gradient-to-b from-[#030712] to-[#08111f] py-20">
|
||||
<div className="mx-auto grid max-w-6xl gap-8 px-4 sm:px-6 lg:grid-cols-[0.85fr_1.15fr]">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-[0.32em] text-fuchsia-200/75">Supporter economy</p>
|
||||
<h2 className="mt-4 text-3xl font-semibold text-white sm:text-4xl">
|
||||
Give people a reason to come back after the receipt.
|
||||
</h2>
|
||||
<p className="mt-4 text-slate-400">
|
||||
Donations create BLW (Blue Wave) credits only after Stripe confirms payment. The wallet turns that proof into perks,
|
||||
raffles, and future campaign experiences.
|
||||
</p>
|
||||
<Link
|
||||
href="/wallet"
|
||||
className="mt-8 inline-flex rounded-full bg-gradient-to-r from-fuchsia-500 to-sky-500 px-6 py-3 text-sm font-semibold text-white shadow-xl shadow-fuchsia-500/20"
|
||||
>
|
||||
Explore the wallet
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{perks.map((perk) => (
|
||||
<article key={perk.title} className="rounded-3xl border border-white/10 bg-black/25 p-5">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-fuchsia-200/80">{perk.cost}</p>
|
||||
<h3 className="mt-4 text-lg font-semibold text-white">{perk.title}</h3>
|
||||
<p className="mt-3 text-sm leading-relaxed text-slate-400">{perk.body}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
22
src/components/SiteFooter.tsx
Normal file
22
src/components/SiteFooter.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export function SiteFooter() {
|
||||
const disclaimer =
|
||||
process.env.NEXT_PUBLIC_DISCLAIMER_TEXT ??
|
||||
process.env.DISCLAIMER_TEXT ??
|
||||
"This application is a technical demonstration for local deployment. It is not legal or FEC advice. Configure committee disclosures before accepting live contributions.";
|
||||
|
||||
return (
|
||||
<footer className="border-t border-white/10 bg-[#020617] py-12">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-6 px-4 text-sm text-slate-500 sm:px-6">
|
||||
<p className="leading-relaxed">{disclaimer}</p>
|
||||
<p className="text-xs text-slate-600">
|
||||
Committee placeholder:{" "}
|
||||
<span className="text-slate-400">
|
||||
{process.env.NEXT_PUBLIC_COMMITTEE_LEGAL_NAME_PLACEHOLDER ??
|
||||
process.env.COMMITTEE_LEGAL_NAME_PLACEHOLDER ??
|
||||
"Configure COMMITTEE_LEGAL_NAME_PLACEHOLDER"}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
61
src/components/SiteNav.tsx
Normal file
61
src/components/SiteNav.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/auth";
|
||||
import { appTitle } from "@/lib/public-env";
|
||||
|
||||
export async function SiteNav() {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-white/10 bg-[#030712]/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4 sm:px-6">
|
||||
<Link href="/" className="group flex items-baseline gap-2">
|
||||
<span className="bg-gradient-to-r from-sky-300 via-indigo-300 to-fuchsia-300 bg-clip-text text-xl font-semibold tracking-tight text-transparent">
|
||||
{appTitle()}
|
||||
</span>
|
||||
<span className="hidden text-xs uppercase tracking-[0.28em] text-slate-500 sm:inline">
|
||||
Grassroots fund
|
||||
</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-3 text-sm text-slate-200">
|
||||
<Link className="hidden rounded-full px-3 py-1.5 hover:bg-white/5 md:inline-flex" href="/#impact">
|
||||
Impact
|
||||
</Link>
|
||||
<Link className="hidden rounded-full px-3 py-1.5 hover:bg-white/5 md:inline-flex" href="/#actions">
|
||||
Actions
|
||||
</Link>
|
||||
<Link className="rounded-full px-3 py-1.5 hover:bg-white/5" href="/raised">
|
||||
Raised
|
||||
</Link>
|
||||
<Link className="rounded-full px-3 py-1.5 hover:bg-white/5" href="/#priorities">
|
||||
Priorities
|
||||
</Link>
|
||||
<Link className="rounded-full px-3 py-1.5 hover:bg-white/5" href="/#donate">
|
||||
Donate
|
||||
</Link>
|
||||
{session?.user ? (
|
||||
<>
|
||||
<Link
|
||||
className="rounded-full bg-white/10 px-4 py-2 font-medium text-white hover:bg-white/15"
|
||||
href="/wallet"
|
||||
>
|
||||
Wallet
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link className="rounded-full px-3 py-1.5 hover:bg-white/5" href="/login">
|
||||
Sign in
|
||||
</Link>
|
||||
<Link
|
||||
className="rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-4 py-2 font-semibold text-white shadow-lg shadow-sky-500/25"
|
||||
href="/register"
|
||||
>
|
||||
Join
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
39
src/components/SupporterFeed.tsx
Normal file
39
src/components/SupporterFeed.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const feed = [
|
||||
"A nurse in Phoenix just turned $20 into a five-ticket raffle push.",
|
||||
"A student organizer in Madison unlocked the digital yard sign pack.",
|
||||
"A retired teacher in Atlanta recruited four first-time monthly donors.",
|
||||
"A union steward in Detroit paired a $50 gift with a Saturday canvass.",
|
||||
"A parent in Raleigh sent the healthcare card to a neighborhood chat.",
|
||||
"A volunteer in Las Vegas used BLW credits to enter the gear drop.",
|
||||
];
|
||||
|
||||
export function SupporterFeed() {
|
||||
return (
|
||||
<section className="overflow-hidden border-b border-white/10 bg-[#020617] py-6">
|
||||
<div className="mx-auto flex max-w-6xl items-center gap-4 px-4 sm:px-6">
|
||||
<p className="shrink-0 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs uppercase tracking-[0.22em] text-sky-200">
|
||||
Live spark
|
||||
</p>
|
||||
<div className="relative min-w-0 flex-1 overflow-hidden">
|
||||
<motion.div
|
||||
className="flex w-max gap-8 text-sm text-slate-300"
|
||||
animate={{ x: ["0%", "-50%"] }}
|
||||
transition={{ duration: 38, repeat: Infinity, ease: "linear" }}
|
||||
>
|
||||
{[...feed, ...feed].map((item, index) => (
|
||||
<span key={`${item}-${index}`} className="whitespace-nowrap">
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</motion.div>
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 w-12 bg-gradient-to-r from-[#020617] to-transparent" />
|
||||
<div className="pointer-events-none absolute inset-y-0 right-0 w-12 bg-gradient-to-l from-[#020617] to-transparent" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
6
src/lib/admin.ts
Normal file
6
src/lib/admin.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function isAdminRole(role: string | undefined): boolean {
|
||||
return role === "ADMIN";
|
||||
}
|
||||
|
||||
/** Display / API: treat admins as having unlimited credits for local QA (balance is not decremented on spend). */
|
||||
export const ADMIN_WALLET_DISPLAY = 2_147_483_647;
|
||||
52
src/lib/exchange.ts
Normal file
52
src/lib/exchange.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Self-contained mock "Blue Wave" (BLW) spot index for UX only — not a blockchain asset.
|
||||
* USD donation → BLW credits use the snapshot rate from PaymentIntent creation (server-authoritative).
|
||||
*/
|
||||
|
||||
export const BLW_DISPLAY_NAME = "Blue Wave";
|
||||
export const BLW_TICKER = "BLW";
|
||||
|
||||
/** Allowed Stripe amounts in USD cents — keep fundraising tiers simple. */
|
||||
export const ALLOWED_DONATION_USD_CENTS = [500, 1000, 2000, 10000] as const;
|
||||
|
||||
export type AllowedTierCents = (typeof ALLOWED_DONATION_USD_CENTS)[number];
|
||||
|
||||
export function isAllowedDonationTier(cents: number): cents is AllowedTierCents {
|
||||
return (ALLOWED_DONATION_USD_CENTS as readonly number[]).includes(cents);
|
||||
}
|
||||
|
||||
/** Synthetic USD price of 1 BLW (oscillates smoothly over ~45 min). */
|
||||
export function blwUsdAt(nowMs = Date.now()): number {
|
||||
const BASE = 0.1;
|
||||
const AMP = 0.035;
|
||||
const PERIOD_MS = 45 * 60 * 1000;
|
||||
const raw = BASE + AMP * Math.sin((nowMs / PERIOD_MS) * 2 * Math.PI);
|
||||
return Math.round(raw * 1_000_000) / 1_000_000;
|
||||
}
|
||||
|
||||
/** BLW credits minted for a USD donation at the given BLW/USD spot (integer BLW units). */
|
||||
export function blwCreditsForUsdCents(usdCents: number, blwUsd: number): number {
|
||||
const usd = usdCents / 100;
|
||||
if (blwUsd <= 0 || usd <= 0) return 0;
|
||||
return Math.max(0, Math.floor(usd / blwUsd));
|
||||
}
|
||||
|
||||
/** Approximate USD value of holdings given BLW balance (same integer as wallet credits) at current index. */
|
||||
export function usdValueOfBlwCredits(credits: number, blwUsd: number): number {
|
||||
if (credits <= 0 || blwUsd <= 0) return 0;
|
||||
return Math.round(credits * blwUsd * 100) / 100;
|
||||
}
|
||||
|
||||
/** Sample points for a tiny sparkline (client-only visualization). */
|
||||
export function blwIndexSamples(
|
||||
pointCount: number,
|
||||
nowMs = Date.now(),
|
||||
stepMs = 60_000,
|
||||
): { t: number; blwUsd: number }[] {
|
||||
const out: { t: number; blwUsd: number }[] = [];
|
||||
for (let i = pointCount - 1; i >= 0; i--) {
|
||||
const t = nowMs - i * stepMs;
|
||||
out.push({ t, blwUsd: blwUsdAt(t) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
35
src/lib/prisma.ts
Normal file
35
src/lib/prisma.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { Pool } from "pg";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
throw new Error("DATABASE_URL is not set");
|
||||
}
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
pgPool: Pool | undefined;
|
||||
};
|
||||
|
||||
const pool =
|
||||
globalForPrisma.pgPool ??
|
||||
new Pool({
|
||||
connectionString,
|
||||
max: 10,
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.pgPool = pool;
|
||||
}
|
||||
|
||||
const adapter = new PrismaPg(pool);
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
adapter,
|
||||
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
3
src/lib/public-env.ts
Normal file
3
src/lib/public-env.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function appTitle(): string {
|
||||
return process.env.NEXT_PUBLIC_APP_NAME ?? process.env.PUBLIC_APP_NAME ?? "Democracy Rising";
|
||||
}
|
||||
16
src/lib/stripe.ts
Normal file
16
src/lib/stripe.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import Stripe from "stripe";
|
||||
|
||||
/** SDK requires a string; real charges require a valid key from env (see create-payment-intent guard). */
|
||||
const stripeSecretKey =
|
||||
process.env.STRIPE_SECRET_KEY?.trim() ||
|
||||
"sk_test_disabled_configure_STRIPE_SECRET_KEY";
|
||||
|
||||
/** Pin Stripe API version via SDK default; set STRIPE_API_VERSION only when upgrading SDK intentionally. */
|
||||
export const stripe = new Stripe(stripeSecretKey, {
|
||||
typescript: true,
|
||||
});
|
||||
|
||||
export function creditsFromUsdCents(amountUsdCents: number): number {
|
||||
const ratio = Math.max(1, parseInt(process.env.CREDIT_RATIO_CENTS_PER_USD ?? "100", 10) || 100);
|
||||
return Math.floor(amountUsdCents / ratio);
|
||||
}
|
||||
15
src/middleware.ts
Normal file
15
src/middleware.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export default auth((req) => {
|
||||
const path = req.nextUrl.pathname;
|
||||
if (!req.auth && path.startsWith("/wallet")) {
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
url.searchParams.set("callbackUrl", path);
|
||||
return Response.redirect(url);
|
||||
}
|
||||
});
|
||||
|
||||
export const config = {
|
||||
matcher: ["/wallet/:path*"],
|
||||
};
|
||||
14
src/types/next-auth.d.ts
vendored
Normal file
14
src/types/next-auth.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { DefaultSession } from "next-auth";
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: DefaultSession["user"] & { id: string; role: "USER" | "ADMIN" };
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
id?: string;
|
||||
role?: "USER" | "ADMIN";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user