Add Democratic fundraising platform.

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

View File

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const gameType = url.searchParams.get("gameType");
const rooms = await prisma.gameRoom.findMany({
where: {
status: "WAITING",
expiresAt: { gt: new Date() },
...(gameType ? { gameType: gameType as "COIN_FLIP" | "PONG" } : {}),
},
orderBy: { createdAt: "desc" },
take: 20,
});
return NextResponse.json({ rooms });
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const body = await req.json();
const { gameType, wageBLW } = body as { gameType: "COIN_FLIP" | "PONG"; wageBLW: number };
if (!Number.isInteger(wageBLW) || wageBLW < 1) return NextResponse.json({ error: "Invalid wager" }, { status: 400 });
if (!["COIN_FLIP", "PONG"].includes(gameType)) return NextResponse.json({ error: "Invalid game type" }, { status: 400 });
// Check balance
const wallet = await prisma.wallet.findUnique({ where: { userId: session.user.id } });
if (!wallet || wallet.balanceCredits < wageBLW) {
return NextResponse.json({ error: "Insufficient balance" }, { status: 402 });
}
const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 min
const room = await prisma.gameRoom.create({
data: {
gameType,
creatorId: session.user.id,
wageBLW,
expiresAt,
},
});
return NextResponse.json({ room });
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,90 @@
import Link from "next/link";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import { appTitle } from "@/lib/public-env";
export function MissionStatement() {
const title = appTitle();
const ticker = creditTicker();
const name = creditDisplayName();
return (
<section id="mission" className="scroll-mt-28 border-b border-white/10 bg-[#030712] py-10">
<div className="mx-auto max-w-6xl px-4 text-center sm:px-6">
<p className="text-xs uppercase tracking-[0.32em] text-sky-200/75">Why were here</p>
<h2 className="mx-auto mt-3 max-w-4xl text-3xl font-semibold leading-tight text-white sm:text-4xl">
A big-tent home for people who want democracy to win and want to see how their support helps
</h2>
<p className="mx-auto mt-5 max-w-3xl text-lg leading-relaxed text-slate-400">
<strong className="text-slate-200">{title}</strong> is where small-dollar donors, volunteers, and curious neighbors meet the
same simple story: give if you can, stay for the community, and use{" "}
<strong className="text-slate-200">
{name} ({ticker})
</strong>{" "}
to cheer on field work, rank ideas, pick perks, and play a small part in something larger than a single campaign cycle.
</p>
<p className="mx-auto mt-4 max-w-3xl text-base leading-relaxed text-slate-500">
You do not need to be a political junkie. If you care about fair elections, good jobs, affordable care, and a country where
more voices count, you already belong. The tools below missions, initiatives, polls, wallet, and games are all optional
ways to stay involved after your first gift.
</p>
<div className="mt-8 rounded-3xl border border-white/10 bg-white/[0.04] p-5 text-left sm:p-6">
<p className="text-center text-xs uppercase tracking-[0.28em] text-slate-400">Pick your next step</p>
<p className="mx-auto mt-2 max-w-2xl text-center text-sm leading-relaxed text-slate-500">
Every link below opens something real you can do today no insider knowledge required.
</p>
<ul className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{[
{
title: "Mission pledges",
body: "Tell organizers which fights to fund first — voting access, climate jobs, care, and more.",
href: "/missions",
},
{
title: "Democratic initiatives",
body: "Publish one idea per account, or back someone elses — totals show what the crowd wants next.",
href: "/initiatives",
},
{
title: "Straw poll",
body: "Cast ballots with your credits — a temperature check, not an official election.",
href: "/vote/next-president",
},
{
title: "Digital perks & raffles",
body: "Redeem rewards and enter drawings when the committee drops new SKUs.",
href: "/wallet",
},
{
title: "Supporter games",
body: "Optional skill and chance rooms — sign in, bring credits, play responsibly.",
href: "/casino",
},
{
title: "Live totals board",
body: "See dollars raised, gift count, and goal progress in one calm, public view.",
href: "/raised",
},
{
title: "Give now",
body: "Pick a tier, lock your rate at checkout, and add your name to the meter.",
href: "/donate",
},
].map((item) => (
<li key={item.href}>
<Link
href={item.href}
className="group flex min-h-[9.5rem] flex-col items-center rounded-2xl border border-white/10 bg-black/30 p-4 text-center transition hover:border-sky-400/40 hover:bg-white/[0.06]"
>
<p className="font-semibold leading-snug text-white">{item.title}</p>
<p className="mt-2 flex-1 text-sm leading-relaxed text-slate-500">{item.body}</p>
<p className="mt-3 text-xs font-medium text-sky-300 group-hover:text-white">Open </p>
</Link>
</li>
))}
</ul>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
type Props = {
loggedIn: boolean;
};
const HEADER_OFFSET = "3.5rem"; /* h-14 */
export function MobileNav({ loggedIn }: Props) {
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const pathname = usePathname();
useEffect(() => {
setMounted(true);
}, []);
useEffect(() => {
setOpen(false);
}, [pathname]);
useEffect(() => {
if (open) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
return () => {
document.body.style.overflow = "";
};
}, [open]);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [open]);
useEffect(() => {
const mq = window.matchMedia("(min-width: 768px)");
const close = () => {
if (mq.matches) setOpen(false);
};
mq.addEventListener("change", close);
return () => mq.removeEventListener("change", close);
}, []);
const drawer =
open && mounted ? (
<>
<div
className="fixed inset-x-0 bottom-0 z-[10050] bg-black/60 backdrop-blur-sm md:hidden"
style={{ top: HEADER_OFFSET }}
aria-hidden
onClick={() => setOpen(false)}
/>
<nav
id="mobile-nav-drawer"
className="fixed inset-x-0 bottom-0 z-[10051] flex max-h-[calc(100dvh-3.5rem-env(safe-area-inset-bottom,0px))] flex-col gap-1 overflow-y-auto overscroll-contain border-b border-white/10 bg-[#030712]/98 px-4 pt-4 backdrop-blur-xl md:hidden touch-pan-y"
style={{
top: HEADER_OFFSET,
paddingBottom: "max(1.25rem, env(safe-area-inset-bottom, 0px))",
}}
aria-label="Mobile navigation"
>
<MobileLink href="/raised" label="Disclosure hall" onNavigate={() => setOpen(false)} />
<MobileLink href="/missions" label="Mission pledges" onNavigate={() => setOpen(false)} />
<MobileLink href="/initiatives" label="Democratic initiatives" onNavigate={() => setOpen(false)} />
<MobileLink href="/vote/next-president" label="Straw poll" onNavigate={() => setOpen(false)} />
{loggedIn ? <MobileLink href="/casino" label="Casino" onNavigate={() => setOpen(false)} /> : null}
<div className="my-2 border-t border-white/10" />
<MobileLink href="/#mission" label="Mission" onNavigate={() => setOpen(false)} />
<MobileLink href="/#start" label="Start here" onNavigate={() => setOpen(false)} />
<MobileLink href="/#impact" label="Impact" onNavigate={() => setOpen(false)} />
<MobileLink href="/#actions" label="Actions" onNavigate={() => setOpen(false)} />
<MobileLink href="/#priorities" label="Priorities" onNavigate={() => setOpen(false)} />
<MobileLink href="/#meter" label="Live meter" onNavigate={() => setOpen(false)} />
<MobileLink href="/#leaderboard" label="Top donors" onNavigate={() => setOpen(false)} />
<MobileLink href="/donate" label="Donate" onNavigate={() => setOpen(false)} />
<MobileLink href="/#faq" label="FAQ" onNavigate={() => setOpen(false)} />
<div className="my-2 border-t border-white/10" />
{loggedIn ? (
<MobileLink href="/wallet" label="Wallet" highlight onNavigate={() => setOpen(false)} />
) : (
<>
<MobileLink href="/login" label="Sign in" onNavigate={() => setOpen(false)} />
<MobileLink href="/register" label="Join — enroll" highlight onNavigate={() => setOpen(false)} />
</>
)}
</nav>
</>
) : null;
return (
<>
<button
type="button"
aria-label={open ? "Close menu" : "Open menu"}
aria-expanded={open}
aria-controls={open ? "mobile-nav-drawer" : undefined}
onClick={() => setOpen((v) => !v)}
className="relative z-[2] flex min-h-11 min-w-11 shrink-0 flex-col items-center justify-center gap-1.5 rounded-xl border border-white/15 bg-[#030712]/90 md:hidden"
>
<span
className={`block h-0.5 w-5 rounded-full bg-white transition-transform duration-200 ${open ? "translate-y-2 rotate-45" : ""}`}
/>
<span
className={`block h-0.5 w-5 rounded-full bg-white transition-opacity duration-200 ${open ? "opacity-0" : ""}`}
/>
<span
className={`block h-0.5 w-5 rounded-full bg-white transition-transform duration-200 ${open ? "-translate-y-2 -rotate-45" : ""}`}
/>
</button>
{drawer ? createPortal(drawer, document.body) : null}
</>
);
}
function MobileLink({
href,
label,
highlight = false,
onNavigate,
}: {
href: string;
label: string;
highlight?: boolean;
onNavigate?: () => void;
}) {
return (
<Link
href={href}
onClick={onNavigate}
className={`min-h-12 rounded-xl px-4 py-3 text-base font-medium leading-snug transition active:bg-white/10 ${
highlight
? "bg-gradient-to-r from-sky-500/20 to-indigo-500/20 text-white"
: "text-slate-200 hover:bg-white/5"
}`}
>
{label}
</Link>
);
}

View File

@@ -1,7 +1,16 @@
"use client";
import { BLW_TICKER } from "@/lib/exchange";
import { useEffect, useState } from "react";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import {
TREASURY_KICKSTART_USD_CENTS,
treasurySparklineUsdPerCredit,
usdPerBwtFromTreasury,
} from "@/lib/treasury-math";
import { motion, AnimatePresence } from "framer-motion";
import { useEffect, useRef, useState } from "react";
const TICKER = creditTicker();
const CREDIT_LONG = creditDisplayName();
type RatePayload = {
symbol: string;
@@ -12,68 +21,152 @@ type RatePayload = {
sparkline: number[];
};
function localFallbackPayload(): RatePayload {
const treasuryUsdCents = TREASURY_KICKSTART_USD_CENTS;
const blwUsd = usdPerBwtFromTreasury(treasuryUsdCents);
const blwPerUsd = blwUsd > 0 ? 1 / blwUsd : 0;
return {
symbol: TICKER,
blwUsd,
blwPerUsd,
updatedAt: Date.now(),
note: `${CREDIT_LONG} (${TICKER}) baseline index — loading live treasury rollup…`,
sparkline: treasurySparklineUsdPerCredit(treasuryUsdCents, 48),
};
}
function normalizePayload(raw: unknown): RatePayload | null {
if (!raw || typeof raw !== "object") return null;
const j = raw as Record<string, unknown>;
const blwUsd = typeof j.blwUsd === "number" ? j.blwUsd : Number(j.blwUsd);
const blwPerUsd = typeof j.blwPerUsd === "number" ? j.blwPerUsd : Number(j.blwPerUsd);
const sparkline = Array.isArray(j.sparkline)
? j.sparkline.filter((x): x is number => typeof x === "number" && Number.isFinite(x))
: [];
const symbol = typeof j.symbol === "string" ? j.symbol : TICKER;
const note = typeof j.note === "string" ? j.note : "";
const updatedAt = typeof j.updatedAt === "number" ? j.updatedAt : Date.now();
if (!Number.isFinite(blwUsd) || blwUsd <= 0) return null;
if (!Number.isFinite(blwPerUsd) || blwPerUsd <= 0) return null;
const spark = sparkline.length > 0 ? sparkline : treasurySparklineUsdPerCredit(TREASURY_KICKSTART_USD_CENTS, 48);
return {
symbol,
blwUsd,
blwPerUsd,
updatedAt,
note: note || localFallbackPayload().note,
sparkline: spark,
};
}
export function MockExchangeTicker() {
const [data, setData] = useState<RatePayload | null>(null);
const [err, setErr] = useState<string | null>(null);
const [data, setData] = useState<RatePayload>(() => localFallbackPayload());
const [prev, setPrev] = useState<number | null>(null);
const [live, setLive] = useState(false);
const priorUsd = useRef<number | null>(null);
useEffect(() => {
let alive = true;
const load = async () => {
const controller = new AbortController();
const timeoutId = globalThis.setTimeout(() => controller.abort(), 15_000);
try {
const res = await fetch("/api/exchange/rate", { cache: "no-store" });
const res = await fetch("/api/exchange/rate", {
cache: "no-store",
signal: controller.signal,
});
globalThis.clearTimeout(timeoutId);
if (!res.ok) throw new Error("rate fetch failed");
const json = (await res.json()) as RatePayload;
if (alive) {
setData(json);
setErr(null);
}
const json = await res.json();
const normalized = normalizePayload(json);
if (!normalized) throw new Error("bad payload");
if (!alive) return;
setPrev(priorUsd.current);
priorUsd.current = normalized.blwUsd;
setData(normalized);
setLive(true);
} catch {
if (alive) setErr("Index unavailable");
globalThis.clearTimeout(timeoutId);
if (!alive) return;
setLive(false);
priorUsd.current = null;
setPrev(null);
setData({
...localFallbackPayload(),
note: `${CREDIT_LONG} (${TICKER}) — live treasury unavailable (check server / database). Baseline curve shown.`,
});
}
};
load();
const id = setInterval(load, 15_000);
void load();
const id = globalThis.setInterval(() => void load(), 30_000);
return () => {
alive = false;
clearInterval(id);
globalThis.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 spark =
data.sparkline.length > 0 ? data.sparkline : treasurySparklineUsdPerCredit(TREASURY_KICKSTART_USD_CENTS, 48);
const min = Math.min(...spark);
const max = Math.max(...spark);
const norm = (v: number) => (max === min ? 0.5 : (v - min) / (max - min));
const trending = prev !== null ? (data.blwUsd >= prev ? "up" : "down") : "up";
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>
<div className="relative overflow-hidden rounded-2xl border border-sky-500/25 bg-gradient-to-br from-sky-500/10 to-indigo-900/30 px-4 py-4">
{/* shimmer sweep */}
<div className="pointer-events-none absolute inset-0 -translate-x-full animate-shimmer rounded-2xl" />
<div className="flex min-w-0 flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<p className="text-xs uppercase tracking-[0.28em] text-sky-200/80">
{data.symbol} · Blue Wave · mock spot
{data.symbol} · {CREDIT_LONG} · {live ? "live treasury spot" : "baseline spot"}
</p>
<p className="mt-2 font-mono text-2xl font-semibold text-white">${data.blwUsd.toFixed(4)} USD / BLW</p>
<div className="mt-2 flex items-baseline gap-2">
<AnimatePresence mode="wait">
<motion.p
key={data.blwUsd.toFixed(4)}
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 6 }}
transition={{ duration: 0.3 }}
className="break-all font-mono text-xl font-bold text-white sm:text-2xl"
>
${data.blwUsd.toFixed(4)}
<span className="ml-1 text-base font-normal text-slate-400"> USD / {TICKER}</span>
</motion.p>
</AnimatePresence>
<span className={`text-sm font-semibold ${trending === "up" ? "text-emerald-400" : "text-rose-400"}`}>
{trending === "up" ? "▲" : "▼"}
</span>
</div>
<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>
{data.blwPerUsd.toFixed(2)} {TICKER} per $1{" "}
<span className="text-slate-500">({live ? "live index" : "offline baseline"})</span>
</p>
</div>
<div className="flex h-14 w-40 items-end gap-px">
{data.sparkline.map((v, i) => (
<div
{/* Sparkline */}
<div className="flex h-14 w-full items-end gap-px sm:w-40 sm:flex-shrink-0">
{spark.map((v, i) => (
<motion.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)}`}
initial={{ height: 0 }}
animate={{ height: `${12 + norm(v) * 44}px` }}
transition={{ delay: i * 0.015, duration: 0.4, ease: "easeOut" }}
className="min-w-[5px] flex-1 rounded-t bg-gradient-to-t from-sky-600/80 to-cyan-300/90"
title={`$${v.toFixed(4)}`}
/>
))}
</div>
</div>
<p className="mt-3 text-xs leading-relaxed text-slate-400">{data.note}</p>
</div>
);

View File

@@ -2,48 +2,107 @@
import { motion } from "framer-motion";
const pillars = [
{
title: "Institutions over impunity",
body: "Independent oversight, ethics enforcement, and a politics that punishes self-dealing — not rewards it.",
color: "border-rose-400/25 bg-rose-500/8",
dot: "bg-rose-400",
},
{
title: "Rights over regression",
body: "Defending voting access, reproductive healthcare autonomy, and civil liberties from partisan capture.",
color: "border-sky-400/25 bg-sky-500/8",
dot: "bg-sky-400",
},
{
title: "Evidence over conspiracy",
body: "Climate action, public health readiness, and tech accountability grounded in science and law.",
color: "border-emerald-400/25 bg-emerald-500/8",
dot: "bg-emerald-400",
},
{
title: "Solidarity over scapegoating",
body: "Economic fairness that lifts workers without feeding the politics of division.",
color: "border-indigo-400/25 bg-indigo-500/8",
dot: "bg-indigo-400",
},
];
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">
<section id="accountability" className="relative overflow-hidden border-b border-white/10 py-10">
<div className="mx-auto max-w-6xl px-4 text-center sm:px-6">
<motion.div
initial={{ opacity: 0, y: 16 }}
initial={{ opacity: 0, y: 20 }}
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"
transition={{ duration: 0.6 }}
className="relative overflow-hidden rounded-[28px] border border-rose-500/20 bg-gradient-to-br from-rose-500/12 via-slate-900/85 to-indigo-900/60 p-6 text-left sm:rounded-[32px] sm:p-8"
>
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,_rgba(248,113,113,0.28),_transparent_55%)]" />
<p className="text-xs uppercase tracking-[0.32em] text-rose-100/80">Accountability frame</p>
<h2 className="mt-4 max-w-3xl text-3xl font-semibold text-white sm:text-4xl">
A professional alternative to MAGA chaosnot a mirror of it.
</h2>
<p className="mt-6 max-w-3xl text-base leading-relaxed text-slate-100/90">
The current Republican leadershipincluding Donald Trumphas normalized corruption-as-branding,
weaponized public office against opponents, and treated democratic guardrails as inconveniences.
This platform exists to fund organizing that restores transparency, protects elections, and proves
that policy victories beat performative cruelty.
</p>
<ul className="mt-8 grid gap-4 text-sm text-slate-100/90 sm:grid-cols-2">
<li className="rounded-2xl border border-white/10 bg-black/30 p-4">
<span className="font-semibold text-white">Institutions over impunity:</span>{" "}
independent oversight, ethics enforcement, and a politics that punishes self-dealingnot rewards
it.
</li>
<li className="rounded-2xl border border-white/10 bg-black/30 p-4">
<span className="font-semibold text-white">Rights over regression:</span>{" "}
defending voting access, reproductive healthcare autonomy, and civil liberties from partisan
capture.
</li>
<li className="rounded-2xl border border-white/10 bg-black/30 p-4">
<span className="font-semibold text-white">Evidence over conspiracy:</span>{" "}
climate action, public health readiness, and tech accountability grounded in science and law.
</li>
<li className="rounded-2xl border border-white/10 bg-black/30 p-4">
<span className="font-semibold text-white">Solidarity over scapegoating:</span>{" "}
economic fairness that lifts workers without feeding the politics of division.
</li>
</ul>
{/* Multi-layer glow */}
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_top_left,rgba(248,113,113,0.25),transparent_50%),radial-gradient(circle_at_bottom_right,rgba(99,102,241,0.18),transparent_50%)]" />
{/* Subtle grid */}
<div
aria-hidden
className="absolute inset-0 rounded-[36px] opacity-[0.04]"
style={{
backgroundImage:
"linear-gradient(rgba(255,255,255,0.5) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,0.5) 1px,transparent 1px)",
backgroundSize: "40px 40px",
}}
/>
<div className="relative">
<p className="text-xs uppercase tracking-[0.32em] text-rose-200/80">Accountability frame</p>
<motion.h2
initial={{ opacity: 0, y: 12 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1, duration: 0.55 }}
className="mx-auto mt-3 max-w-3xl text-3xl font-bold text-white sm:text-4xl"
>
A professional alternative to MAGA chaos {" "}
<span className="bg-gradient-to-r from-rose-200 to-indigo-200 bg-clip-text text-transparent">
not a mirror of it.
</span>
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={{ delay: 0.2, duration: 0.5 }}
className="mx-auto mt-4 max-w-3xl text-base leading-relaxed text-slate-100/85"
>
The current Republican leadership 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.
</motion.p>
<div className="mt-6 grid gap-3 text-sm sm:grid-cols-2">
{pillars.map((p, i) => (
<motion.div
key={p.title}
initial={{ opacity: 0, y: 14 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.25 + i * 0.08, duration: 0.45 }}
whileHover={{ scale: 1.02 }}
className={`rounded-2xl border ${p.color} p-4 backdrop-blur-sm`}
>
<div className="flex items-start gap-3">
<div className={`mt-1 h-2 w-2 shrink-0 rounded-full ${p.dot}`} />
<div>
<p className="font-bold text-white">{p.title}:</p>
<p className="mt-1 text-slate-200/90">{p.body}</p>
</div>
</div>
</motion.div>
))}
</div>
</div>
</motion.div>
</div>
</section>

View File

@@ -0,0 +1,337 @@
"use client";
import { LOGIN_RETURN_VOTE } from "@/lib/auth-links";
import { creditDisplayName } from "@/lib/credits-brand";
import { motion, AnimatePresence } from "framer-motion";
import Link from "next/link";
import { useSession } from "next-auth/react";
import { useCallback, useEffect, useMemo, useState } from "react";
type PollPayload = {
costCredits: number;
creditName: string;
totalBallots: number;
uniqueCandidates: number;
tallies: { normalizedKey: string; label: string; count: number; pct: number }[];
dailyActivity: { date: string; count: number }[];
recentBallots: { name: string; at: string }[];
you: { voted: boolean; yourChoice?: string } | null;
};
function timeAgo(iso: string): string {
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (s < 45) return "just now";
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
return `${Math.floor(s / 86400)}d ago`;
}
export function PresidentialPoll() {
const { data: session, status: sessionStatus } = useSession();
const [data, setData] = useState<PollPayload | null>(null);
const [balance, setBalance] = useState<number | null>(null);
const [infinite, setInfinite] = useState(false);
const [writeIn, setWriteIn] = useState("");
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [celebrate, setCelebrate] = useState(false);
const load = useCallback(async () => {
const [pollRes, walletRes] = await Promise.all([
fetch("/api/polls/next-president", { cache: "no-store" }),
session?.user
? fetch("/api/wallet", { cache: "no-store" })
: Promise.resolve(null as Response | null),
]);
if (!pollRes.ok) return;
const j = (await pollRes.json()) as PollPayload;
setData(j);
if (walletRes && walletRes.ok) {
const w = await walletRes.json();
setBalance(w.balanceCredits ?? 0);
setInfinite(!!w.infiniteCredits);
} else {
setBalance(null);
setInfinite(false);
}
}, [session?.user]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
const id = setInterval(load, 12000);
return () => clearInterval(id);
}, [load]);
const maxCount = useMemo(() => {
if (!data?.tallies?.length) return 1;
return Math.max(...data.tallies.map((t) => t.count), 1);
}, [data?.tallies]);
const dailyMax = useMemo(() => {
if (!data?.dailyActivity?.length) return 1;
return Math.max(...data.dailyActivity.map((d) => d.count), 1);
}, [data?.dailyActivity]);
const podium = data?.tallies.slice(0, 3) ?? [];
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (!session?.user || busy) return;
setBusy(true);
setErr(null);
const res = await fetch("/api/polls/next-president", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ writeIn }),
});
const body = await res.json().catch(() => ({}));
setBusy(false);
if (!res.ok) {
setErr(typeof body.error === "string" ? body.error : "Something went wrong");
return;
}
setWriteIn("");
setCelebrate(true);
setTimeout(() => setCelebrate(false), 4200);
await load();
};
const cost = data?.costCredits ?? 5;
const creditName = data?.creditName ?? creditDisplayName();
const loginHref = LOGIN_RETURN_VOTE;
const canVote =
session?.user &&
data?.you?.voted === false &&
(infinite || (balance !== null && balance >= cost));
if (!data) {
return (
<div className="flex min-h-[40vh] items-center justify-center text-slate-500">
<span className={sessionStatus === "loading" ? "animate-pulse" : ""}>Loading the ballot box</span>
</div>
);
}
return (
<div className="relative mx-auto max-w-6xl px-4 pb-24 pt-10 sm:px-6">
<AnimatePresence>
{celebrate ? (
<motion.div
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
className="pointer-events-none fixed inset-x-0 top-20 z-50 mx-auto flex max-w-lg justify-center px-4 pt-[env(safe-area-inset-top,0px)] sm:top-24"
>
<div className="rounded-2xl border border-emerald-400/50 bg-emerald-500/20 px-6 py-4 text-center shadow-[0_0_60px_rgba(16,185,129,0.35)]">
<p className="text-lg font-semibold text-white">Ballot cast!</p>
<p className="mt-1 text-sm text-emerald-100/90">Your write-in is live in the crowd chart. Go tell three friends.</p>
</div>
</motion.div>
) : null}
</AnimatePresence>
<div className="text-center">
<p className="text-xs uppercase tracking-[0.32em] text-fuchsia-200/80">Supporter straw poll</p>
<h1 className="mt-4 bg-gradient-to-r from-sky-200 via-fuchsia-200 to-amber-200 bg-clip-text text-3xl font-bold tracking-tight text-transparent sm:text-5xl">
Who should be the next president?
</h1>
<p className="mx-auto mt-4 max-w-2xl text-slate-400">
Authorized write-ins only; duplicate spellings aggregate into a single tally.{" "}
<strong className="text-slate-300">Not a government electionsupporter engagement only.</strong> Each ballot spends{" "}
{creditName} from your ledger like other interactive features.
</p>
</div>
<div className="mt-12 grid gap-10 lg:grid-cols-12 lg:gap-12">
<div className="space-y-8 lg:col-span-5">
<div className="rounded-3xl border border-white/10 bg-white/[0.04] p-6 shadow-[0_0_80px_rgba(56,189,248,0.08)]">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm font-medium text-white">Cast your ballot</p>
<span className="rounded-full border border-amber-400/40 bg-amber-400/15 px-3 py-1 text-xs font-semibold text-amber-100">
{cost} {creditName} per vote
</span>
</div>
{sessionStatus === "unauthenticated" ? (
<div className="mt-6 space-y-4 text-sm text-slate-400">
<p>Sign in so we can enforce one ballot per supporter.</p>
<Link
href={loginHref}
className="inline-flex rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-6 py-2.5 font-semibold text-white"
>
Sign in to vote
</Link>
</div>
) : data?.you?.voted ? (
<motion.div initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="mt-6 space-y-3">
<p className="text-sm text-emerald-200/90">Youre in. Locked choice:</p>
<p className="text-2xl font-semibold text-white">{data.you.yourChoice}</p>
<p className="text-xs text-slate-500">One vote per accountsame rules as a real party reform push.</p>
</motion.div>
) : (
<form onSubmit={submit} className="mt-6 space-y-4">
<label className="block text-sm text-slate-300">
Write-in name
<input
value={writeIn}
onChange={(e) => setWriteIn(e.target.value)}
placeholder="e.g. your favorite leader"
className="mt-2 w-full rounded-2xl border border-white/10 bg-black/40 px-4 py-3 text-lg text-white outline-none ring-fuchsia-500/30 focus:ring-2"
maxLength={120}
autoComplete="off"
/>
</label>
{session?.user && !infinite && balance !== null && balance < cost ? (
<p className="text-sm text-amber-200/90">
You need {cost} {creditName}.{" "}
<Link href="/donate" className="text-sky-300 underline">
Donate
</Link>{" "}
to mint credits, then come back.
</p>
) : null}
{err ? <p className="text-sm text-rose-300">{err}</p> : null}
<button
type="submit"
disabled={!canVote || busy || !writeIn.trim()}
className="w-full rounded-2xl bg-gradient-to-r from-fuchsia-500 to-amber-500 py-3.5 text-sm font-bold text-white shadow-lg shadow-fuchsia-500/25 disabled:cursor-not-allowed disabled:opacity-40"
>
{busy ? "Sealing envelope…" : `Burn ${cost} ${creditName} & vote`}
</button>
{infinite ? <p className="text-center text-xs text-amber-200/80">Admin mode: no credit burn.</p> : null}
</form>
)}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4 text-center">
<p className="text-xs uppercase text-slate-500">Ballots</p>
<p className="mt-1 text-2xl font-semibold text-white">{data?.totalBallots ?? 0}</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4 text-center">
<p className="text-xs uppercase text-slate-500">Unique names</p>
<p className="mt-1 text-2xl font-semibold text-white">{data?.uniqueCandidates ?? 0}</p>
</div>
<div className="col-span-2 rounded-2xl border border-sky-500/20 bg-sky-500/10 p-4 text-center sm:col-span-1">
<p className="text-xs uppercase text-slate-500">Your wallet</p>
<p className="mt-1 text-2xl font-semibold text-sky-100">
{infinite ? "∞" : balance !== null ? balance.toLocaleString() : "—"}{" "}
<span className="text-sm font-normal text-slate-400">{creditName}</span>
</p>
</div>
</div>
</div>
<div className="space-y-10 lg:col-span-7">
{podium.length > 0 ? (
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Podium (top 3)</p>
<div className="mt-4 flex snap-x snap-mandatory items-end justify-start gap-2 overflow-x-auto pb-2 sm:justify-center sm:gap-4 sm:overflow-visible sm:pb-0">
{[
{ rank: 2, entry: podium[1], h: "h-28", ring: "border-slate-400/40", emoji: "🥈" },
{ rank: 1, entry: podium[0], h: "h-40", ring: "border-amber-300/50", emoji: "🥇" },
{ rank: 3, entry: podium[2], h: "h-24", ring: "border-orange-400/35", emoji: "🥉" },
].map((slot) =>
slot.entry ? (
<motion.div
key={slot.rank}
layout
className={`flex w-[72vw] max-w-[160px] shrink-0 snap-center flex-col justify-end rounded-2xl border ${slot.ring} bg-white/[0.06] p-3 text-center sm:w-[30%] sm:max-w-[160px]`}
>
<span className="text-2xl">{slot.emoji}</span>
<p className="mt-2 line-clamp-2 text-sm font-semibold text-white">{slot.entry.label}</p>
<p className="mt-1 font-mono text-lg text-fuchsia-200">{slot.entry.count}×</p>
<div className={`mt-3 ${slot.h} rounded-lg bg-gradient-to-t from-white/10 to-transparent`} />
</motion.div>
) : (
<div key={slot.rank} className={`hidden w-[72vw] shrink-0 sm:block sm:w-[30%] sm:max-w-[160px] ${slot.h}`} />
),
)}
</div>
</div>
) : (
<div className="rounded-3xl border border-dashed border-white/15 bg-white/[0.02] p-10 text-center text-slate-500">
No ballots yetgo first and watch the bars wake up.
</div>
)}
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Crowd chart</p>
<p className="mt-1 text-sm text-slate-600">Merged by spelling (case-insensitive). Xi × 4 means four people typed the same bucket.</p>
<div className="mt-5 space-y-3">
{(data?.tallies ?? []).map((row, i) => (
<motion.div key={row.normalizedKey} layout initial={{ opacity: 0.6 }} animate={{ opacity: 1 }} className="space-y-1">
<div className="flex items-center justify-between gap-3 text-sm">
<span className="truncate font-medium text-slate-200">
<span className="mr-2 font-mono text-xs text-slate-500">#{i + 1}</span>
{row.label}
</span>
<span className="shrink-0 font-mono text-sky-200">
{row.count}× <span className="text-slate-500">({row.pct}%)</span>
</span>
</div>
<div className="h-3 overflow-hidden rounded-full bg-black/40">
<motion.div
layout
className="h-full rounded-full bg-gradient-to-r from-sky-500 via-fuchsia-500 to-amber-400"
initial={{ width: 0 }}
animate={{ width: `${(row.count / maxCount) * 100}%` }}
transition={{ type: "spring", stiffness: 120, damping: 18 }}
/>
</div>
</motion.div>
))}
</div>
</div>
<div className="rounded-3xl border border-white/10 bg-[#050b1f] p-6">
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Momentum (last ~2 weeks)</p>
<div className="mt-6 overflow-x-auto overscroll-x-contain pb-1 [-webkit-overflow-scrolling:touch]">
<div className="flex h-36 min-w-min items-end justify-between gap-1 px-0.5">
{(data?.dailyActivity ?? []).map((d) => (
<div key={d.date} className="flex w-8 shrink-0 flex-col items-center gap-2 sm:w-10">
<motion.div
layout
className="w-full max-w-[28px] rounded-t-md bg-gradient-to-t from-indigo-600 to-sky-400"
initial={{ height: 0 }}
animate={{ height: `${Math.max(8, (d.count / dailyMax) * 120)}px` }}
transition={{ type: "spring", stiffness: 100, damping: 16 }}
/>
<span className="whitespace-nowrap text-[10px] text-slate-600">{d.date.slice(5)}</span>
</div>
))}
</div>
</div>
{!data.dailyActivity?.length ? (
<p className="mt-4 text-center text-sm text-slate-600">Daily bars appear once votes roll in.</p>
) : null}
</div>
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Latest ballots</p>
<ul className="mt-4 divide-y divide-white/5 rounded-2xl border border-white/10 bg-black/20">
{(data.recentBallots ?? []).length === 0 ? (
<li className="px-4 py-6 text-center text-sm text-slate-600">Quiet be the crackle in the feed.</li>
) : (
data.recentBallots.map((r, idx) => (
<li key={`${r.at}-${idx}`} className="flex items-center justify-between gap-4 px-4 py-3 text-sm">
<span className="truncate text-slate-300">Someone voted <strong className="text-white">{r.name}</strong></span>
<span className="shrink-0 text-xs text-slate-600">{timeAgo(r.at)}</span>
</li>
))
)}
</ul>
</div>
</div>
</div>
<p className="mx-auto mt-16 max-w-3xl text-center text-xs leading-relaxed text-slate-600">
Straw poll / engagement layer only. Not affiliated with any government election. {creditName} has no cash value and is not
transferable off-site.
</p>
</div>
);
}

View File

@@ -1,7 +1,8 @@
"use client";
import { motion, useSpring, useTransform } from "framer-motion";
import { useEffect, useState } from "react";
import { motion, useSpring, useTransform, animate } from "framer-motion";
import Link from "next/link";
import { useEffect, useRef, useState } from "react";
type Stats = {
raisedUsd: number;
@@ -10,28 +11,44 @@ type Stats = {
uniqueDonors: number;
};
function AnimatedNumber({ value, prefix = "", suffix = "" }: { value: number; prefix?: string; suffix?: string }) {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const ctrl = animate(0, value, {
duration: 1.2,
ease: "easeOut",
onUpdate: (v) => {
el.textContent = prefix + Math.round(v).toLocaleString() + suffix;
},
});
return () => ctrl.stop();
}, [value, prefix, suffix]);
return <span ref={ref}>{prefix}0{suffix}</span>;
}
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);
try {
const res = await fetch("/api/public/stats", { cache: "no-store" });
if (!res.ok) return;
const data = await res.json();
if (alive) setStats(data);
} catch { /* silent */ }
};
load();
const id = setInterval(load, 30000);
return () => {
alive = false;
clearInterval(id);
};
const id = setInterval(load, 30_000);
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 spring = useSpring(0, { stiffness: 80, damping: 18 });
const width = useTransform(spring, (v) => `${v}%`);
useEffect(() => {
@@ -39,44 +56,125 @@ export function ProgressSection() {
}, [pct, spring]);
return (
<section className="border-b border-white/10 bg-gradient-to-b from-[#030712] to-[#050b1f] py-16">
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.32em] text-slate-400">Grassroots meter</p>
<h2 className="mt-3 text-3xl font-semibold text-white sm:text-4xl">Momentum you can see</h2>
<p className="mt-3 max-w-xl text-slate-400">
Every dollar is a choice about whose voice counts. We publish aggregate totals so supporters can
feel the collective liftwithout gamifying human dignity.
</p>
<section id="meter" className="relative overflow-hidden border-b border-white/10 bg-gradient-to-b from-[#030712] to-[#050b1f] py-10">
{/* Ambient glow */}
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_70%_40%_at_50%_100%,rgba(56,189,248,0.07),transparent)]" />
<div className="relative mx-auto max-w-6xl px-4 text-center sm:px-6 lg:text-left">
<div className="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
<div className="mx-auto max-w-xl lg:mx-0">
<motion.p
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
className="text-xs uppercase tracking-[0.32em] text-slate-400"
>
Grassroots meter
</motion.p>
<motion.h2
initial={{ opacity: 0, y: 14 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.55 }}
className="mt-3 text-3xl font-bold text-white sm:text-4xl"
>
Momentum you can{" "}
<span className="bg-gradient-to-r from-sky-300 to-indigo-300 bg-clip-text text-sky-200 supports-[(-webkit-background-clip:text)]:text-transparent">
see
</span>
</motion.h2>
<motion.p
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1, duration: 0.5 }}
className="mt-3 max-w-xl text-slate-400"
>
Every dollar is a vote for whose voice counts. These numbers are the same ones on our public board refreshed on a
short timer so the homepage always feels current.
</motion.p>
<div className="mt-5 flex flex-wrap items-center justify-center gap-x-4 gap-y-2 text-sm lg:justify-start">
<Link
href="/donate"
className="inline-flex items-center gap-1.5 font-medium text-sky-300 hover:text-sky-200 transition"
>
Add your gift
</Link>
<span className="hidden text-slate-600 sm:inline" aria-hidden>
|
</span>
<Link href="/raised" className="inline-flex items-center gap-1.5 font-medium text-slate-400 hover:text-white transition">
Open full totals board
</Link>
</div>
</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>
{/* Stat cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
{[
{
label: "Raised",
value: stats?.raisedUsd ?? 0,
display: stats ? `$${stats.raisedUsd.toLocaleString(undefined, { maximumFractionDigits: 0 })}` : "—",
color: "from-sky-500/20 to-transparent",
glow: "border-sky-500/20",
},
{
label: "Donations",
value: stats?.donationCount ?? 0,
display: stats?.donationCount?.toLocaleString() ?? "—",
color: "from-indigo-500/20 to-transparent",
glow: "border-indigo-500/20",
},
{
label: "Supporters",
value: stats?.uniqueDonors ?? 0,
display: stats?.uniqueDonors?.toLocaleString() ?? "—",
color: "from-fuchsia-500/20 to-transparent",
glow: "border-fuchsia-500/20",
},
].map(({ label, display, color, glow }, i) => (
<motion.div
key={label}
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: i * 0.08, duration: 0.5 }}
whileHover={{ scale: 1.04 }}
className={`relative overflow-hidden rounded-2xl border ${glow} bg-white/5 p-4 ${i === 2 ? "col-span-2 sm:col-span-1" : "col-span-1"}`}
>
<div className={`pointer-events-none absolute inset-0 bg-gradient-to-br ${color}`} />
<p className="relative text-xs uppercase tracking-wide text-slate-400">{label}</p>
<p className="relative mt-2 text-2xl font-bold text-white">{display}</p>
</motion.div>
))}
</div>
</div>
<div className="mt-10">
{/* Progress bar */}
<div className="mt-8">
<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 className="font-medium text-slate-300">
Goal: ${goal.toLocaleString()}
</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 className="relative mt-3 h-5 overflow-hidden rounded-full border border-white/10 bg-black/40">
{/* Glow track */}
<div className="absolute inset-y-0 left-0 w-full bg-gradient-to-r from-sky-900/30 to-indigo-900/20 rounded-full" />
<motion.div
style={{ width }}
className="relative h-full rounded-full bg-gradient-to-r from-sky-400 via-indigo-400 to-fuchsia-400 shadow-[0_0_20px_rgba(56,189,248,0.5)]"
>
{/* Shimmer sweep on bar */}
<div className="absolute inset-0 animate-shimmer rounded-full" />
{/* Leading dot */}
<div className="absolute right-0 top-1/2 -translate-y-1/2 h-5 w-5 rounded-full bg-white shadow-[0_0_12px_rgba(56,189,248,0.9)] -mr-2.5" />
</motion.div>
</div>
<p className="mt-3 text-right text-sm font-semibold text-sky-300">
{stats ? `${pct}% to goal` : "Loading…"}
</p>
</div>
</div>
</section>

View File

@@ -1,53 +1,70 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
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 async function RewardsPreview() {
const skus = await prisma.prizeSku.findMany({ orderBy: { costCredits: "asc" }, take: 3 });
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>
<section className="border-b border-white/10 bg-gradient-to-b from-[#030712] to-[#08111f] py-10">
<div className="mx-auto grid max-w-6xl gap-6 px-4 sm:px-6 lg:grid-cols-[0.85fr_1.15fr] lg:items-center">
<div className="text-center lg:text-left">
<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 className="mx-auto mt-3 max-w-xl text-3xl font-semibold text-white sm:text-4xl lg:mx-0">
After you give, the story keeps going
</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 className="mx-auto mt-4 max-w-xl text-base leading-relaxed text-slate-400 lg:mx-0">
Donations are only the first chapter. Your wallet turns verified support into perks you can redeem, raffles you can
enter, and future drops the committee lines up for people who stayed in the fight.
</p>
<p className="mx-auto mt-3 max-w-xl text-sm leading-relaxed text-slate-500 lg:mx-0">
Nothing here replaces the work of knocking doors or making calls it is a gentle nudge to come back tomorrow: check
your balance, grab a reward, and see what the campaign unlocked next.
</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"
className="mt-6 inline-flex rounded-full bg-gradient-to-r from-fuchsia-500 to-sky-500 px-6 py-2.5 text-sm font-semibold text-white shadow-xl shadow-fuchsia-500/20"
>
Explore the wallet
Open your 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 className="grid gap-3 md:grid-cols-3">
{skus.length > 0
? skus.map((sku) => (
<article key={sku.id} className="rounded-2xl border border-white/10 bg-black/25 p-4">
<p className="text-xs uppercase tracking-[0.22em] text-fuchsia-200/80">
{sku.costCredits.toLocaleString()} credits
</p>
<h3 className="mt-4 text-lg font-semibold text-white">{sku.title}</h3>
<p className="mt-3 text-sm leading-relaxed text-slate-400">{sku.description}</p>
</article>
))
: fallbackPerks.map((perk) => (
<article key={perk.title} className="rounded-2xl border border-white/10 bg-black/25 p-4">
<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>
);
}
const fallbackPerks = [
{
title: "Digital yard sign pack",
cost: "15 credits",
body: "Printable, shareable visibility assets for people who want to do more than click donate.",
},
{
title: "Supporter badge",
cost: "8 credits",
body: "Profile flair for the early crew, useful for future leaderboards and volunteer recognition.",
},
{
title: "Grassroots gear raffle",
cost: "5 credits / ticket",
body: "Spend credits to enter draws for campaign gear and exclusive supporter experiences.",
},
];

View File

@@ -1,20 +1,182 @@
import Link from "next/link";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import { appTitle } from "@/lib/public-env";
export function SiteFooter() {
const creditName = creditDisplayName();
const ticker = creditTicker();
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.";
"Contributions are solicited by an authorized political committee. Federal law requires political committees to report contributor information and to retain records in accordance with FEC rules. This statement is general information only and not legal, FEC, or tax advice; consult qualified counsel for your committees obligations.";
const year = new Date().getFullYear();
const committee =
process.env.NEXT_PUBLIC_COMMITTEE_LEGAL_NAME_PLACEHOLDER ??
process.env.COMMITTEE_LEGAL_NAME_PLACEHOLDER ??
"Official committee name as filed with the Federal Election Commission.";
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>
<footer className="border-t border-white/10 bg-[#020617] pb-8 pt-10">
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="rounded-2xl border border-sky-500/25 bg-gradient-to-br from-sky-950/85 via-[#0a1628] to-indigo-950/55 px-5 py-6 text-center sm:px-8">
<p className="text-xs uppercase tracking-[0.35em] text-sky-300/90">The vision</p>
<p className="mt-4 text-balance text-xl font-semibold leading-snug text-white sm:text-2xl">
The home for Democratic giving donate with Stripe, earn {ticker}, and actually see your money at work.
</p>
<p className="mx-auto mt-4 max-w-3xl text-sm leading-relaxed text-slate-400">
Games, democratic initiatives, missions, and a live meter turn a one-time gift into an ongoing coalition more
interactive than a static donate page, built to round up supporters for the fights ahead.
</p>
</div>
<div className="mt-8 grid gap-10 border-b border-white/10 pb-8 lg:grid-cols-3 lg:gap-12">
<div className="flex flex-col items-center text-center">
<p className="bg-gradient-to-r from-sky-300 to-indigo-300 bg-clip-text text-xl font-semibold tracking-tight text-transparent">
{appTitle()}
</p>
<p className="mt-3 max-w-xs text-sm leading-relaxed text-slate-500 sm:max-w-sm">
Give when you can, stay for missions and ideas. Secure card gifts, public totals, and accountable recognition through{" "}
<span className="text-slate-400">
{creditName} ({ticker})
</span>{" "}
run by an authorized political committee.
</p>
<div className="mt-5 flex flex-wrap justify-center gap-3">
<Link
href="/register"
className="rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-5 py-2 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 hover:opacity-95"
>
Enroll
</Link>
<Link href="/login" className="rounded-full border border-white/15 px-5 py-2 text-sm font-medium text-slate-200 hover:bg-white/5">
Sign in
</Link>
</div>
</div>
<div className="flex flex-col items-center text-center">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-slate-400">On the site</p>
<ul className="mt-4 flex flex-col items-center gap-2.5 text-sm text-slate-500">
<li>
<Link href="/#start" className="hover:text-sky-300">
Start here
</Link>
</li>
<li>
<Link href="/#impact" className="hover:text-sky-300">
Impact planner
</Link>
</li>
<li>
<Link href="/#actions" className="hover:text-sky-300">
Action center
</Link>
</li>
<li>
<Link href="/#meter" className="hover:text-sky-300">
Live meter
</Link>
</li>
<li>
<Link href="/#leaderboard" className="hover:text-sky-300">
Hall of fame
</Link>
</li>
<li>
<Link href="/#priorities" className="hover:text-sky-300">
Priorities
</Link>
</li>
<li>
<Link href="/donate" className="hover:text-sky-300">
Contribute
</Link>
</li>
<li>
<Link href="/#faq" className="hover:text-sky-300">
FAQ
</Link>
</li>
</ul>
</div>
<div className="flex flex-col items-center text-center">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-slate-400">Programs & data</p>
<ul className="mt-4 flex flex-col items-center gap-2.5 text-sm text-slate-500">
<li>
<Link href="/raised" className="hover:text-sky-300">
Live totals board
</Link>
</li>
<li>
<Link href="/missions" className="hover:text-sky-300">
Mission pledges
</Link>
</li>
<li>
<Link href="/initiatives" className="hover:text-sky-300">
Democratic initiatives
</Link>
</li>
<li>
<Link href="/vote/next-president" className="hover:text-sky-300">
Supporter straw poll
</Link>
</li>
<li>
<Link href="/wallet" className="hover:text-sky-300">
Supporter wallet
</Link>
</li>
<li>
<Link href="/cards" className="hover:text-sky-300">
Supporter cards
</Link>
</li>
<li>
<Link href="/faq-board" className="hover:text-sky-300">
FAQ board
</Link>
</li>
<li>
<Link href="/casino" className="hover:text-sky-300">
Games
</Link>
</li>
<li>
<Link href="/spotlight" className="hover:text-sky-300">
Spotlight
</Link>
</li>
</ul>
</div>
</div>
<div className="mt-6 grid gap-6 text-center sm:gap-8 lg:grid-cols-3 lg:gap-10">
<div className="flex flex-col items-center">
<p className="text-xs uppercase tracking-[0.2em] text-slate-600">Committee contact</p>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-slate-500">
Volunteer and press routing follows the committees filed Statement of Organization and authorized public disclosures.
</p>
</div>
<div className="flex flex-col items-center">
<p className="text-xs uppercase tracking-[0.2em] text-slate-600">Distribution</p>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-slate-500">
Campaign communications are issued only through channels the committee authorizes for official business.
</p>
</div>
<div className="flex flex-col items-center">
<p className="text-xs uppercase tracking-[0.2em] text-slate-600">Paid for by</p>
<p className="mt-2 max-w-sm text-xs leading-relaxed text-slate-500">{committee}</p>
</div>
</div>
<p className="mt-6 leading-relaxed text-sm text-slate-500">{disclaimer}</p>
<p className="mt-6 text-center text-xs leading-relaxed text-slate-600">
© {year} {appTitle()}. All rights reserved. Use of this portal constitutes acknowledgment of committee rules of
engagement where applicable.
</p>
</div>
</footer>

View File

@@ -1,60 +1,86 @@
import Link from "next/link";
import { auth } from "@/auth";
import { appTitle } from "@/lib/public-env";
import { MobileNav } from "./MobileNav";
const primaryLinks = [
{ href: "/raised", label: "Raised" },
{ href: "/missions", label: "Missions" },
{ href: "/initiatives", label: "Initiatives" },
{ href: "/vote/next-president", label: "Poll" },
{ href: "/donate", label: "Donate" },
] as const;
export async function SiteNav() {
const session = await auth();
const title = appTitle();
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
<header
id="site-header"
className="sticky top-0 z-50 shrink-0 border-b border-white/10 bg-[#030712]/90 backdrop-blur-xl"
>
<div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-3 sm:gap-3 sm:px-6">
<Link
href="/"
className="shrink-0 truncate text-sm font-semibold tracking-tight text-sky-200 sm:max-w-[11rem] sm:text-base lg:max-w-[13rem]"
title={title}
>
<span className="bg-gradient-to-r from-sky-300 via-indigo-300 to-fuchsia-300 bg-clip-text supports-[(-webkit-background-clip:text)]:text-transparent">
{title}
</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>
<div className="min-w-2 flex-1" aria-hidden />
<nav
className="hidden min-w-0 flex-nowrap items-center justify-end gap-0.5 text-sm text-slate-200 md:flex"
aria-label="Main"
>
{primaryLinks.map((item) => (
<Link key={item.href} href={item.href} className="shrink-0 rounded-full px-2 py-1.5 hover:bg-white/5 lg:px-2.5">
{item.label}
</Link>
))}
{session?.user ? (
<>
<Link
className="rounded-full bg-white/10 px-4 py-2 font-medium text-white hover:bg-white/15"
href="/casino"
className="hidden shrink-0 rounded-full px-2.5 py-1.5 font-medium text-fuchsia-300 hover:bg-white/5 lg:inline-flex"
>
Games
</Link>
<Link
href="/wallet"
className="shrink-0 rounded-full bg-white/10 px-2.5 py-1.5 font-medium text-white hover:bg-white/15 lg:px-3"
>
Wallet
</Link>
</>
) : (
<>
<Link className="rounded-full px-3 py-1.5 hover:bg-white/5" href="/login">
<Link href="/login" className="shrink-0 rounded-full px-2 py-1.5 hover:bg-white/5 lg:px-2.5">
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"
className="shrink-0 rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-2.5 py-1.5 font-semibold text-white shadow-lg shadow-sky-500/20 lg:px-3"
>
Join
</Link>
</>
)}
</nav>
<div className="flex shrink-0 items-center gap-2 md:hidden">
<Link
href="/donate"
className="rounded-full bg-gradient-to-r from-sky-500 to-indigo-500 px-3 py-1.5 text-sm font-semibold text-white shadow-md shadow-sky-500/20"
>
Donate
</Link>
<MobileNav loggedIn={!!session?.user} />
</div>
</div>
</header>
);

View File

@@ -0,0 +1,108 @@
"use client";
import { useEffect, useRef } from "react";
type Wisp = {
x: number;
y: number;
r: number;
vx: number;
vy: number;
a: number;
phase: number;
speed: number;
hue: number;
};
export function SmokeWisps() {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const canvas = ref.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let rafId = 0;
let wisps: Wisp[] = [];
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = canvas.clientWidth * dpr;
canvas.height = canvas.clientHeight * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
spawnWisps();
};
const spawnWisps = () => {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
wisps = Array.from({ length: 14 }, (_, i) => ({
x: Math.random() * w,
y: h * 0.3 + Math.random() * h * 0.6,
r: 60 + Math.random() * 120,
vx: (Math.random() - 0.5) * 0.18,
vy: -(0.06 + Math.random() * 0.12),
a: 0.025 + Math.random() * 0.045,
phase: Math.random() * Math.PI * 2,
speed: 0.0004 + Math.random() * 0.0006,
hue: [210, 220, 230, 200, 190, 215][i % 6],
}));
};
const tick = (t: number) => {
const w = canvas.clientWidth;
const h = canvas.clientHeight;
ctx.clearRect(0, 0, w, h);
for (const wisp of wisps) {
wisp.x += wisp.vx + Math.sin(t * wisp.speed + wisp.phase) * 0.25;
wisp.y += wisp.vy;
wisp.r += 0.08;
wisp.a *= 0.9995;
// Wrap vertically
if (wisp.y + wisp.r < 0) {
wisp.y = h + wisp.r;
wisp.x = Math.random() * w;
wisp.r = 60 + Math.random() * 100;
wisp.a = 0.025 + Math.random() * 0.04;
}
// Wrap horizontally
if (wisp.x < -wisp.r) wisp.x = w + wisp.r;
if (wisp.x > w + wisp.r) wisp.x = -wisp.r;
const grad = ctx.createRadialGradient(wisp.x, wisp.y, 0, wisp.x, wisp.y, wisp.r);
grad.addColorStop(0, `hsla(${wisp.hue}, 60%, 70%, ${wisp.a})`);
grad.addColorStop(0.5, `hsla(${wisp.hue}, 50%, 60%, ${wisp.a * 0.4})`);
grad.addColorStop(1, `hsla(${wisp.hue}, 40%, 50%, 0)`);
ctx.beginPath();
ctx.ellipse(wisp.x, wisp.y, wisp.r, wisp.r * 0.55, t * wisp.speed * 0.3, 0, Math.PI * 2);
ctx.fillStyle = grad;
ctx.fill();
}
rafId = requestAnimationFrame(tick);
};
resize();
window.addEventListener("resize", resize);
rafId = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(rafId);
window.removeEventListener("resize", resize);
};
}, []);
return (
<canvas
ref={ref}
aria-hidden
className="pointer-events-none absolute inset-0 h-full w-full"
/>
);
}

View File

@@ -1,37 +1,82 @@
"use client";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import Link from "next/link";
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.",
];
import { useMemo } from "react";
export function SupporterFeed() {
const ticker = creditTicker();
const creditName = creditDisplayName();
const feed = useMemo(
() => [
{ text: "A nurse in Phoenix just turned $20 into a five-ticket raffle push.", flag: "🩺" },
{ text: "A student organizer in Madison unlocked the digital yard sign pack.", flag: "🎓" },
{ text: "A retired teacher in Atlanta recruited four first-time monthly donors.", flag: "📚" },
{ text: "A union steward in Detroit paired a $50 gift with a Saturday canvass.", flag: "✊" },
{ text: "A parent in Raleigh sent the healthcare card to a neighborhood chat.", flag: "💙" },
{ text: `A volunteer in Las Vegas used ${ticker} to enter the gear drop.`, flag: "🎰" },
{ text: `A firefighter in Seattle donated $100 and earned ${creditName}.`, flag: "🔥" },
{ text: "A college chapter in Austin registered 40 first-time voters this week.", flag: "🗳️" },
],
[ticker, creditName],
);
const doubled = useMemo(() => [...feed, ...feed], [feed]);
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" />
<section className="relative overflow-hidden border-b border-white/10 bg-[#020617] py-10 sm:py-12">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-sky-950/30 via-transparent to-indigo-950/30" />
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="mx-auto max-w-3xl text-center">
<p className="text-xs font-medium uppercase tracking-[0.28em] text-sky-200/85">Community pulse</p>
<h2 className="mt-3 text-2xl font-semibold text-white sm:text-3xl">You are not donating into a void</h2>
<p className="mt-4 text-sm leading-relaxed text-slate-400 sm:text-base">
Real people across the country use this portal to give, earn {creditName}, and show up again for raffles, missions,
polls, and neighbor-to-neighbor asks. The ticker below is illustrative motion for atmosphere; your actual gift still hits
the same live meter as everyone else&apos;s.
</p>
<div className="mt-5 flex flex-wrap justify-center gap-3 text-sm">
<Link href="/donate" className="rounded-full border border-white/15 px-4 py-2 text-slate-200 hover:bg-white/5 hover:text-white">
Give now
</Link>
<Link href="/wallet" className="rounded-full border border-white/15 px-4 py-2 text-slate-200 hover:bg-white/5 hover:text-white">
Open wallet
</Link>
<Link href="/missions" className="rounded-full border border-white/15 px-4 py-2 text-slate-200 hover:bg-white/5 hover:text-white">
Missions
</Link>
</div>
</div>
<div className="mx-auto mt-8 flex max-w-6xl items-center gap-5">
<div className="shrink-0 flex items-center gap-2 rounded-full border border-sky-500/30 bg-sky-500/10 px-3 py-1.5">
<span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-400 opacity-75" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-sky-400" />
</span>
<span className="text-xs font-medium uppercase tracking-[0.2em] text-sky-300">Live vibe</span>
</div>
<div className="relative min-w-0 flex-1 overflow-hidden">
<motion.div
className="flex w-max gap-10"
animate={{ x: ["0%", "-50%"] }}
transition={{ duration: 52, repeat: Infinity, ease: "linear" }}
>
{doubled.map((item, i) => (
<span key={i} className="flex items-center gap-2 whitespace-nowrap text-sm text-slate-300">
<span className="text-base">{item.flag}</span>
{item.text}
<span className="mx-3 text-slate-600">·</span>
</span>
))}
</motion.div>
<div className="pointer-events-none absolute inset-y-0 left-0 w-16 bg-gradient-to-r from-[#020617] to-transparent" />
<div className="pointer-events-none absolute inset-y-0 right-0 w-16 bg-gradient-to-l from-[#020617] to-transparent" />
</div>
</div>
</div>
</section>

View File

@@ -0,0 +1,173 @@
"use client";
import { creditTicker } from "@/lib/credits-brand";
import { motion } from "framer-motion";
import Link from "next/link";
import { useMemo, useState } from "react";
const amounts = [5, 10, 20, 100];
const lanes = [
{
id: "field",
label: "Field",
title: "Door-to-door momentum",
body: "Turn a small-dollar gift into canvass shifts, call time, ride assists, and local volunteer materials.",
accent: "from-sky-400 to-cyan-300",
multiplier: 1.25,
},
{
id: "rights",
label: "Rights",
title: "Voter protection sprint",
body: "Push resources toward access, legal readiness, turnout help, and rapid response when voting rules change.",
accent: "from-emerald-300 to-teal-300",
multiplier: 1.05,
},
{
id: "message",
label: "Message",
title: "Persuasion engine",
body: "Fuel explainers, peer shares, creator clips, texting, and follow-up that meets voters where they are.",
accent: "from-rose-300 to-amber-200",
multiplier: 0.95,
},
];
export function SupporterQuest() {
const ticker = creditTicker();
const [amount, setAmount] = useState(20);
const [laneId, setLaneId] = useState(lanes[0].id);
const selectedLane = lanes.find((lane) => lane.id === laneId) ?? lanes[0];
const results = useMemo(() => {
const multiplier = selectedLane.multiplier;
return {
credits: Math.round(amount * 82),
contacts: Math.round(amount * 42 * multiplier),
actions: Math.max(3, Math.round(amount * 0.75 * multiplier)),
pulse: Math.min(100, Math.round(28 + amount * 0.58 * multiplier)),
};
}, [amount, selectedLane.multiplier]);
return (
<section id="quest" className="border-b border-white/10 bg-[#070b14] py-10">
<div className="mx-auto grid max-w-6xl gap-6 px-4 sm:px-6 lg:grid-cols-[0.9fr_1.1fr] lg:items-center">
<div className="text-center lg:text-left">
<p className="text-xs uppercase tracking-[0.32em] text-amber-100/75">Donation quest</p>
<h2 className="mx-auto mt-3 max-w-2xl text-3xl font-semibold leading-tight text-white sm:text-4xl lg:mx-0">
Make the first gift feel like the first move.
</h2>
<p className="mx-auto mt-3 max-w-xl text-slate-400 lg:mx-0">
Choose a contribution, pick a lane (field, rights, or message), and see what your first move could unlock before you
check out. Figures here are a friendly preview your real {ticker} balance updates after you give while signed in.
</p>
<p className="mx-auto mt-3 max-w-xl text-sm text-slate-500 lg:mx-0">
Ready? Head to donate, then explore missions and initiatives it is the same wallet everywhere.
</p>
<div className="mt-5 flex flex-wrap justify-center gap-3 lg:justify-start">
<Link
href="/donate"
className="rounded-full bg-white px-5 py-2.5 text-sm font-semibold text-slate-950 transition hover:bg-sky-100"
>
Go to donate
</Link>
<Link
href="/missions"
className="rounded-full border border-white/15 bg-white/5 px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-white/10"
>
See missions
</Link>
</div>
</div>
<motion.div
initial={{ opacity: 0, y: 18 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-80px" }}
transition={{ duration: 0.5 }}
className="overflow-hidden rounded-3xl border border-white/10 bg-black/30 shadow-[0_0_90px_rgba(14,165,233,0.12)]"
>
<div className="border-b border-white/10 bg-white/[0.04] p-4 sm:p-5">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs uppercase tracking-[0.24em] text-slate-500">Build a run</p>
<p className="mt-1 text-xl font-semibold text-white">Your ${amount} campaign move</p>
</div>
<div className="flex flex-wrap gap-2">
{amounts.map((value) => (
<button
key={value}
type="button"
onClick={() => setAmount(value)}
className={`min-w-14 rounded-full px-4 py-2 text-sm font-semibold transition ${
amount === value ? "bg-white text-slate-950" : "bg-white/[0.08] text-slate-200 hover:bg-white/[0.14]"
}`}
>
${value}
</button>
))}
</div>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-3">
{lanes.map((lane) => (
<button
key={lane.id}
type="button"
onClick={() => setLaneId(lane.id)}
className={`rounded-2xl border p-3.5 text-left transition sm:p-4 ${
laneId === lane.id
? "border-white/35 bg-white/[0.12] text-white"
: "border-white/10 bg-black/20 text-slate-300 hover:bg-white/[0.08]"
}`}
>
<span className={`block h-1 w-9 rounded-full bg-gradient-to-r ${lane.accent}`} />
<span className="mt-2.5 block text-sm font-semibold">{lane.label}</span>
<span className="mt-1 line-clamp-2 text-[11px] leading-snug text-slate-500">{lane.title}</span>
</button>
))}
</div>
</div>
<div className="grid gap-0 lg:grid-cols-[0.95fr_1.05fr]">
<div className="border-b border-white/10 p-5 lg:border-b-0 lg:border-r">
<p className="text-xs uppercase tracking-[0.24em] text-slate-500">Selected lane</p>
<h3 className="mt-2 text-2xl font-semibold text-white">{selectedLane.title}</h3>
<p className="mt-3 text-sm leading-relaxed text-slate-400">{selectedLane.body}</p>
<div className="mt-5">
<div className="flex items-center justify-between text-xs text-slate-500">
<span>Campaign pulse</span>
<span>{results.pulse}%</span>
</div>
<div className="mt-2 h-3 overflow-hidden rounded-full bg-white/10">
<motion.div
className={`h-full rounded-full bg-gradient-to-r ${selectedLane.accent}`}
animate={{ width: `${results.pulse}%` }}
transition={{ type: "spring", stiffness: 90, damping: 18 }}
/>
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-px bg-white/10">
<QuestMetric label={`${ticker} preview`} value={results.credits.toLocaleString()} />
<QuestMetric label="Voters touched" value={results.contacts.toLocaleString()} />
<QuestMetric label="Next actions" value={results.actions.toLocaleString()} />
</div>
</div>
</motion.div>
</div>
</section>
);
}
function QuestMetric({ label, value }: { label: string; value: string }) {
return (
<div className="bg-[#070b14] p-4 text-center">
<p className="text-2xl font-semibold text-white sm:text-3xl">{value}</p>
<p className="mt-2 text-xs uppercase tracking-wide text-slate-500">{label}</p>
</div>
);
}

View File

@@ -0,0 +1,107 @@
import Link from "next/link";
import { creditDisplayName, creditTicker } from "@/lib/credits-brand";
import { appTitle } from "@/lib/public-env";
function buildSteps(t: string) {
return [
{
n: "1",
title: "Join free",
body: "Create a supporter account in a minute. No fee to enroll — you only pay when you choose to give.",
href: "/register",
cta: "Create account",
},
{
n: "2",
title: "Pick an amount",
body: `Small-dollar tiers from $5 to $100 via Stripe. Stay signed in when you pay so your wallet receives ${t} after the gift clears.`,
href: "/donate",
cta: "Open donate",
},
{
n: "3",
title: "Steer the movement",
body: `Spend ${t} on missions, democratic initiatives, the straw poll, perks, and games — one wallet, every feature on the site.`,
href: "/wallet",
cta: "Open wallet",
},
{
n: "4",
title: "Watch the meter",
body: "See dollars raised and goal progress update alongside thousands of other supporters.",
href: "/#meter",
cta: "See live meter",
},
];
}
export function WelcomePath() {
const title = appTitle();
const t = creditTicker();
const n = creditDisplayName();
const steps = buildSteps(t);
return (
<section id="start" className="scroll-mt-28 border-b border-white/10 bg-gradient-to-b from-[#061022] to-[#030712] py-12 sm:py-14">
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="mx-auto max-w-3xl text-center">
<p className="text-xs font-medium uppercase tracking-[0.28em] text-emerald-200/85">Welcome</p>
<h2 className="mt-3 text-3xl font-bold tracking-tight text-white sm:text-4xl">
New here? You belong here&apos;s how {title} works
</h2>
<p className="mt-4 text-base leading-relaxed text-slate-400 sm:text-lg">
This site is built so supporting democracy feels straightforward: give if you can, earn {n} ({t}) when you&apos;re
signed in, then choose how to put those credits toward field work, ideas, polls, and perks. No maze just a clear path
from I care to I did something.
</p>
<p className="mx-auto mt-4 max-w-2xl text-sm leading-relaxed text-slate-500">
Guest gifts still move the public fundraising meter. For the full experience wallet, missions, initiatives, games
use the steps below.
</p>
</div>
<div className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{steps.map((s) => (
<Link
key={s.n}
href={s.href}
className="group flex flex-col rounded-2xl border border-white/10 bg-white/[0.04] p-5 text-center transition hover:border-emerald-400/35 hover:bg-white/[0.07] sm:text-left"
>
<span className="mx-auto flex h-10 w-10 items-center justify-center rounded-full border border-emerald-400/30 bg-emerald-500/10 font-mono text-sm font-bold text-emerald-200 sm:mx-0">
{s.n}
</span>
<p className="mt-4 text-lg font-semibold text-white">{s.title}</p>
<p className="mt-2 flex-1 text-sm leading-relaxed text-slate-400">{s.body}</p>
<span className="mt-4 text-sm font-semibold text-emerald-300 group-hover:text-emerald-200">
{s.cta}
</span>
</Link>
))}
</div>
<div className="mx-auto mt-10 flex max-w-3xl flex-wrap items-center justify-center gap-3 text-sm">
<Link href="/missions" className="rounded-full border border-white/15 px-4 py-2 text-slate-300 transition hover:bg-white/5 hover:text-white">
Mission pledges
</Link>
<Link href="/initiatives" className="rounded-full border border-white/15 px-4 py-2 text-slate-300 transition hover:bg-white/5 hover:text-white">
Democratic initiatives
</Link>
<Link href="/raised" className="rounded-full border border-white/15 px-4 py-2 text-slate-300 transition hover:bg-white/5 hover:text-white">
Live totals board
</Link>
<Link href="/#faq" className="rounded-full border border-white/15 px-4 py-2 text-slate-300 transition hover:bg-white/5 hover:text-white">
Read FAQ
</Link>
</div>
<p className="mx-auto mt-8 max-w-xl text-center text-sm text-slate-500">
Already enrolled?{" "}
<Link href="/login" className="font-medium text-emerald-300/95 underline-offset-2 hover:text-emerald-200 hover:underline">
Sign in
</Link>{" "}
to pick up where you left off wallet, missions, and perks stay on your account.
</p>
</div>
</section>
);
}

View File

@@ -0,0 +1,151 @@
"use client";
import { creditTicker } from "@/lib/credits-brand";
import { useState } from "react";
const T = creditTicker();
function cardColor(card: string) {
const suit = card.slice(-1);
return suit === "♥" || suit === "♦" ? "text-red-400" : "text-white";
}
function handSum(cards: string[]): number {
const vals: Record<string, number> = { A: 11, J: 10, Q: 10, K: 10 };
let val = 0, aces = 0;
for (const c of cards) {
const r = c.slice(0, -1);
const v = vals[r] ?? parseInt(r) ?? 10;
if (r === "A") aces++;
val += v;
}
while (val > 21 && aces > 0) { val -= 10; aces--; }
return val;
}
function Hand({ cards, label }: { cards: string[]; label: string }) {
return (
<div className="space-y-2">
<p className="text-xs uppercase tracking-widest text-slate-400">{label} {handSum(cards)}</p>
<div className="flex flex-wrap gap-2">
{cards.map((c, i) => (
<div key={i} className={`w-12 h-16 rounded-lg border border-white/20 bg-white/10 flex items-center justify-center font-bold text-sm ${cardColor(c)}`}>
{c}
</div>
))}
</div>
</div>
);
}
export function BlackjackGame({ balance }: { balance: number }) {
const [wager, setWager] = useState(10);
const [clientSeed, setClientSeed] = useState("my-seed");
const [phase, setPhase] = useState<"idle" | "playing" | "done">("idle");
const [roundId, setRoundId] = useState<string | null>(null);
const [playerHand, setPlayerHand] = useState<string[]>([]);
const [dealerVisible, setDealerVisible] = useState<string[]>([]);
const [dealerFull, setDealerFull] = useState<string[]>([]);
const [result, setResult] = useState<{ outcome: string; payout?: number; playerVal?: number; dealerVal?: number } | null>(null);
const [loading, setLoading] = useState(false);
async function startGame() {
setLoading(true);
const res = await fetch("/api/games/blackjack", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ wageBLW: wager, clientSeed }),
});
setLoading(false);
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
const data = await res.json();
setRoundId(data.roundId);
setPlayerHand(data.playerHand);
setDealerVisible(data.dealerVisible ?? []);
setDealerFull([]);
setResult(null);
if (data.outcome !== "active") {
setResult(data);
setPhase("done");
} else {
setPhase("playing");
}
}
async function action(act: "hit" | "stand" | "double") {
setLoading(true);
const res = await fetch("/api/games/blackjack", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roundId, action: act }),
});
setLoading(false);
if (!res.ok) return;
const data = await res.json();
if (data.playerHand) setPlayerHand(data.playerHand);
if (data.dealerHand) setDealerFull(data.dealerHand);
if (data.outcome !== "active") {
setResult(data);
setPhase("done");
}
}
const outcomeColor = !result ? "" : result.outcome === "win" || result.outcome === "blackjack" ? "border-green-500/50 bg-green-900/20" : result.outcome === "push" ? "border-yellow-500/50 bg-yellow-900/20" : "border-red-500/50 bg-red-900/20";
const outcomeLabel = !result
? ""
: result.outcome === "blackjack"
? `🃏 Blackjack! +${result.payout} ${T}`
: result.outcome === "win"
? `Win! +${result.payout} ${T}`
: result.outcome === "push"
? "Push — refunded"
: "Bust / Dealer wins";
return (
<div className="space-y-5">
{result && (
<div className={`rounded-xl border p-4 text-center font-bold ${outcomeColor}`}>
{outcomeLabel}
{result.dealerVal && <span className="text-slate-300 ml-2 text-sm font-normal">(Dealer: {result.dealerVal})</span>}
</div>
)}
<div className="space-y-4 min-h-[120px]">
{dealerFull.length > 0 ? <Hand cards={dealerFull} label="Dealer" /> : dealerVisible.length > 0 && <Hand cards={[...dealerVisible, "🂠"]} label="Dealer" />}
{playerHand.length > 0 && <Hand cards={playerHand} label="You" />}
</div>
{phase === "playing" && (
<div className="flex gap-2">
<button onClick={() => action("hit")} disabled={loading}
className="flex-1 rounded-xl bg-sky-600 py-3 font-semibold text-white hover:bg-sky-500 disabled:opacity-50">Hit</button>
<button onClick={() => action("stand")} disabled={loading}
className="flex-1 rounded-xl bg-indigo-600 py-3 font-semibold text-white hover:bg-indigo-500 disabled:opacity-50">Stand</button>
<button onClick={() => action("double")} disabled={loading || balance < wager}
className="flex-1 rounded-xl bg-purple-600 py-3 font-semibold text-white hover:bg-purple-500 disabled:opacity-50">Double</button>
</div>
)}
{phase !== "playing" && (
<>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-slate-400 mb-1">Wager</label>
<input type="number" min={1} max={balance} value={wager}
onChange={e => setWager(Math.max(1, parseInt(e.target.value) || 1))}
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-white focus:outline-none" />
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">Client Seed</label>
<input type="text" value={clientSeed} onChange={e => setClientSeed(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-white focus:outline-none" />
</div>
</div>
<button onClick={startGame} disabled={loading || wager > balance}
className="w-full rounded-xl bg-gradient-to-r from-sky-500 to-indigo-500 py-3 font-semibold text-white disabled:opacity-50 hover:opacity-90">
{loading ? "Dealing…" : phase === "done" ? "New Hand" : "Deal"}
</button>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,125 @@
"use client";
import { creditTicker } from "@/lib/credits-brand";
import { useState, useEffect } from "react";
import { io, Socket } from "socket.io-client";
const T = creditTicker();
interface Room { id: string; wageBLW: number; creatorId: string; }
export function CoinFlipRoom({ userId, balance }: { userId: string; balance: number }) {
const [rooms, setRooms] = useState<Room[]>([]);
const [wager, setWager] = useState(50);
const [phase, setPhase] = useState<"lobby" | "waiting" | "result">("lobby");
const [socket, setSocket] = useState<Socket | null>(null);
const [result, setResult] = useState<{ resultLabel: string; winnerId: string; payout: number; serverSeed: string } | null>(null);
const [myRoomId, setMyRoomId] = useState<string | null>(null);
useEffect(() => {
fetchRooms();
}, []);
async function fetchRooms() {
const r = await fetch("/api/games/rooms?gameType=COIN_FLIP");
if (r.ok) { const d = await r.json(); setRooms(d.rooms); }
}
function connect(roomId: string) {
const s = io("/coin-flip", { path: "/api/socket" });
setSocket(s);
s.emit("join_room", { roomId, userId });
s.on("waiting", () => setPhase("waiting"));
s.on("game_start", () => setPhase("waiting"));
s.on("result", (data) => { setResult(data); setPhase("result"); s.disconnect(); });
s.on("error", (msg: string) => { alert(msg); s.disconnect(); setPhase("lobby"); });
}
async function createRoom() {
const res = await fetch("/api/games/rooms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ gameType: "COIN_FLIP", wageBLW: wager }),
});
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
const { room } = await res.json();
setMyRoomId(room.id);
connect(room.id);
setPhase("waiting");
}
if (phase === "waiting") {
return (
<div className="rounded-2xl border border-white/10 bg-white/5 p-8 text-center space-y-4">
<div className="text-5xl animate-spin">🪙</div>
<p className="text-white font-semibold">Waiting for opponent</p>
<p className="text-slate-400 text-sm">Room ID: <span className="font-mono text-sky-300">{myRoomId}</span></p>
<p className="text-slate-500 text-xs">Share this with a friend to flip instantly!</p>
</div>
);
}
if (phase === "result" && result) {
const won = result.winnerId === userId;
return (
<div className={`rounded-2xl border p-8 text-center space-y-4 ${won ? "border-green-500/50 bg-green-900/20" : "border-red-500/50 bg-red-900/20"}`}>
<div className="text-6xl">{result.resultLabel === "heads" ? "🦅" : "🔵"}</div>
<p className="text-2xl font-bold text-white">{result.resultLabel.toUpperCase()}</p>
<p className={`font-semibold text-lg ${won ? "text-green-300" : "text-red-300"}`}>
{won ? `You win! +${result.payout} ${T}` : "You lose!"}
</p>
<p className="text-xs text-slate-500 break-all">Seed: {result.serverSeed}</p>
<button onClick={() => { setPhase("lobby"); setResult(null); fetchRooms(); }}
className="rounded-xl bg-gradient-to-r from-sky-500 to-indigo-500 px-6 py-2 font-semibold text-white">
Back to Lobby
</button>
</div>
);
}
return (
<div className="space-y-5">
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
<p className="text-sm font-semibold text-white">Create a Room</p>
<div className="flex gap-3">
<div className="flex-1">
<label className="block text-xs text-slate-400 mb-1">Wager ({T})</label>
<input type="number" min={1} max={balance} value={wager}
onChange={e => setWager(Math.max(1, parseInt(e.target.value) || 1))}
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-white focus:outline-none focus:border-sky-500" />
</div>
<div className="flex items-end">
<button onClick={createRoom} disabled={wager > balance}
className="rounded-xl bg-gradient-to-r from-sky-500 to-indigo-500 px-5 py-2 font-semibold text-white disabled:opacity-50 hover:opacity-90">
Create
</button>
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold text-white">Open Rooms</p>
<button onClick={fetchRooms} className="text-xs text-sky-400 hover:text-sky-300">Refresh</button>
</div>
{rooms.length === 0 ? (
<p className="text-center text-slate-500 py-8">No open rooms create one!</p>
) : (
rooms.map(room => (
<div key={room.id} className="flex items-center justify-between rounded-xl border border-white/10 bg-white/5 px-4 py-3">
<div>
<p className="text-white font-medium">{room.wageBLW} {T}</p>
<p className="text-xs text-slate-500 font-mono">{room.id.slice(0, 8)}</p>
</div>
<button
onClick={() => connect(room.id)}
disabled={room.creatorId === userId || balance < room.wageBLW}
className="rounded-xl bg-indigo-600 px-4 py-1.5 text-sm font-semibold text-white hover:bg-indigo-500 disabled:opacity-50">
Join
</button>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { creditTicker } from "@/lib/credits-brand";
import { useState, useEffect, useRef } from "react";
const T = creditTicker();
export function CrashGame({ balance }: { balance: number }) {
const [wager, setWager] = useState(10);
const [clientSeed, setClientSeed] = useState("my-seed");
const [phase, setPhase] = useState<"idle" | "running" | "done">("idle");
const [roundId, setRoundId] = useState<string | null>(null);
const [multiplier, setMultiplier] = useState(1.0);
const [crashAt, setCrashAt] = useState<number | null>(null);
const [result, setResult] = useState<{ outcome: string; multiplier?: number; payout?: number; serverSeed?: string } | null>(null);
const rafRef = useRef<number | null>(null);
const startRef = useRef<number>(0);
function startAnimation(crash: number) {
startRef.current = Date.now();
function tick() {
const elapsed = (Date.now() - startRef.current) / 1000;
const current = Math.pow(Math.E, 0.2 * elapsed);
setMultiplier(parseFloat(current.toFixed(2)));
if (current < crash) {
rafRef.current = requestAnimationFrame(tick);
} else {
setMultiplier(crash);
}
}
rafRef.current = requestAnimationFrame(tick);
}
async function startRound() {
const res = await fetch("/api/games/crash", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ wageBLW: wager, clientSeed }),
});
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
const data = await res.json();
setRoundId(data.roundId);
setCrashAt(data.crashAt);
setPhase("running");
setResult(null);
startAnimation(data.crashAt);
}
async function cashOut() {
if (!roundId || phase !== "running") return;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
const res = await fetch("/api/games/crash", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roundId, cashoutAt: multiplier }),
});
if (!res.ok) return;
const data = await res.json();
setResult(data);
setPhase("done");
}
// Auto-crash detection
useEffect(() => {
if (phase === "running" && crashAt !== null && multiplier >= crashAt) {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
// Auto-resolve as loss if player didn't cash out
fetch("/api/games/crash", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ roundId, cashoutAt: crashAt + 1 }),
}).then(r => r.json()).then(data => { setResult(data); setPhase("done"); });
}
}, [multiplier, crashAt, phase, roundId]);
useEffect(() => () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []);
const crashed = phase === "done" && result?.outcome === "loss";
const won = phase === "done" && (result?.outcome === "cashout" || result?.outcome === "win");
return (
<div className="space-y-6">
<div className={`relative rounded-2xl border flex flex-col items-center justify-center h-56 overflow-hidden transition-colors ${
crashed ? "border-red-500/50 bg-red-900/20" : won ? "border-green-500/50 bg-green-900/20" : "border-white/10 bg-white/5"
}`}>
<div className={`text-7xl font-black tabular-nums transition-colors ${crashed ? "text-red-400" : "text-white"}`}>
{multiplier.toFixed(2)}x
</div>
{phase === "running" && (
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-sky-500 to-indigo-500 animate-pulse" />
)}
{crashed && <p className="text-red-400 text-sm mt-2 font-semibold">CRASHED at {crashAt?.toFixed(2)}x</p>}
{won && <p className="text-green-400 text-sm mt-2 font-semibold">+{result?.payout} {T} at {result?.multiplier?.toFixed(2)}x</p>}
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs text-slate-400 mb-1">Wager ({T})</label>
<input
type="number" min={1} max={balance} value={wager}
onChange={e => setWager(Math.max(1, parseInt(e.target.value) || 1))}
disabled={phase === "running"}
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-white focus:outline-none focus:border-sky-500"
/>
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">Client Seed</label>
<input
type="text" value={clientSeed} onChange={e => setClientSeed(e.target.value)}
disabled={phase === "running"}
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-white focus:outline-none focus:border-sky-500"
/>
</div>
</div>
<div className="flex gap-3">
{phase !== "running" ? (
<button
onClick={startRound}
disabled={wager > balance}
className="flex-1 rounded-xl bg-gradient-to-r from-sky-500 to-indigo-500 py-3 font-semibold text-white disabled:opacity-50 hover:opacity-90 transition-opacity"
>
{phase === "done" ? "Play Again" : "Bet"}
</button>
) : (
<button
onClick={cashOut}
className="flex-1 rounded-xl bg-gradient-to-r from-green-500 to-emerald-500 py-3 font-bold text-white animate-pulse hover:animate-none"
>
Cash Out at {multiplier.toFixed(2)}x
</button>
)}
</div>
{result?.serverSeed && (
<p className="text-xs text-slate-500 break-all">Server seed: {result.serverSeed}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import { creditTicker } from "@/lib/credits-brand";
import { useState } from "react";
const T = creditTicker();
export function DiceGame({ balance }: { balance: number }) {
const [wager, setWager] = useState(10);
const [threshold, setThreshold] = useState(50);
const [direction, setDirection] = useState<"over" | "under">("over");
const [clientSeed, setClientSeed] = useState("my-seed");
const [result, setResult] = useState<{ roll: number; won: boolean; payout: number; multiplier: number; serverSeed: string } | null>(null);
const [loading, setLoading] = useState(false);
const winProb = direction === "over" ? (99 - threshold) / 100 : threshold / 100;
const multiplier = (0.98 / winProb).toFixed(4);
async function roll() {
setLoading(true);
const res = await fetch("/api/games/dice", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ wageBLW: wager, clientSeed, threshold, direction }),
});
setLoading(false);
if (!res.ok) { const e = await res.json(); alert(e.error); return; }
setResult(await res.json());
}
return (
<div className="space-y-6">
{result && (
<div className={`rounded-2xl border p-6 text-center ${result.won ? "border-green-500/50 bg-green-900/20" : "border-red-500/50 bg-red-900/20"}`}>
<p className="text-xs uppercase tracking-widest text-slate-400 mb-2">Roll</p>
<p className={`text-7xl font-black tabular-nums ${result.won ? "text-green-400" : "text-red-400"}`}>{result.roll}</p>
<p className={`mt-2 font-semibold ${result.won ? "text-green-300" : "text-red-300"}`}>
{result.won ? `+${result.payout} ${T} (${result.multiplier}x)` : "No win"}
</p>
<p className="text-xs text-slate-500 mt-2 break-all">Seed: {result.serverSeed}</p>
</div>
)}
<div className="space-y-4">
<div>
<label className="block text-xs text-slate-400 mb-1">Threshold: {threshold}</label>
<input type="range" min={2} max={98} value={threshold} onChange={e => setThreshold(parseInt(e.target.value))}
className="w-full accent-sky-500" />
<div className="flex justify-between text-xs text-slate-500 mt-1">
<span>2</span><span>98</span>
</div>
</div>
<div className="flex gap-3">
<button
onClick={() => setDirection("over")}
className={`flex-1 rounded-xl py-3 font-semibold transition-colors ${direction === "over" ? "bg-sky-600 text-white" : "border border-white/10 text-slate-400 hover:bg-white/5"}`}
>Over {threshold}</button>
<button
onClick={() => setDirection("under")}
className={`flex-1 rounded-xl py-3 font-semibold transition-colors ${direction === "under" ? "bg-indigo-600 text-white" : "border border-white/10 text-slate-400 hover:bg-white/5"}`}
>Under {threshold}</button>
</div>
<div className="grid grid-cols-3 gap-3 text-center text-sm">
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
<p className="text-xs text-slate-400">Win Chance</p>
<p className="text-white font-semibold">{(winProb * 100).toFixed(1)}%</p>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
<p className="text-xs text-slate-400">Multiplier</p>
<p className="text-white font-semibold">{multiplier}x</p>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 p-3">
<p className="text-xs text-slate-400">Payout</p>
<p className="text-white font-semibold">{Math.floor(wager * parseFloat(multiplier))} {T}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs text-slate-400 mb-1">Wager</label>
<input type="number" min={1} max={balance} value={wager}
onChange={e => setWager(Math.max(1, parseInt(e.target.value) || 1))}
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-white focus:outline-none focus:border-sky-500" />
</div>
<div>
<label className="block text-xs text-slate-400 mb-1">Client Seed</label>
<input type="text" value={clientSeed} onChange={e => setClientSeed(e.target.value)}
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-white focus:outline-none focus:border-sky-500" />
</div>
</div>
<button
onClick={roll}
disabled={loading || wager > balance}
className="w-full rounded-xl bg-gradient-to-r from-sky-500 to-indigo-500 py-3 font-semibold text-white disabled:opacity-50 hover:opacity-90 transition-opacity"
>
{loading ? "Rolling…" : "Roll Dice"}
</button>
</div>
</div>
);
}

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