Add multi-tenant Spaces with subdomain routing

Introduces independent community Spaces where admins can manage members,
allot credits, and run proposals. Each Space gets its own subdomain
(e.g. cryptocommons.rvote.online). Home page and demo are now fully
public with no auth required.

- Schema: Space, SpaceMember, SpaceInvite models with per-space credits
- Middleware: subdomain detection + URL rewriting to /s/[slug]/*
- APIs: Space CRUD, member management, invite system, space-scoped voting
- UI: Space dashboard, proposals, members, settings, join pages
- Extracted InteractiveDemo component for home/demo pages
- Global /proposals routes redirect to /spaces

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-10 22:10:19 +00:00
parent 333230cea9
commit e22063c092
49 changed files with 3653 additions and 1215 deletions

View File

@ -36,6 +36,8 @@ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=builder /app/node_modules/prisma ./node_modules/prisma
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
# Set ownership
RUN chown -R nextjs:nodejs /app

View File

@ -4,7 +4,7 @@ services:
restart: unless-stopped
labels:
- "traefik.enable=true"
- "traefik.http.routers.rvote.rule=Host(`rvote.online`) || Host(`www.rvote.online`)"
- "traefik.http.routers.rvote.rule=Host(`rvote.online`) || Host(`www.rvote.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rvote.online`)"
- "traefik.http.routers.rvote.entrypoints=web"
- "traefik.http.services.rvote.loadbalancer.server.port=3000"
- "traefik.docker.network=traefik-public"
@ -13,6 +13,8 @@ services:
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
- NEXTAUTH_URL=https://rvote.online
- RESEND_API_KEY=${RESEND_API_KEY}
- ROOT_DOMAIN=rvote.online
- NEXT_PUBLIC_ROOT_DOMAIN=rvote.online
networks:
- traefik-public
- rvote-internal

View File

@ -0,0 +1,219 @@
-- CreateEnum
CREATE TYPE "SpaceRole" AS ENUM ('ADMIN', 'MEMBER');
-- CreateEnum
CREATE TYPE "ProposalStatus" AS ENUM ('RANKING', 'VOTING', 'PASSED', 'FAILED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "VoteChoice" AS ENUM ('YES', 'NO', 'ABSTAIN');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"passwordHash" TEXT,
"name" TEXT,
"credits" INTEGER NOT NULL DEFAULT 0,
"lastCreditAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"emailVerified" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Space" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"description" TEXT,
"isPublic" BOOLEAN NOT NULL DEFAULT false,
"promotionThreshold" INTEGER NOT NULL DEFAULT 100,
"votingPeriodDays" INTEGER NOT NULL DEFAULT 7,
"creditsPerDay" INTEGER NOT NULL DEFAULT 10,
"maxCredits" INTEGER NOT NULL DEFAULT 500,
"startingCredits" INTEGER NOT NULL DEFAULT 50,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Space_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SpaceMember" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"spaceId" TEXT NOT NULL,
"role" "SpaceRole" NOT NULL DEFAULT 'MEMBER',
"credits" INTEGER NOT NULL DEFAULT 0,
"lastCreditAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SpaceMember_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SpaceInvite" (
"id" TEXT NOT NULL,
"spaceId" TEXT NOT NULL,
"email" TEXT,
"token" TEXT NOT NULL,
"maxUses" INTEGER,
"uses" INTEGER NOT NULL DEFAULT 0,
"expiresAt" TIMESTAMP(3),
"createdBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SpaceInvite_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Proposal" (
"id" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"authorId" TEXT NOT NULL,
"spaceId" TEXT,
"status" "ProposalStatus" NOT NULL DEFAULT 'RANKING',
"score" INTEGER NOT NULL DEFAULT 0,
"votingEndsAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Proposal_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Vote" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"proposalId" TEXT NOT NULL,
"weight" INTEGER NOT NULL,
"creditCost" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"decaysAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Vote_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FinalVote" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"proposalId" TEXT NOT NULL,
"vote" "VoteChoice" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "FinalVote_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "Space_slug_key" ON "Space"("slug");
-- CreateIndex
CREATE INDEX "SpaceMember_spaceId_idx" ON "SpaceMember"("spaceId");
-- CreateIndex
CREATE UNIQUE INDEX "SpaceMember_userId_spaceId_key" ON "SpaceMember"("userId", "spaceId");
-- CreateIndex
CREATE UNIQUE INDEX "SpaceInvite_token_key" ON "SpaceInvite"("token");
-- CreateIndex
CREATE INDEX "SpaceInvite_token_idx" ON "SpaceInvite"("token");
-- CreateIndex
CREATE INDEX "SpaceInvite_email_spaceId_idx" ON "SpaceInvite"("email", "spaceId");
-- CreateIndex
CREATE INDEX "Proposal_spaceId_status_idx" ON "Proposal"("spaceId", "status");
-- CreateIndex
CREATE UNIQUE INDEX "Vote_userId_proposalId_key" ON "Vote"("userId", "proposalId");
-- CreateIndex
CREATE UNIQUE INDEX "FinalVote_userId_proposalId_key" ON "FinalVote"("userId", "proposalId");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SpaceMember" ADD CONSTRAINT "SpaceMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SpaceMember" ADD CONSTRAINT "SpaceMember_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SpaceInvite" ADD CONSTRAINT "SpaceInvite_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Proposal" ADD CONSTRAINT "Proposal_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Proposal" ADD CONSTRAINT "Proposal_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Vote" ADD CONSTRAINT "Vote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Vote" ADD CONSTRAINT "Vote_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FinalVote" ADD CONSTRAINT "FinalVote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FinalVote" ADD CONSTRAINT "FinalVote_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@ -24,6 +24,9 @@ model User {
// NextAuth fields
accounts Account[]
sessions Session[]
// Space memberships
spaceMemberships SpaceMember[]
}
model Account {
@ -61,12 +64,85 @@ model VerificationToken {
@@unique([identifier, token])
}
// ─── Spaces (Multi-Tenant) ───────────────────────────────────────────
model Space {
id String @id @default(cuid())
name String
slug String @unique
description String? @db.Text
isPublic Boolean @default(false)
// Configurable per-space voting parameters
promotionThreshold Int @default(100)
votingPeriodDays Int @default(7)
creditsPerDay Int @default(10)
maxCredits Int @default(500)
startingCredits Int @default(50)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members SpaceMember[]
proposals Proposal[]
invites SpaceInvite[]
}
enum SpaceRole {
ADMIN
MEMBER
}
model SpaceMember {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
role SpaceRole @default(MEMBER)
// Per-space credits
credits Int @default(0)
lastCreditAt DateTime @default(now())
joinedAt DateTime @default(now())
@@unique([userId, spaceId])
@@index([spaceId])
}
model SpaceInvite {
id String @id @default(cuid())
spaceId String
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
// For email invites
email String?
// For shareable link invites
token String @unique @default(cuid())
// Invite metadata
maxUses Int?
uses Int @default(0)
expiresAt DateTime?
createdBy String
createdAt DateTime @default(now())
@@index([token])
@@index([email, spaceId])
}
// ─── Proposals & Voting ──────────────────────────────────────────────
model Proposal {
id String @id @default(cuid())
title String
description String @db.Text
authorId String
author User @relation(fields: [authorId], references: [id])
spaceId String?
space Space? @relation(fields: [spaceId], references: [id], onDelete: Cascade)
status ProposalStatus @default(RANKING)
score Int @default(0)
votes Vote[]
@ -74,6 +150,8 @@ model Proposal {
votingEndsAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([spaceId, status])
}
enum ProposalStatus {

84
prisma/seed-spaces.ts Normal file
View File

@ -0,0 +1,84 @@
/**
* Migration script: Create a "Legacy" space and migrate existing data.
*
* Run on the production DB after the schema migration:
* npx tsx prisma/seed-spaces.ts
*/
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
async function main() {
console.log("Starting space migration...");
// 1. Create the legacy space
const legacySpace = await prisma.space.upsert({
where: { slug: "legacy" },
update: {},
create: {
name: "rVote Community",
slug: "legacy",
description: "The original rVote community space.",
isPublic: true,
promotionThreshold: 100,
votingPeriodDays: 7,
creditsPerDay: 10,
maxCredits: 500,
startingCredits: 50,
},
});
console.log(`Legacy space created/found: ${legacySpace.id}`);
// 2. Enroll all existing users as members with their current credits
const users = await prisma.user.findMany({
select: { id: true, credits: true, lastCreditAt: true },
});
for (const user of users) {
await prisma.spaceMember.upsert({
where: {
userId_spaceId: { userId: user.id, spaceId: legacySpace.id },
},
update: {},
create: {
userId: user.id,
spaceId: legacySpace.id,
role: "MEMBER",
credits: user.credits,
lastCreditAt: user.lastCreditAt,
},
});
}
console.log(`Enrolled ${users.length} users into legacy space`);
// Make the first user an admin
if (users.length > 0) {
await prisma.spaceMember.update({
where: {
userId_spaceId: { userId: users[0].id, spaceId: legacySpace.id },
},
data: { role: "ADMIN" },
});
console.log(`Made first user admin: ${users[0].id}`);
}
// 3. Assign all existing proposals to the legacy space
const result = await prisma.proposal.updateMany({
where: { spaceId: null },
data: { spaceId: legacySpace.id },
});
console.log(`Assigned ${result.count} proposals to legacy space`);
console.log("Migration complete!");
}
main()
.catch((e) => {
console.error("Migration failed:", e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

View File

@ -0,0 +1,42 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { requireSpaceMembership } from "@/lib/spaces";
import { calculateAvailableCredits, maxAffordableWeight } from "@/lib/credits";
import { NextRequest, NextResponse } from "next/server";
// GET — Get the current user's credits in this space
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const session = await auth();
const { slug } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let space, membership;
try {
const result = await requireSpaceMembership(session.user.id, slug);
space = result.space;
membership = result.membership;
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: e.status || 403 });
}
const available = calculateAvailableCredits(
membership.credits,
membership.lastCreditAt,
space.creditsPerDay,
space.maxCredits
);
return NextResponse.json({
stored: membership.credits,
available,
maxAffordableVote: maxAffordableWeight(available),
creditsPerDay: space.creditsPerDay,
maxCredits: space.maxCredits,
});
}

View File

@ -0,0 +1,32 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { requireSpaceAdmin } from "@/lib/spaces";
import { NextRequest, NextResponse } from "next/server";
// DELETE — Revoke an invite (admin only)
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ slug: string; id: string }> }
) {
const session = await auth();
const { slug, id } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
await requireSpaceAdmin(session.user.id, slug);
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: e.status || 403 });
}
const invite = await prisma.spaceInvite.findUnique({ where: { id } });
if (!invite) {
return NextResponse.json({ error: "Invite not found" }, { status: 404 });
}
await prisma.spaceInvite.delete({ where: { id } });
return NextResponse.json({ success: true });
}

View File

@ -0,0 +1,71 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { requireSpaceAdmin } from "@/lib/spaces";
import { NextRequest, NextResponse } from "next/server";
// GET — List invites for a space (admin only)
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const session = await auth();
const { slug } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let space;
try {
const result = await requireSpaceAdmin(session.user.id, slug);
space = result.space;
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: e.status || 403 });
}
const invites = await prisma.spaceInvite.findMany({
where: { spaceId: space.id },
orderBy: { createdAt: "desc" },
});
return NextResponse.json(invites);
}
// POST — Create an invite (admin only)
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const session = await auth();
const { slug } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let space;
try {
const result = await requireSpaceAdmin(session.user.id, slug);
space = result.space;
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: e.status || 403 });
}
const { email, maxUses, expiresAt } = await req.json();
const invite = await prisma.spaceInvite.create({
data: {
spaceId: space.id,
email: email || null,
maxUses: maxUses || null,
expiresAt: expiresAt ? new Date(expiresAt) : null,
createdBy: session.user.id,
},
});
const rootDomain = process.env.ROOT_DOMAIN || "rvote.online";
const protocol = process.env.NODE_ENV === "production" ? "https" : "http";
const inviteUrl = `${protocol}://${slug}.${rootDomain}/join?token=${invite.token}`;
return NextResponse.json({ ...invite, inviteUrl }, { status: 201 });
}

View File

@ -0,0 +1,90 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { requireSpaceAdmin } from "@/lib/spaces";
import { NextRequest, NextResponse } from "next/server";
// PATCH — Update member role (admin only)
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ slug: string; userId: string }> }
) {
const session = await auth();
const { slug, userId } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let space;
try {
const result = await requireSpaceAdmin(session.user.id, slug);
space = result.space;
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: e.status || 403 });
}
const { role } = await req.json();
if (!role || !["ADMIN", "MEMBER"].includes(role)) {
return NextResponse.json({ error: "Role must be ADMIN or MEMBER" }, { status: 400 });
}
const member = await prisma.spaceMember.findUnique({
where: { userId_spaceId: { userId, spaceId: space.id } },
});
if (!member) {
return NextResponse.json({ error: "Member not found" }, { status: 404 });
}
const updated = await prisma.spaceMember.update({
where: { id: member.id },
data: { role },
include: { user: { select: { id: true, name: true, email: true } } },
});
return NextResponse.json(updated);
}
// DELETE — Remove member (admin only)
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ slug: string; userId: string }> }
) {
const session = await auth();
const { slug, userId } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let space;
try {
const result = await requireSpaceAdmin(session.user.id, slug);
space = result.space;
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: e.status || 403 });
}
// Prevent removing yourself if you're the last admin
if (userId === session.user.id) {
const adminCount = await prisma.spaceMember.count({
where: { spaceId: space.id, role: "ADMIN" },
});
if (adminCount <= 1) {
return NextResponse.json(
{ error: "Cannot remove the last admin" },
{ status: 400 }
);
}
}
const member = await prisma.spaceMember.findUnique({
where: { userId_spaceId: { userId, spaceId: space.id } },
});
if (!member) {
return NextResponse.json({ error: "Member not found" }, { status: 404 });
}
await prisma.spaceMember.delete({ where: { id: member.id } });
return NextResponse.json({ success: true });
}

View File

@ -0,0 +1,57 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { requireSpaceAdmin } from "@/lib/spaces";
import { NextRequest, NextResponse } from "next/server";
// POST — Allot credits to a member (admin only)
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const session = await auth();
const { slug } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let space;
try {
const result = await requireSpaceAdmin(session.user.id, slug);
space = result.space;
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: e.status || 403 });
}
const { userId, amount } = await req.json();
if (!userId || typeof userId !== "string") {
return NextResponse.json({ error: "userId is required" }, { status: 400 });
}
if (typeof amount !== "number" || amount <= 0) {
return NextResponse.json({ error: "Amount must be a positive number" }, { status: 400 });
}
const member = await prisma.spaceMember.findUnique({
where: { userId_spaceId: { userId, spaceId: space.id } },
});
if (!member) {
return NextResponse.json({ error: "Member not found" }, { status: 404 });
}
const updated = await prisma.spaceMember.update({
where: { id: member.id },
data: {
credits: { increment: amount },
lastCreditAt: new Date(),
},
include: { user: { select: { id: true, name: true, email: true } } },
});
return NextResponse.json({
success: true,
member: updated,
newCredits: updated.credits,
});
}

View File

@ -0,0 +1,92 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { requireSpaceAdmin, requireSpaceMembership } from "@/lib/spaces";
import { NextRequest, NextResponse } from "next/server";
// GET — List members of a space
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const session = await auth();
const { slug } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
await requireSpaceMembership(session.user.id, slug);
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: e.status || 403 });
}
const space = await prisma.space.findUnique({ where: { slug } });
const members = await prisma.spaceMember.findMany({
where: { spaceId: space!.id },
include: {
user: { select: { id: true, name: true, email: true } },
},
orderBy: { joinedAt: "asc" },
});
return NextResponse.json(members);
}
// POST — Add a member by email (admin only)
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const session = await auth();
const { slug } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let space;
try {
const result = await requireSpaceAdmin(session.user.id, slug);
space = result.space;
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: e.status || 403 });
}
const { email, role = "MEMBER" } = await req.json();
if (!email || typeof email !== "string") {
return NextResponse.json({ error: "Email is required" }, { status: 400 });
}
// Find user by email
const user = await prisma.user.findUnique({ where: { email } });
if (!user) {
return NextResponse.json(
{ error: "No user found with this email. They must create an account first." },
{ status: 404 }
);
}
// Check if already a member
const existing = await prisma.spaceMember.findUnique({
where: { userId_spaceId: { userId: user.id, spaceId: space.id } },
});
if (existing) {
return NextResponse.json({ error: "User is already a member" }, { status: 409 });
}
const member = await prisma.spaceMember.create({
data: {
userId: user.id,
spaceId: space.id,
role: role === "ADMIN" ? "ADMIN" : "MEMBER",
credits: space.startingCredits,
},
include: {
user: { select: { id: true, name: true, email: true } },
},
});
return NextResponse.json(member, { status: 201 });
}

View File

@ -0,0 +1,88 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { requireSpaceAdmin, requireSpaceMembership } from "@/lib/spaces";
import { NextRequest, NextResponse } from "next/server";
// GET /api/spaces/[slug] — Get space details
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const space = await prisma.space.findUnique({
where: { slug },
include: {
_count: { select: { members: true, proposals: true } },
},
});
if (!space) {
return NextResponse.json({ error: "Space not found" }, { status: 404 });
}
return NextResponse.json(space);
}
// PATCH /api/spaces/[slug] — Update space (admin only)
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const session = await auth();
const { slug } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
await requireSpaceAdmin(session.user.id, slug);
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: e.status || 403 });
}
const body = await req.json();
const allowedFields = [
"name", "description", "isPublic",
"promotionThreshold", "votingPeriodDays",
"creditsPerDay", "maxCredits", "startingCredits",
];
const data: Record<string, any> = {};
for (const field of allowedFields) {
if (body[field] !== undefined) {
data[field] = body[field];
}
}
const updated = await prisma.space.update({
where: { slug },
data,
});
return NextResponse.json(updated);
}
// DELETE /api/spaces/[slug] — Delete space (admin only)
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const session = await auth();
const { slug } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
await requireSpaceAdmin(session.user.id, slug);
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: e.status || 403 });
}
await prisma.space.delete({ where: { slug } });
return NextResponse.json({ success: true });
}

View File

@ -0,0 +1,110 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { NextRequest, NextResponse } from "next/server";
// POST — Accept an invite and join a space
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const session = await auth();
const { token } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const invite = await prisma.spaceInvite.findUnique({
where: { token },
include: { space: true },
});
if (!invite) {
return NextResponse.json({ error: "Invalid invite link" }, { status: 404 });
}
// Check expiry
if (invite.expiresAt && invite.expiresAt < new Date()) {
return NextResponse.json({ error: "This invite has expired" }, { status: 410 });
}
// Check max uses
if (invite.maxUses !== null && invite.uses >= invite.maxUses) {
return NextResponse.json({ error: "This invite has reached its usage limit" }, { status: 410 });
}
// Check email restriction
if (invite.email && invite.email !== session.user.email) {
return NextResponse.json(
{ error: "This invite is for a different email address" },
{ status: 403 }
);
}
// Check if already a member
const existing = await prisma.spaceMember.findUnique({
where: {
userId_spaceId: { userId: session.user.id, spaceId: invite.spaceId },
},
});
if (existing) {
return NextResponse.json({
success: true,
alreadyMember: true,
space: invite.space,
});
}
// Join the space
await prisma.$transaction([
prisma.spaceMember.create({
data: {
userId: session.user.id,
spaceId: invite.spaceId,
role: "MEMBER",
credits: invite.space.startingCredits,
},
}),
prisma.spaceInvite.update({
where: { id: invite.id },
data: { uses: { increment: 1 } },
}),
]);
return NextResponse.json({
success: true,
space: invite.space,
});
}
// GET — Get invite info (public, for showing the join page)
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ token: string }> }
) {
const { token } = await params;
const invite = await prisma.spaceInvite.findUnique({
where: { token },
include: {
space: {
select: { name: true, slug: true, description: true },
},
},
});
if (!invite) {
return NextResponse.json({ error: "Invalid invite link" }, { status: 404 });
}
const expired = invite.expiresAt ? invite.expiresAt < new Date() : false;
const maxedOut = invite.maxUses !== null ? invite.uses >= invite.maxUses : false;
return NextResponse.json({
space: invite.space,
expired,
maxedOut,
valid: !expired && !maxedOut,
});
}

View File

@ -0,0 +1,86 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { generateUniqueSlug } from "@/lib/spaces";
import { NextRequest, NextResponse } from "next/server";
// GET /api/spaces — List the user's spaces
export async function GET() {
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const memberships = await prisma.spaceMember.findMany({
where: { userId: session.user.id },
include: {
space: {
include: {
_count: { select: { members: true, proposals: true } },
},
},
},
orderBy: { joinedAt: "desc" },
});
const spaces = memberships.map((m) => ({
...m.space,
role: m.role,
memberCount: m.space._count.members,
proposalCount: m.space._count.proposals,
}));
return NextResponse.json(spaces);
}
// POST /api/spaces — Create a new space
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 { name, description, slug: requestedSlug } = body;
if (!name || typeof name !== "string" || name.trim().length === 0) {
return NextResponse.json({ error: "Name is required" }, { status: 400 });
}
if (name.length > 100) {
return NextResponse.json({ error: "Name must be 100 characters or less" }, { status: 400 });
}
const slug = requestedSlug
? requestedSlug.toLowerCase().replace(/[^a-z0-9-]/g, "")
: await generateUniqueSlug(name);
// Check slug uniqueness
const existing = await prisma.space.findUnique({ where: { slug } });
if (existing) {
return NextResponse.json({ error: "This slug is already taken" }, { status: 409 });
}
// Create space + admin membership in a transaction
const space = await prisma.$transaction(async (tx) => {
const newSpace = await tx.space.create({
data: {
name: name.trim(),
slug,
description: description?.trim() || null,
},
});
await tx.spaceMember.create({
data: {
userId: session.user.id,
spaceId: newSpace.id,
role: "ADMIN",
credits: newSpace.startingCredits,
},
});
return newSpace;
});
return NextResponse.json(space, { status: 201 });
}

View File

@ -1,206 +1,11 @@
"use client";
import { useState } from "react";
import { InteractiveDemo } from "@/components/InteractiveDemo";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
import {
ChevronUp,
ChevronDown,
Check,
X,
Minus,
ArrowRight,
RotateCcw,
Coins,
TrendingUp,
Clock,
} from "lucide-react";
interface DemoProposal {
id: number;
title: string;
description: string;
score: number;
userVote: number;
pendingVote: number;
stage: "ranking" | "voting";
yesVotes: number;
noVotes: number;
}
const initialProposals: DemoProposal[] = [
{
id: 1,
title: "Allocate 15% of treasury to ecosystem grants program",
description: "Fund community developers building tools and integrations for the ecosystem over the next 6 months",
score: 72,
userVote: 0,
pendingVote: 0,
stage: "ranking",
yesVotes: 0,
noVotes: 0,
},
{
id: 2,
title: "Establish a community moderation council",
description: "Elect 7 members to handle disputes, enforce guidelines, and maintain community standards",
score: 58,
userVote: 0,
pendingVote: 0,
stage: "ranking",
yesVotes: 0,
noVotes: 0,
},
{
id: 3,
title: "Partner with University research lab for governance study",
description: "Collaborate with academic researchers to analyze and improve our decision-making processes",
score: 41,
userVote: 0,
pendingVote: 0,
stage: "ranking",
yesVotes: 0,
noVotes: 0,
},
{
id: 4,
title: "Create bounty program for security audits",
description: "Reward external security researchers who identify vulnerabilities in our smart contracts",
score: 35,
userVote: 0,
pendingVote: 0,
stage: "ranking",
yesVotes: 0,
noVotes: 0,
},
{
id: 5,
title: "Host quarterly virtual town halls",
description: "Regular video conferences for community updates, Q&A sessions, and open discussion",
score: 23,
userVote: 0,
pendingVote: 0,
stage: "ranking",
yesVotes: 0,
noVotes: 0,
},
];
import { ArrowRight } from "lucide-react";
export default function DemoPage() {
const [credits, setCredits] = useState(100);
const [proposals, setProposals] = useState<DemoProposal[]>(initialProposals);
const maxWeight = Math.floor(Math.sqrt(credits));
function handleUpvote(proposalId: number) {
setProposals((prev) =>
prev.map((p) => {
if (p.id !== proposalId) return p;
// If already voted up, remove the vote
if (p.userVote > 0) {
const refund = p.userVote * p.userVote;
setCredits((c) => c + refund);
return { ...p, score: p.score - p.userVote, userVote: 0, pendingVote: 0 };
}
// If already voted down, can't upvote
if (p.userVote < 0) return p;
// Increment pending vote
const newPending = p.pendingVote + 1;
const newCost = newPending * newPending;
if (newCost <= credits && newPending <= maxWeight) {
return { ...p, pendingVote: newPending };
}
return p;
})
);
}
function handleDownvote(proposalId: number) {
setProposals((prev) =>
prev.map((p) => {
if (p.id !== proposalId) return p;
// If already voted down, remove the vote
if (p.userVote < 0) {
const refund = p.userVote * p.userVote;
setCredits((c) => c + refund);
return { ...p, score: p.score - p.userVote, userVote: 0, pendingVote: 0 };
}
// If already voted up, can't downvote
if (p.userVote > 0) return p;
// Decrement pending vote
const newPending = p.pendingVote - 1;
const newCost = newPending * newPending;
if (newCost <= credits && Math.abs(newPending) <= maxWeight) {
return { ...p, pendingVote: newPending };
}
return p;
})
);
}
function cancelPending(proposalId: number) {
setProposals((prev) =>
prev.map((p) => (p.id === proposalId ? { ...p, pendingVote: 0 } : p))
);
}
function confirmVote(proposalId: number) {
setProposals((prev) =>
prev.map((p) => {
if (p.id !== proposalId || p.pendingVote === 0) return p;
const cost = p.pendingVote * p.pendingVote;
const newScore = p.score + p.pendingVote;
const promoted = newScore >= 100 && p.stage === "ranking";
setCredits((c) => c - cost);
return {
...p,
score: newScore,
userVote: p.pendingVote,
pendingVote: 0,
stage: promoted ? "voting" : p.stage,
yesVotes: promoted ? 8 : p.yesVotes,
noVotes: promoted ? 3 : p.noVotes,
};
})
);
}
function castFinalVote(proposalId: number, vote: "yes" | "no" | "abstain") {
setProposals((prev) =>
prev.map((p) => {
if (p.id === proposalId) {
return {
...p,
yesVotes: vote === "yes" ? p.yesVotes + 1 : p.yesVotes,
noVotes: vote === "no" ? p.noVotes + 1 : p.noVotes,
};
}
return p;
})
);
}
function resetDemo() {
setCredits(100);
setProposals(initialProposals);
}
const rankingProposals = proposals
.filter((p) => p.stage === "ranking")
.sort((a, b) => b.score - a.score);
const votingProposals = proposals.filter((p) => p.stage === "voting");
return (
<div className="max-w-4xl mx-auto space-y-8">
<div className="text-center space-y-4">
@ -214,296 +19,15 @@ export default function DemoPage() {
</p>
</div>
{/* Credits display */}
<Card className="border-2 border-orange-500/30 bg-gradient-to-r from-orange-500/10 to-amber-500/10">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Coins className="h-6 w-6 text-orange-500" />
<span className="font-bold text-2xl text-orange-600">{credits}</span>
<span className="text-muted-foreground">credits</span>
</div>
<Badge variant="outline" className="border-orange-500/30 text-orange-600">
Max vote: ±{maxWeight}
</Badge>
</div>
<Button variant="outline" size="sm" onClick={resetDemo} className="border-orange-500/30 hover:bg-orange-500/10">
<RotateCcw className="h-4 w-4 mr-2" />
Reset Demo
</Button>
</div>
</CardContent>
</Card>
{/* Quadratic cost explainer */}
<Card className="border-muted">
<CardHeader className="pb-2">
<CardTitle className="text-lg flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-orange-500" />
Quadratic Voting Cost
</CardTitle>
<CardDescription>
Each additional vote costs exponentially more credits
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-5 gap-2 text-center text-sm">
{[1, 2, 3, 4, 5].map((w) => (
<div
key={w}
className={`p-3 rounded-lg border-2 transition-all ${
w <= maxWeight
? "bg-orange-500/10 border-orange-500/40 text-orange-700"
: "bg-muted/50 border-muted text-muted-foreground"
}`}
>
<div className="font-bold text-xl">+{w}</div>
<div className="text-xs opacity-70">vote{w > 1 ? "s" : ""}</div>
<div className="font-mono text-sm mt-1 font-semibold">{w * w}¢</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Ranking stage */}
<section className="space-y-3">
<div className="flex items-center gap-3">
<Badge className="bg-orange-500 hover:bg-orange-600">Stage 1</Badge>
<h2 className="text-xl font-semibold">Quadratic Ranking</h2>
<span className="text-muted-foreground text-sm">
Score +100 to advance
</span>
</div>
<div className="space-y-2">
{rankingProposals.map((proposal) => {
const hasPending = proposal.pendingVote !== 0;
const hasVoted = proposal.userVote !== 0;
const pendingCost = proposal.pendingVote * proposal.pendingVote;
const displayScore = hasPending ? proposal.score + proposal.pendingVote : proposal.score;
const progressPercent = Math.min((displayScore / 100) * 100, 100);
const isUpvoted = (hasVoted && proposal.userVote > 0) || proposal.pendingVote > 0;
const isDownvoted = (hasVoted && proposal.userVote < 0) || proposal.pendingVote < 0;
return (
<div
key={proposal.id}
className={`flex rounded-xl border bg-card shadow-sm overflow-hidden transition-all duration-200 ${
hasPending
? proposal.pendingVote > 0
? "ring-2 ring-orange-500/50"
: "ring-2 ring-blue-500/50"
: ""
}`}
>
{/* Reddit-style vote column */}
<div className="flex flex-col items-center justify-center py-3 px-4 bg-muted/50 border-r min-w-[72px]">
<Button
variant="ghost"
size="sm"
className={`h-10 w-10 p-0 rounded-md transition-all ${
isUpvoted
? "text-orange-500 bg-orange-500/10 hover:bg-orange-500/20"
: "text-muted-foreground hover:text-orange-500 hover:bg-orange-500/10"
} ${hasVoted && proposal.userVote < 0 ? "opacity-30 pointer-events-none" : ""}`}
onClick={() => handleUpvote(proposal.id)}
>
<ChevronUp className="h-7 w-7" strokeWidth={2.5} />
</Button>
<span className={`font-bold text-xl tabular-nums py-1 ${
isUpvoted ? "text-orange-500" : isDownvoted ? "text-blue-500" : "text-foreground"
}`}>
{displayScore}
</span>
<Button
variant="ghost"
size="sm"
className={`h-10 w-10 p-0 rounded-md transition-all ${
isDownvoted
? "text-blue-500 bg-blue-500/10 hover:bg-blue-500/20"
: "text-muted-foreground hover:text-blue-500 hover:bg-blue-500/10"
} ${hasVoted && proposal.userVote > 0 ? "opacity-30 pointer-events-none" : ""}`}
onClick={() => handleDownvote(proposal.id)}
>
<ChevronDown className="h-7 w-7" strokeWidth={2.5} />
</Button>
</div>
{/* Proposal content */}
<div className="flex-1 p-4 min-w-0">
<h3 className="font-semibold text-base leading-tight">{proposal.title}</h3>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{proposal.description}
</p>
{/* Progress bar */}
<div className="mt-3">
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
<span>Progress to voting stage</span>
<span className={isUpvoted ? "text-orange-500 font-medium" : isDownvoted ? "text-blue-500 font-medium" : ""}>
{displayScore}/100
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${
isUpvoted ? "bg-orange-500" : isDownvoted ? "bg-blue-500" : "bg-primary"
}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
{/* Pending vote confirmation */}
{hasPending && (
<div className="mt-3 flex items-center gap-2">
<Badge
variant="outline"
className={proposal.pendingVote > 0
? "border-orange-500/50 text-orange-600 bg-orange-500/10"
: "border-blue-500/50 text-blue-600 bg-blue-500/10"
}
>
{proposal.pendingVote > 0 ? "+" : ""}{proposal.pendingVote} vote = {pendingCost} credits
</Badge>
<Button
size="sm"
variant="ghost"
className="h-7 px-2"
onClick={() => cancelPending(proposal.id)}
>
<X className="h-4 w-4 mr-1" />
Cancel
</Button>
<Button
size="sm"
className={`h-7 ${
proposal.pendingVote > 0
? "bg-orange-500 hover:bg-orange-600"
: "bg-blue-500 hover:bg-blue-600"
}`}
onClick={() => confirmVote(proposal.id)}
>
<Check className="h-4 w-4 mr-1" />
Confirm
</Button>
</div>
)}
{/* Existing vote indicator */}
{hasVoted && !hasPending && (
<div className="mt-3">
<Badge
variant="secondary"
className={proposal.userVote > 0
? "bg-orange-500/20 text-orange-600"
: "bg-blue-500/20 text-blue-600"
}
>
You voted: {proposal.userVote > 0 ? "+" : ""}{proposal.userVote}
</Badge>
</div>
)}
</div>
</div>
);
})}
</div>
</section>
{/* Voting stage */}
{votingProposals.length > 0 && (
<section className="space-y-3">
<div className="flex items-center gap-3">
<Badge variant="outline" className="border-green-500/50 text-green-600">Stage 2</Badge>
<h2 className="text-xl font-semibold">Pass/Fail Voting</h2>
<span className="text-muted-foreground text-sm">
One member = one vote
</span>
</div>
{votingProposals.map((proposal) => {
const total = proposal.yesVotes + proposal.noVotes;
const yesPercent = total > 0 ? (proposal.yesVotes / total) * 100 : 50;
return (
<Card key={proposal.id} className="border-green-500/30 bg-green-500/5">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{proposal.title}</CardTitle>
<div className="flex items-center gap-1 text-sm text-amber-600">
<Clock className="h-4 w-4" />
<span>6 days left</span>
</div>
</div>
<CardDescription>{proposal.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="h-4 rounded-full overflow-hidden bg-muted flex">
<div
className="h-full bg-green-500 transition-all"
style={{ width: `${yesPercent}%` }}
/>
<div
className="h-full bg-red-500 transition-all"
style={{ width: `${100 - yesPercent}%` }}
/>
</div>
<div className="flex justify-between text-sm font-medium">
<span className="text-green-600">
{proposal.yesVotes} Yes ({Math.round(yesPercent)}%)
</span>
<span className="text-red-600">
{proposal.noVotes} No ({Math.round(100 - yesPercent)}%)
</span>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<Button
variant="outline"
className="flex-col h-auto py-3 border-green-500/30 hover:bg-green-500/10 hover:border-green-500"
onClick={() => castFinalVote(proposal.id, "yes")}
>
<Check className="h-5 w-5 text-green-500" />
<span className="text-xs mt-1">Yes</span>
</Button>
<Button
variant="outline"
className="flex-col h-auto py-3 border-red-500/30 hover:bg-red-500/10 hover:border-red-500"
onClick={() => castFinalVote(proposal.id, "no")}
>
<X className="h-5 w-5 text-red-500" />
<span className="text-xs mt-1">No</span>
</Button>
<Button
variant="outline"
className="flex-col h-auto py-3"
onClick={() => castFinalVote(proposal.id, "abstain")}
>
<Minus className="h-5 w-5" />
<span className="text-xs mt-1">Abstain</span>
</Button>
</div>
</CardContent>
</Card>
);
})}
</section>
)}
<InteractiveDemo />
{/* CTA */}
<Card className="border-2 border-primary/30 bg-gradient-to-r from-primary/10 to-accent/10">
<CardContent className="py-8 text-center space-y-4">
<h2 className="text-2xl font-bold">Ready to try it for real?</h2>
<p className="text-muted-foreground">
Create an account to start ranking and voting on community proposals.
You&apos;ll get 50 credits to start and earn 10 more each day.
Create a Space for your community to start ranking and voting on proposals.
Invite members and allot credits to get started.
</p>
<div className="flex justify-center gap-4">
<Button asChild size="lg" className="bg-orange-500 hover:bg-orange-600">

View File

@ -1,11 +1,7 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { ProposalList } from "@/components/ProposalList";
import { InteractiveDemo } from "@/components/InteractiveDemo";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { calculateAvailableCredits } from "@/lib/credits";
import { getEffectiveWeight } from "@/lib/voting";
import Link from "next/link";
import {
ArrowRight,
@ -21,62 +17,10 @@ import {
Play,
ListOrdered,
Target,
Layers,
ChevronUp,
} from "lucide-react";
export default async function HomePage() {
const session = await auth();
// Get top ranking proposals
const proposals = await prisma.proposal.findMany({
where: { status: "RANKING" },
orderBy: { score: "desc" },
take: 5,
include: {
author: {
select: { id: true, name: true, email: true },
},
votes: true,
},
});
// Get user's votes and credits if logged in
let availableCredits = 0;
let userVotes: { proposalId: string; weight: number; effectiveWeight: number }[] = [];
if (session?.user?.id) {
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { credits: true, lastCreditAt: true },
});
if (user) {
availableCredits = calculateAvailableCredits(user.credits, user.lastCreditAt);
}
const votes = await prisma.vote.findMany({
where: {
userId: session.user.id,
proposalId: { in: proposals.map((p) => p.id) },
},
});
userVotes = votes.map((v) => ({
proposalId: v.proposalId,
weight: v.weight,
effectiveWeight: getEffectiveWeight(v.weight, v.createdAt),
}));
}
// Get counts for stats
const [rankingCount, votingCount, passedCount, userCount] = await Promise.all([
prisma.proposal.count({ where: { status: "RANKING" } }),
prisma.proposal.count({ where: { status: "VOTING" } }),
prisma.proposal.count({ where: { status: "PASSED" } }),
prisma.user.count(),
]);
export default function HomePage() {
return (
<div className="space-y-16">
{/* Hero section */}
@ -104,15 +48,9 @@ export default async function HomePage() {
Try the Demo
</Link>
</Button>
{!session?.user ? (
<Button asChild variant="outline" size="lg" className="text-lg px-8 border-primary/30 hover:bg-primary/5">
<Link href="/auth/signup">Create Account</Link>
</Button>
) : (
<Button asChild variant="outline" size="lg" className="text-lg px-8 border-primary/30 hover:bg-primary/5">
<Link href="/proposals">Browse Proposals</Link>
</Button>
)}
<Button asChild variant="outline" size="lg" className="text-lg px-8 border-primary/30 hover:bg-primary/5">
<Link href="/auth/signup">Create a Space</Link>
</Button>
</div>
</section>
@ -137,7 +75,7 @@ export default async function HomePage() {
<CardContent className="pt-5 pb-4">
<div className="flex items-center gap-2 mb-3">
<div className="h-8 w-8 rounded-full bg-orange-500 flex items-center justify-center">
<span className="text-white font-bold text-sm">x²</span>
<span className="text-white font-bold text-sm">x&sup2;</span>
</div>
<h3 className="font-bold text-orange-600 text-lg">Quadratic</h3>
</div>
@ -182,6 +120,22 @@ export default async function HomePage() {
</div>
</section>
{/* Interactive Demo inline */}
<section className="py-8">
<div className="text-center mb-6">
<Badge variant="secondary" className="mb-3">
Interactive Demo
</Badge>
<h2 className="text-2xl font-bold">Try It Yourself</h2>
<p className="text-muted-foreground max-w-2xl mx-auto">
Click the vote arrows to rank proposals. Watch how quadratic costs scale in real-time.
</p>
</div>
<div className="max-w-4xl mx-auto">
<InteractiveDemo />
</div>
</section>
{/* What is Quadratic Proposal Ranking */}
<section className="py-8">
<div className="text-center mb-12">
@ -431,57 +385,6 @@ export default async function HomePage() {
</div>
</section>
{/* Stats */}
{(rankingCount > 0 || userCount > 1) && (
<section className="py-8">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="bg-gradient-to-br from-primary/10 to-transparent border-primary/20">
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold text-primary">{userCount}</div>
<p className="text-sm text-muted-foreground">Members</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-accent/10 to-transparent border-accent/20">
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold text-accent">{rankingCount}</div>
<p className="text-sm text-muted-foreground">Being Ranked</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-secondary/10 to-transparent border-secondary/20">
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold text-secondary">{votingCount}</div>
<p className="text-sm text-muted-foreground">In Voting</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-green-500/10 to-transparent border-green-500/20">
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold text-green-600">{passedCount}</div>
<p className="text-sm text-muted-foreground">Passed</p>
</CardContent>
</Card>
</div>
</section>
)}
{/* Active proposals */}
{proposals.length > 0 && (
<section className="py-8">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold">Active Proposals</h2>
<Button asChild variant="ghost" className="text-primary hover:text-primary/80">
<Link href="/proposals">
View All <ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
<ProposalList
proposals={proposals}
userVotes={userVotes}
availableCredits={availableCredits}
/>
</section>
)}
{/* CTA */}
<section className="py-12">
<Card className="border-2 border-primary/30 bg-gradient-to-br from-primary/10 via-accent/5 to-secondary/10 overflow-hidden relative">
@ -491,24 +394,22 @@ export default async function HomePage() {
<Badge className="bg-primary/10 text-primary border-primary/20">Join the rSpace Ecosystem</Badge>
<h2 className="text-3xl font-bold">Ready to prioritize democratically?</h2>
<p className="text-lg text-muted-foreground max-w-xl mx-auto">
Experience Quadratic Proposal Ranking firsthand. Try the interactive demo or
create an account to start building your community&apos;s backlog together.
Create a Space for your community and start using Quadratic Proposal Ranking.
Invite members, allot credits, and let the best ideas rise to the top.
</p>
<div className="flex flex-col sm:flex-row justify-center gap-4">
<Button asChild size="lg" className="text-lg px-8 bg-gradient-to-r from-primary to-accent hover:opacity-90">
<Link href="/spaces/new">
Create a Space
<ArrowRight className="ml-2 h-5 w-5" />
</Link>
</Button>
<Button asChild variant="outline" size="lg" className="text-lg px-8 border-primary/30 hover:bg-primary/5">
<Link href="/demo">
<Play className="mr-2 h-5 w-5" />
Interactive Demo
</Link>
</Button>
{!session?.user && (
<Button asChild variant="outline" size="lg" className="text-lg px-8 border-primary/30 hover:bg-primary/5">
<Link href="/auth/signup">
Create Free Account
<ArrowRight className="ml-2 h-5 w-5" />
</Link>
</Button>
)}
</div>
</CardContent>
</Card>

View File

@ -1,330 +1,27 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { VoteButtons } from "@/components/VoteButtons";
import { FinalVotePanel } from "@/components/FinalVotePanel";
import { calculateAvailableCredits } from "@/lib/credits";
import { getEffectiveWeight, getDecayPercentage } from "@/lib/voting";
import { formatDistanceToNow, format } from "date-fns";
import Link from "next/link";
import { ArrowLeft, User, Clock, TrendingUp } from "lucide-react";
import { ProposalStatus } from "@prisma/client";
import { redirect, notFound } from "next/navigation";
const statusColors: Record<ProposalStatus, string> = {
RANKING: "bg-blue-500/10 text-blue-500 border-blue-500/20",
VOTING: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20",
PASSED: "bg-green-500/10 text-green-500 border-green-500/20",
FAILED: "bg-red-500/10 text-red-500 border-red-500/20",
ARCHIVED: "bg-gray-500/10 text-gray-500 border-gray-500/20",
};
export default async function ProposalPage({
export default async function ProposalDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const session = await auth();
// Look up the proposal's space and redirect to the space-scoped URL
const proposal = await prisma.proposal.findUnique({
where: { id },
include: {
author: { select: { id: true, name: true, email: true } },
votes: {
include: {
user: { select: { id: true, name: true } },
},
orderBy: { weight: "desc" },
},
finalVotes: true,
},
include: { space: { select: { slug: true } } },
});
if (!proposal) {
notFound();
if (!proposal) notFound();
if (proposal.space) {
const rootDomain = process.env.ROOT_DOMAIN || "rvote.online";
const protocol = process.env.NODE_ENV === "production" ? "https" : "http";
redirect(`${protocol}://${proposal.space.slug}.${rootDomain}/proposals/${id}`);
}
// Get user's credits and vote
let availableCredits = 0;
let userVote: { weight: number; effectiveWeight: number } | undefined;
let userFinalVote: "YES" | "NO" | "ABSTAIN" | undefined;
if (session?.user?.id) {
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { credits: true, lastCreditAt: true },
});
if (user) {
availableCredits = calculateAvailableCredits(user.credits, user.lastCreditAt);
}
const existingVote = proposal.votes.find((v) => v.userId === session.user.id);
if (existingVote) {
userVote = {
weight: existingVote.weight,
effectiveWeight: getEffectiveWeight(existingVote.weight, existingVote.createdAt),
};
}
const existingFinalVote = proposal.finalVotes.find(
(v) => v.userId === session.user.id
);
if (existingFinalVote) {
userFinalVote = existingFinalVote.vote;
}
}
// Calculate final vote counts
const finalVoteCounts = proposal.finalVotes.reduce(
(acc, fv) => {
acc[fv.vote.toLowerCase() as "yes" | "no" | "abstain"]++;
acc.total++;
return acc;
},
{ yes: 0, no: 0, abstain: 0, total: 0 }
);
// Calculate effective score
const effectiveScore = proposal.votes.reduce((sum, v) => {
return sum + getEffectiveWeight(v.weight, v.createdAt);
}, 0);
const isRanking = proposal.status === "RANKING";
const isVoting = proposal.status === "VOTING";
const isCompleted = proposal.status === "PASSED" || proposal.status === "FAILED";
const progressToVoting = Math.min((effectiveScore / 100) * 100, 100);
return (
<div className="max-w-4xl mx-auto space-y-6">
<Button variant="ghost" asChild>
<Link href="/proposals">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Proposals
</Link>
</Button>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Main content */}
<div className="md:col-span-2 space-y-6">
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-4">
<CardTitle className="text-2xl">{proposal.title}</CardTitle>
<Badge variant="outline" className={statusColors[proposal.status]}>
{proposal.status}
</Badge>
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<User className="h-4 w-4" />
{proposal.author.name || proposal.author.email.split("@")[0]}
</span>
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{formatDistanceToNow(proposal.createdAt, { addSuffix: true })}
</span>
</div>
</CardHeader>
<CardContent>
<div className="prose dark:prose-invert max-w-none">
<p className="whitespace-pre-wrap">{proposal.description}</p>
</div>
</CardContent>
</Card>
{/* Votes list for ranking stage */}
{isRanking && proposal.votes.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Recent Votes</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{proposal.votes.slice(0, 10).map((vote) => {
const effectiveWeight = getEffectiveWeight(
vote.weight,
vote.createdAt
);
const decayPct = getDecayPercentage(vote.createdAt);
return (
<div
key={vote.id}
className="flex items-center justify-between py-2 border-b last:border-0"
>
<div className="flex items-center gap-2">
<Badge
variant={vote.weight > 0 ? "default" : "destructive"}
>
{vote.weight > 0 ? "+" : ""}
{effectiveWeight}
</Badge>
<span className="text-sm">
{vote.user.name || "Anonymous"}
</span>
</div>
<div className="text-xs text-muted-foreground">
{decayPct > 0 && (
<span className="text-yellow-600">
{decayPct}% decayed {" "}
</span>
)}
{formatDistanceToNow(vote.createdAt, {
addSuffix: true,
})}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Ranking vote panel */}
{isRanking && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Ranking</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-center">
<VoteButtons
proposalId={proposal.id}
currentScore={effectiveScore}
userVote={userVote}
availableCredits={availableCredits}
disabled={!session?.user}
/>
</div>
{!session?.user && (
<p className="text-sm text-muted-foreground text-center">
<Link href="/auth/signin" className="text-primary hover:underline">
Sign in
</Link>{" "}
to vote
</p>
)}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="flex items-center gap-1 text-muted-foreground">
<TrendingUp className="h-4 w-4" />
Progress to voting
</span>
<span className="font-mono">{effectiveScore}/100</span>
</div>
<div className="h-2 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progressToVoting}%` }}
/>
</div>
</div>
<div className="text-xs text-muted-foreground space-y-1">
<p>
<strong>{proposal.votes.length}</strong> votes cast
</p>
{session?.user && (
<p>
You have <strong>{availableCredits}</strong> credits
</p>
)}
</div>
</CardContent>
</Card>
)}
{/* Pass/fail vote panel */}
{isVoting && proposal.votingEndsAt && (
<FinalVotePanel
proposalId={proposal.id}
votingEndsAt={proposal.votingEndsAt}
votes={finalVoteCounts}
userVote={userFinalVote}
/>
)}
{/* Completed result */}
{isCompleted && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Result</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-center">
<Badge
variant={proposal.status === "PASSED" ? "default" : "destructive"}
className="text-lg px-4 py-1"
>
{proposal.status}
</Badge>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-sm">
<div>
<div className="font-bold text-green-500">
{finalVoteCounts.yes}
</div>
<div className="text-muted-foreground">Yes</div>
</div>
<div>
<div className="font-bold text-red-500">
{finalVoteCounts.no}
</div>
<div className="text-muted-foreground">No</div>
</div>
<div>
<div className="font-bold text-gray-500">
{finalVoteCounts.abstain}
</div>
<div className="text-muted-foreground">Abstain</div>
</div>
</div>
{proposal.votingEndsAt && (
<p className="text-xs text-muted-foreground text-center">
Voting ended {format(proposal.votingEndsAt, "PPP")}
</p>
)}
</CardContent>
</Card>
)}
{/* Info card */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Details</CardTitle>
</CardHeader>
<CardContent className="text-sm space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Created</span>
<span>{format(proposal.createdAt, "PPP")}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Last updated</span>
<span>{format(proposal.updatedAt, "PPP")}</span>
</div>
{proposal.votingEndsAt && (
<div className="flex justify-between">
<span className="text-muted-foreground">
{isVoting ? "Voting ends" : "Voting ended"}
</span>
<span>{format(proposal.votingEndsAt, "PPP")}</span>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
// Fallback: redirect to spaces list if no space assigned
redirect("/spaces");
}

View File

@ -1,131 +1,5 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2, ArrowLeft } from "lucide-react";
import { toast } from "sonner";
import Link from "next/link";
import { redirect } from "next/navigation";
export default function NewProposalPage() {
const router = useRouter();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [isLoading, setIsLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) {
toast.error("Title is required");
return;
}
if (!description.trim()) {
toast.error("Description is required");
return;
}
setIsLoading(true);
try {
const res = await fetch("/api/proposals", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, description }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Failed to create proposal");
}
toast.success("Proposal created successfully!");
router.push(`/proposals/${data.proposal.id}`);
} catch (error) {
toast.error(error instanceof Error ? error.message : "An error occurred");
} finally {
setIsLoading(false);
}
}
return (
<div className="max-w-2xl mx-auto">
<Button variant="ghost" asChild className="mb-4">
<Link href="/proposals">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Proposals
</Link>
</Button>
<Card>
<CardHeader>
<CardTitle>Create New Proposal</CardTitle>
<CardDescription>
Submit a proposal for the community to rank and vote on. Proposals
start in the ranking stage and advance to pass/fail voting when they
reach a score of +100.
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
placeholder="A clear, concise title for your proposal"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
required
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
{title.length}/200 characters
</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Describe your proposal in detail. What problem does it solve? How would it be implemented? What are the benefits?"
value={description}
onChange={(e) => setDescription(e.target.value)}
maxLength={10000}
rows={10}
required
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
{description.length}/10,000 characters
</p>
</div>
<div className="bg-muted p-4 rounded-lg text-sm space-y-2">
<p className="font-medium">What happens next?</p>
<ul className="list-disc list-inside text-muted-foreground space-y-1">
<li>Your proposal starts in the <strong>Ranking</strong> stage</li>
<li>Community members can upvote or downvote using their credits</li>
<li>When the score reaches <strong>+100</strong>, it advances to voting</li>
<li>In the voting stage, members vote Yes/No for 7 days</li>
</ul>
</div>
</CardContent>
<CardFooter>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Submit Proposal
</Button>
</CardFooter>
</form>
</Card>
</div>
);
redirect("/spaces");
}

View File

@ -1,138 +1,5 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { ProposalList } from "@/components/ProposalList";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { calculateAvailableCredits } from "@/lib/credits";
import { getEffectiveWeight } from "@/lib/voting";
import Link from "next/link";
import { Plus } from "lucide-react";
import { redirect } from "next/navigation";
export default async function ProposalsPage() {
const session = await auth();
// Get all proposals grouped by status
const [rankingProposals, votingProposals, completedProposals] = await Promise.all([
prisma.proposal.findMany({
where: { status: "RANKING" },
orderBy: { score: "desc" },
include: {
author: { select: { id: true, name: true, email: true } },
votes: true,
},
}),
prisma.proposal.findMany({
where: { status: "VOTING" },
orderBy: { votingEndsAt: "asc" },
include: {
author: { select: { id: true, name: true, email: true } },
votes: true,
},
}),
prisma.proposal.findMany({
where: { status: { in: ["PASSED", "FAILED"] } },
orderBy: { updatedAt: "desc" },
take: 20,
include: {
author: { select: { id: true, name: true, email: true } },
votes: true,
},
}),
]);
// Get user's votes and credits if logged in
let availableCredits = 0;
let userVotes: { proposalId: string; weight: number; effectiveWeight: number }[] = [];
if (session?.user?.id) {
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { credits: true, lastCreditAt: true },
});
if (user) {
availableCredits = calculateAvailableCredits(user.credits, user.lastCreditAt);
}
const allProposalIds = [
...rankingProposals.map((p) => p.id),
...votingProposals.map((p) => p.id),
...completedProposals.map((p) => p.id),
];
const votes = await prisma.vote.findMany({
where: {
userId: session.user.id,
proposalId: { in: allProposalIds },
},
});
userVotes = votes.map((v) => ({
proposalId: v.proposalId,
weight: v.weight,
effectiveWeight: getEffectiveWeight(v.weight, v.createdAt),
}));
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Proposals</h1>
<p className="text-muted-foreground">
Browse, rank, and vote on community proposals
</p>
</div>
{session?.user && (
<Button asChild>
<Link href="/proposals/new">
<Plus className="h-4 w-4 mr-2" />
New Proposal
</Link>
</Button>
)}
</div>
<Tabs defaultValue="ranking" className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="ranking">
Ranking ({rankingProposals.length})
</TabsTrigger>
<TabsTrigger value="voting">
Voting ({votingProposals.length})
</TabsTrigger>
<TabsTrigger value="completed">
Completed ({completedProposals.length})
</TabsTrigger>
</TabsList>
<TabsContent value="ranking" className="mt-6">
<ProposalList
proposals={rankingProposals}
userVotes={userVotes}
availableCredits={availableCredits}
emptyMessage="No proposals are currently being ranked."
/>
</TabsContent>
<TabsContent value="voting" className="mt-6">
<ProposalList
proposals={votingProposals}
userVotes={userVotes}
availableCredits={availableCredits}
emptyMessage="No proposals are currently in voting."
/>
</TabsContent>
<TabsContent value="completed" className="mt-6">
<ProposalList
proposals={completedProposals}
userVotes={userVotes}
availableCredits={availableCredits}
emptyMessage="No proposals have been completed yet."
/>
</TabsContent>
</Tabs>
</div>
);
export default function ProposalsPage() {
redirect("/spaces");
}

View File

@ -0,0 +1,105 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { requireSpaceMembership } from "@/lib/spaces";
import { NextRequest, NextResponse } from "next/server";
import { VoteChoice } from "@prisma/client";
// POST — Cast a final vote (space-scoped)
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ slug: string; id: string }> }
) {
const session = await auth();
const { slug, id: proposalId } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let space;
try {
const result = await requireSpaceMembership(session.user.id, slug);
space = result.space;
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: e.status || 403 });
}
try {
const { vote } = await req.json();
if (!vote || !["YES", "NO", "ABSTAIN"].includes(vote)) {
return NextResponse.json({ error: "Vote must be YES, NO, or ABSTAIN" }, { status: 400 });
}
const proposal = await prisma.proposal.findUnique({
where: { id: proposalId },
select: { status: true, votingEndsAt: true, spaceId: true },
});
if (!proposal || proposal.spaceId !== space.id) {
return NextResponse.json({ error: "Proposal not found" }, { status: 404 });
}
if (proposal.status !== "VOTING") {
return NextResponse.json({ error: "This proposal is not in the voting stage" }, { status: 400 });
}
if (proposal.votingEndsAt && new Date() > proposal.votingEndsAt) {
return NextResponse.json({ error: "Voting has ended for this proposal" }, { status: 400 });
}
await prisma.finalVote.upsert({
where: { userId_proposalId: { userId: session.user.id, proposalId } },
create: { userId: session.user.id, proposalId, vote: vote as VoteChoice },
update: { vote: vote as VoteChoice },
});
const voteCounts = await prisma.finalVote.groupBy({
by: ["vote"],
where: { proposalId },
_count: true,
});
const votes = { yes: 0, no: 0, abstain: 0, total: 0 };
voteCounts.forEach((vc) => {
const key = vc.vote.toLowerCase() as "yes" | "no" | "abstain";
votes[key] = vc._count;
votes.total += vc._count;
});
return NextResponse.json({ success: true, votes, userVote: vote });
} catch (error) {
console.error("Final vote error:", error);
return NextResponse.json({ error: "Failed to cast vote" }, { status: 500 });
}
}
// GET — Get vote counts (space-scoped)
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ slug: string; id: string }> }
) {
const session = await auth();
const { slug, id: proposalId } = await params;
const voteCounts = await prisma.finalVote.groupBy({
by: ["vote"],
where: { proposalId },
_count: true,
});
const votes = { yes: 0, no: 0, abstain: 0, total: 0 };
voteCounts.forEach((vc) => {
const key = vc.vote.toLowerCase() as "yes" | "no" | "abstain";
votes[key] = vc._count;
votes.total += vc._count;
});
let userVote: VoteChoice | null = null;
if (session?.user?.id) {
const existing = await prisma.finalVote.findUnique({
where: { userId_proposalId: { userId: session.user.id, proposalId } },
});
userVote = existing?.vote || null;
}
return NextResponse.json({ votes, userVote });
}

View File

@ -0,0 +1,106 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { requireSpaceMembership } from "@/lib/spaces";
import { getEffectiveWeight } from "@/lib/voting";
import { NextRequest, NextResponse } from "next/server";
// GET — Get proposal details
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ slug: string; id: string }> }
) {
const { slug, id } = await params;
const space = await prisma.space.findUnique({ where: { slug } });
if (!space) {
return NextResponse.json({ error: "Space not found" }, { status: 404 });
}
const proposal = await prisma.proposal.findUnique({
where: { id },
include: {
author: { select: { id: true, name: true, email: true } },
votes: { include: { user: { select: { id: true, name: true } } }, orderBy: { weight: "desc" } },
finalVotes: true,
},
});
if (!proposal || proposal.spaceId !== space.id) {
return NextResponse.json({ error: "Proposal not found" }, { status: 404 });
}
const effectiveScore = proposal.votes.reduce(
(sum, v) => sum + getEffectiveWeight(v.weight, v.createdAt),
0
);
return NextResponse.json({ ...proposal, effectiveScore });
}
// PATCH — Update proposal (author only)
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ slug: string; id: string }> }
) {
const session = await auth();
const { slug, id } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
await requireSpaceMembership(session.user.id, slug);
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: e.status || 403 });
}
const proposal = await prisma.proposal.findUnique({ where: { id } });
if (!proposal) {
return NextResponse.json({ error: "Proposal not found" }, { status: 404 });
}
if (proposal.authorId !== session.user.id) {
return NextResponse.json({ error: "Only the author can edit this proposal" }, { status: 403 });
}
if (proposal.status !== "RANKING") {
return NextResponse.json({ error: "Can only edit proposals in ranking stage" }, { status: 400 });
}
const { title, description } = await req.json();
const data: { title?: string; description?: string } = {};
if (title) data.title = title;
if (description) data.description = description;
const updated = await prisma.proposal.update({ where: { id }, data });
return NextResponse.json(updated);
}
// DELETE — Delete proposal (author only)
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ slug: string; id: string }> }
) {
const session = await auth();
const { slug, id } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
await requireSpaceMembership(session.user.id, slug);
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: e.status || 403 });
}
const proposal = await prisma.proposal.findUnique({ where: { id } });
if (!proposal) {
return NextResponse.json({ error: "Proposal not found" }, { status: 404 });
}
if (proposal.authorId !== session.user.id) {
return NextResponse.json({ error: "Only the author can delete this proposal" }, { status: 403 });
}
await prisma.proposal.delete({ where: { id } });
return NextResponse.json({ success: true });
}

View File

@ -0,0 +1,173 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { requireSpaceMembership } from "@/lib/spaces";
import { calculateAvailableCredits, calculateVoteCost } from "@/lib/credits";
import { getEffectiveWeight, shouldPromote, getVotingEndDate, DECAY_START_DAYS } from "@/lib/voting";
import { NextRequest, NextResponse } from "next/server";
import { addDays } from "date-fns";
// POST — Cast a ranking vote (space-scoped)
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ slug: string; id: string }> }
) {
const session = await auth();
const { slug, id: proposalId } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let space, membership;
try {
const result = await requireSpaceMembership(session.user.id, slug);
space = result.space;
membership = result.membership;
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: e.status || 403 });
}
try {
const { weight } = await req.json();
if (typeof weight !== "number" || weight === 0) {
return NextResponse.json({ error: "Weight must be a non-zero number" }, { status: 400 });
}
// Check proposal exists, belongs to space, and is ranking
const proposal = await prisma.proposal.findUnique({
where: { id: proposalId },
select: { status: true, spaceId: true },
});
if (!proposal || proposal.spaceId !== space.id) {
return NextResponse.json({ error: "Proposal not found" }, { status: 404 });
}
if (proposal.status !== "RANKING") {
return NextResponse.json({ error: "This proposal is no longer accepting ranking votes" }, { status: 400 });
}
// Calculate credits from SpaceMember (using space config)
const availableCredits = calculateAvailableCredits(
membership.credits,
membership.lastCreditAt,
space.creditsPerDay,
space.maxCredits
);
const creditCost = calculateVoteCost(weight);
// Check for existing vote
const existingVote = await prisma.vote.findUnique({
where: { userId_proposalId: { userId: session.user.id, proposalId } },
});
const returnedCredits = existingVote ? existingVote.creditCost : 0;
const netCost = creditCost - returnedCredits;
if (netCost > availableCredits) {
return NextResponse.json(
{ error: `Not enough credits. Need ${netCost}, have ${availableCredits}` },
{ status: 400 }
);
}
// Perform the vote in a transaction
const result = await prisma.$transaction(async (tx) => {
if (existingVote) {
await tx.vote.update({
where: { id: existingVote.id },
data: { weight, creditCost, createdAt: new Date(), decaysAt: addDays(new Date(), DECAY_START_DAYS) },
});
} else {
await tx.vote.create({
data: { userId: session.user.id, proposalId, weight, creditCost, decaysAt: addDays(new Date(), DECAY_START_DAYS) },
});
}
// Update SpaceMember credits
const newCredits = availableCredits - netCost;
await tx.spaceMember.update({
where: { id: membership.id },
data: { credits: newCredits, lastCreditAt: new Date() },
});
// Calculate new proposal score
const allVotes = await tx.vote.findMany({ where: { proposalId } });
const newScore = allVotes.reduce((sum, v) => sum + getEffectiveWeight(v.weight, v.createdAt), 0);
const updateData: { score: number; status?: "VOTING"; votingEndsAt?: Date } = { score: newScore };
if (shouldPromote(newScore, space.promotionThreshold)) {
updateData.status = "VOTING";
updateData.votingEndsAt = getVotingEndDate(new Date(), space.votingPeriodDays);
}
await tx.proposal.update({ where: { id: proposalId }, data: updateData });
return { newScore, promoted: shouldPromote(newScore, space.promotionThreshold) };
});
return NextResponse.json({ success: true, newScore: result.newScore, promoted: result.promoted, creditCost });
} catch (error) {
console.error("Vote error:", error);
return NextResponse.json({ error: "Failed to cast vote" }, { status: 500 });
}
}
// DELETE — Remove a vote (space-scoped)
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ slug: string; id: string }> }
) {
const session = await auth();
const { slug, id: proposalId } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let space, membership;
try {
const result = await requireSpaceMembership(session.user.id, slug);
space = result.space;
membership = result.membership;
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: e.status || 403 });
}
const proposal = await prisma.proposal.findUnique({
where: { id: proposalId },
select: { status: true, spaceId: true },
});
if (!proposal || proposal.spaceId !== space.id || proposal.status !== "RANKING") {
return NextResponse.json({ error: "Cannot remove vote from this proposal" }, { status: 400 });
}
const existingVote = await prisma.vote.findUnique({
where: { userId_proposalId: { userId: session.user.id, proposalId } },
});
if (!existingVote) {
return NextResponse.json({ error: "No vote to remove" }, { status: 404 });
}
const result = await prisma.$transaction(async (tx) => {
await tx.vote.delete({ where: { id: existingVote.id } });
const currentCredits = calculateAvailableCredits(
membership.credits, membership.lastCreditAt, space.creditsPerDay, space.maxCredits
);
await tx.spaceMember.update({
where: { id: membership.id },
data: { credits: currentCredits + existingVote.creditCost, lastCreditAt: new Date() },
});
const allVotes = await tx.vote.findMany({ where: { proposalId } });
const newScore = allVotes.reduce((sum, v) => sum + getEffectiveWeight(v.weight, v.createdAt), 0);
await tx.proposal.update({ where: { id: proposalId }, data: { score: newScore } });
return { newScore, returnedCredits: existingVote.creditCost };
});
return NextResponse.json({ success: true, newScore: result.newScore, returnedCredits: result.returnedCredits });
}

View File

@ -0,0 +1,100 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { requireSpaceMembership } from "@/lib/spaces";
import { NextRequest, NextResponse } from "next/server";
import { ProposalStatus } from "@prisma/client";
// GET — List proposals in this space
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const { searchParams } = new URL(req.url);
const status = searchParams.get("status") as ProposalStatus | null;
const sortBy = searchParams.get("sortBy") || "createdAt";
const sortOrder = searchParams.get("sortOrder") || "desc";
const page = parseInt(searchParams.get("page") || "1");
const limit = parseInt(searchParams.get("limit") || "20");
const space = await prisma.space.findUnique({ where: { slug } });
if (!space) {
return NextResponse.json({ error: "Space not found" }, { status: 404 });
}
const where: { spaceId: string; status?: ProposalStatus } = { spaceId: space.id };
if (status) where.status = status;
const orderBy: Record<string, string> = {};
if (["score", "createdAt", "votingEndsAt"].includes(sortBy)) {
orderBy[sortBy] = sortOrder;
} else {
orderBy.createdAt = "desc";
}
const [proposals, total] = await Promise.all([
prisma.proposal.findMany({
where,
orderBy,
skip: (page - 1) * limit,
take: limit,
include: {
author: { select: { id: true, name: true, email: true } },
_count: { select: { votes: true, finalVotes: true } },
},
}),
prisma.proposal.count({ where }),
]);
return NextResponse.json({
proposals,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
});
}
// POST — Create a proposal in this space
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ slug: string }> }
) {
const session = await auth();
const { slug } = await params;
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let space;
try {
const result = await requireSpaceMembership(session.user.id, slug);
space = result.space;
} catch (e: any) {
return NextResponse.json({ error: e.message }, { status: e.status || 403 });
}
const { title, description } = await req.json();
if (!title || !description) {
return NextResponse.json({ error: "Title and description are required" }, { status: 400 });
}
if (title.length > 200) {
return NextResponse.json({ error: "Title must be 200 characters or less" }, { status: 400 });
}
if (description.length > 10000) {
return NextResponse.json({ error: "Description must be 10,000 characters or less" }, { status: 400 });
}
const proposal = await prisma.proposal.create({
data: {
title,
description,
authorId: session.user.id,
spaceId: space.id,
},
include: {
author: { select: { id: true, name: true, email: true } },
},
});
return NextResponse.json({ proposal }, { status: 201 });
}

View File

@ -0,0 +1,105 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useSearchParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { toast } from "sonner";
import { Users, LogIn } from "lucide-react";
import Link from "next/link";
export default function JoinSpacePage() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const slug = params.slug as string;
const token = searchParams.get("token");
const [inviteInfo, setInviteInfo] = useState<{ spaceName: string; uses: number; maxUses?: number } | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (token) {
fetch(`/api/spaces/join/${token}`)
.then((res) => res.json())
.then((data) => {
if (data.error) {
setError(data.error);
} else {
setInviteInfo(data);
}
})
.catch(() => setError("Failed to load invite"));
} else {
setError("No invite token provided");
}
}, [token]);
async function acceptInvite() {
if (!token) return;
setLoading(true);
try {
const res = await fetch(`/api/spaces/join/${token}`, { method: "POST" });
const data = await res.json();
if (res.ok) {
toast.success("Welcome to the space!");
router.push("/");
} else if (res.status === 401) {
// Not logged in - redirect to signin with invite token preserved
const rootDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN || "rvote.online";
const protocol = window.location.protocol;
window.location.href = `${protocol}//${rootDomain}/auth/signin?callbackUrl=${encodeURIComponent(window.location.href)}`;
} else {
toast.error(data.error || "Failed to join space");
}
} finally {
setLoading(false);
}
}
if (error) {
return (
<div className="max-w-md mx-auto mt-16">
<Card>
<CardContent className="py-8 text-center space-y-4">
<p className="text-lg text-muted-foreground">{error}</p>
<Button asChild variant="outline">
<Link href="/">Go Home</Link>
</Button>
</CardContent>
</Card>
</div>
);
}
if (!inviteInfo) {
return (
<div className="max-w-md mx-auto mt-16 text-center text-muted-foreground">
Loading invite...
</div>
);
}
return (
<div className="max-w-md mx-auto mt-16">
<Card>
<CardHeader className="text-center">
<div className="mx-auto h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center mb-4">
<Users className="h-8 w-8 text-primary" />
</div>
<CardTitle className="text-2xl">Join {inviteInfo.spaceName}</CardTitle>
<CardDescription>
You&apos;ve been invited to join this space.
</CardDescription>
</CardHeader>
<CardContent className="text-center space-y-4">
<Button onClick={acceptInvite} disabled={loading} size="lg" className="w-full">
<LogIn className="h-4 w-4 mr-2" />
{loading ? "Joining..." : "Accept Invite"}
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,67 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { SpaceProvider } from "@/components/SpaceProvider";
import { SpaceNav } from "@/components/SpaceNav";
import { calculateAvailableCredits } from "@/lib/credits";
export default async function SpaceLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const session = await auth();
const space = await prisma.space.findUnique({ where: { slug } });
if (!space) notFound();
let membership = null;
if (session?.user?.id) {
const member = await prisma.spaceMember.findUnique({
where: { userId_spaceId: { userId: session.user.id, spaceId: space.id } },
});
if (member) {
const credits = calculateAvailableCredits(
member.credits,
member.lastCreditAt,
space.creditsPerDay,
space.maxCredits
);
membership = { id: member.id, role: member.role, credits };
}
}
return (
<SpaceProvider
space={{
id: space.id,
name: space.name,
slug: space.slug,
description: space.description,
isPublic: space.isPublic,
promotionThreshold: space.promotionThreshold,
votingPeriodDays: space.votingPeriodDays,
creditsPerDay: space.creditsPerDay,
maxCredits: space.maxCredits,
startingCredits: space.startingCredits,
}}
membership={membership}
>
<div className="border-b bg-card">
<div className="container mx-auto px-4 py-3">
<h1 className="text-xl font-bold">{space.name}</h1>
{space.description && (
<p className="text-sm text-muted-foreground">{space.description}</p>
)}
</div>
</div>
<SpaceNav />
<div className="container mx-auto px-4 py-6">
{children}
</div>
</SpaceProvider>
);
}

View File

@ -0,0 +1,59 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { notFound, redirect } from "next/navigation";
import { MemberList } from "@/components/MemberList";
import { InviteDialog } from "@/components/InviteDialog";
import { Badge } from "@/components/ui/badge";
export default async function SpaceMembersPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const session = await auth();
if (!session?.user?.id) redirect("/auth/signin");
const space = await prisma.space.findUnique({ where: { slug } });
if (!space) notFound();
const membership = await prisma.spaceMember.findUnique({
where: { userId_spaceId: { userId: session.user.id, spaceId: space.id } },
});
if (!membership) redirect("/");
const members = await prisma.spaceMember.findMany({
where: { spaceId: space.id },
include: { user: { select: { id: true, name: true, email: true } } },
orderBy: [{ role: "asc" }, { joinedAt: "asc" }],
});
const serializedMembers = members.map((m) => ({
id: m.id,
role: m.role,
credits: m.credits,
joinedAt: m.joinedAt.toISOString(),
user: m.user,
}));
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">Members</h2>
<p className="text-muted-foreground">
{members.length} member{members.length !== 1 ? "s" : ""}
</p>
</div>
{membership.role === "ADMIN" && <InviteDialog spaceSlug={slug} />}
</div>
<MemberList
members={serializedMembers}
spaceSlug={slug}
isAdmin={membership.role === "ADMIN"}
currentUserId={session.user.id}
/>
</div>
);
}

110
src/app/s/[slug]/page.tsx Normal file
View File

@ -0,0 +1,110 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
import { FileText, Users, Vote, CheckCircle, ArrowRight, Plus } from "lucide-react";
export default async function SpaceDashboard({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const space = await prisma.space.findUnique({ where: { slug } });
if (!space) notFound();
const [rankingCount, votingCount, passedCount, memberCount, topProposals] = await Promise.all([
prisma.proposal.count({ where: { spaceId: space.id, status: "RANKING" } }),
prisma.proposal.count({ where: { spaceId: space.id, status: "VOTING" } }),
prisma.proposal.count({ where: { spaceId: space.id, status: "PASSED" } }),
prisma.spaceMember.count({ where: { spaceId: space.id } }),
prisma.proposal.findMany({
where: { spaceId: space.id, status: "RANKING" },
orderBy: { score: "desc" },
take: 5,
select: { id: true, title: true, score: true },
}),
]);
return (
<div className="space-y-8">
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card className="bg-gradient-to-br from-orange-500/10 to-transparent border-orange-500/20">
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold text-orange-600">{rankingCount}</div>
<p className="text-sm text-muted-foreground">Being Ranked</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-blue-500/10 to-transparent border-blue-500/20">
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold text-blue-600">{votingCount}</div>
<p className="text-sm text-muted-foreground">In Voting</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-green-500/10 to-transparent border-green-500/20">
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold text-green-600">{passedCount}</div>
<p className="text-sm text-muted-foreground">Passed</p>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-purple-500/10 to-transparent border-purple-500/20">
<CardContent className="pt-6 text-center">
<div className="text-3xl font-bold text-purple-600">{memberCount}</div>
<p className="text-sm text-muted-foreground">Members</p>
</CardContent>
</Card>
</div>
{/* Top proposals */}
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold">Top Proposals</h2>
<div className="flex gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/proposals">
View All <ArrowRight className="ml-1 h-4 w-4" />
</Link>
</Button>
<Button asChild size="sm">
<Link href="/proposals/new">
<Plus className="h-4 w-4 mr-1" />
New Proposal
</Link>
</Button>
</div>
</div>
{topProposals.length === 0 ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
No proposals yet. Be the first to create one!
</CardContent>
</Card>
) : (
<div className="space-y-2">
{topProposals.map((proposal, i) => (
<Link key={proposal.id} href={`/proposals/${proposal.id}`}>
<Card className="hover:border-primary/30 transition-colors cursor-pointer">
<CardContent className="py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-muted-foreground text-sm font-mono w-6">#{i + 1}</span>
<span className="font-medium">{proposal.title}</span>
</div>
<Badge variant="outline" className="text-orange-600 border-orange-500/30">
Score: {proposal.score}
</Badge>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,160 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { VoteButtons } from "@/components/VoteButtons";
import { FinalVotePanel } from "@/components/FinalVotePanel";
import { calculateAvailableCredits } from "@/lib/credits";
import { getEffectiveWeight } from "@/lib/voting";
import { formatDistanceToNow } from "date-fns";
export default async function SpaceProposalDetailPage({
params,
}: {
params: Promise<{ slug: string; id: string }>;
}) {
const { slug, id } = await params;
const session = await auth();
const space = await prisma.space.findUnique({ where: { slug } });
if (!space) notFound();
const proposal = await prisma.proposal.findUnique({
where: { id },
include: {
author: { select: { id: true, name: true, email: true } },
votes: { include: { user: { select: { id: true, name: true } } }, orderBy: { weight: "desc" } },
finalVotes: true,
},
});
if (!proposal || proposal.spaceId !== space.id) notFound();
let availableCredits = 0;
let userVote = undefined;
let isAuthenticated = false;
let userFinalVote = undefined;
if (session?.user?.id) {
isAuthenticated = true;
const member = await prisma.spaceMember.findUnique({
where: { userId_spaceId: { userId: session.user.id, spaceId: space.id } },
});
if (member) {
availableCredits = calculateAvailableCredits(
member.credits, member.lastCreditAt, space.creditsPerDay, space.maxCredits
);
}
const vote = await prisma.vote.findUnique({
where: { userId_proposalId: { userId: session.user.id, proposalId: id } },
});
if (vote) {
userVote = { weight: vote.weight, effectiveWeight: getEffectiveWeight(vote.weight, vote.createdAt) };
}
const fv = proposal.finalVotes.find((v) => v.userId === session.user!.id);
if (fv) userFinalVote = fv.vote;
}
const effectiveScore = proposal.votes.reduce(
(sum, v) => sum + getEffectiveWeight(v.weight, v.createdAt),
0
);
// Compute final vote tallies
const finalVoteCounts = { yes: 0, no: 0, abstain: 0, total: 0 };
proposal.finalVotes.forEach((fv) => {
const key = fv.vote.toLowerCase() as "yes" | "no" | "abstain";
finalVoteCounts[key]++;
finalVoteCounts.total++;
});
const statusColors: Record<string, string> = {
RANKING: "bg-orange-500",
VOTING: "bg-blue-500",
PASSED: "bg-green-500",
FAILED: "bg-red-500",
};
return (
<div className="max-w-3xl mx-auto space-y-6">
<div className="flex items-start gap-4">
{proposal.status === "RANKING" && (
<div className="pt-1">
<VoteButtons
proposalId={proposal.id}
currentScore={effectiveScore}
userVote={userVote}
availableCredits={availableCredits}
isAuthenticated={isAuthenticated}
spaceSlug={slug}
/>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<Badge className={statusColors[proposal.status]}>{proposal.status}</Badge>
{proposal.votingEndsAt && (
<span className="text-sm text-muted-foreground">
Voting ends {formatDistanceToNow(new Date(proposal.votingEndsAt), { addSuffix: true })}
</span>
)}
</div>
<h1 className="text-2xl font-bold">{proposal.title}</h1>
<p className="text-sm text-muted-foreground mt-1">
by {proposal.author.name || proposal.author.email} &middot;{" "}
{formatDistanceToNow(new Date(proposal.createdAt), { addSuffix: true })}
</p>
</div>
</div>
<Card>
<CardContent className="py-6 prose prose-sm max-w-none">
<p className="whitespace-pre-wrap">{proposal.description}</p>
</CardContent>
</Card>
{proposal.status === "VOTING" && (
<FinalVotePanel
proposalId={proposal.id}
votingEndsAt={proposal.votingEndsAt ?? undefined}
votes={finalVoteCounts}
userVote={userFinalVote}
isAuthenticated={isAuthenticated}
spaceSlug={slug}
/>
)}
{/* Voters */}
{proposal.votes.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Votes ({proposal.votes.length})</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{proposal.votes.map((vote) => {
const ew = getEffectiveWeight(vote.weight, vote.createdAt);
return (
<div key={vote.id} className="flex items-center justify-between text-sm">
<span>{vote.user.name || "Anonymous"}</span>
<div className="flex items-center gap-2">
<span className={vote.weight > 0 ? "text-orange-600" : "text-blue-600"}>
{vote.weight > 0 ? "+" : ""}{vote.weight}
</span>
{Math.abs(ew) < Math.abs(vote.weight) && (
<span className="text-muted-foreground text-xs">
(effective: {ew.toFixed(1)})
</span>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@ -0,0 +1,87 @@
"use client";
import { useState } from "react";
import { useRouter, useParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
export default function NewProposalPage() {
const router = useRouter();
const params = useParams();
const slug = params.slug as string;
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim() || !description.trim()) {
toast.error("Title and description are required");
return;
}
setLoading(true);
try {
const res = await fetch(`/api/proposals`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: title.trim(), description: description.trim() }),
});
if (res.ok) {
toast.success("Proposal created!");
router.push("/proposals");
} else {
const data = await res.json();
toast.error(data.error || "Failed to create proposal");
}
} finally {
setLoading(false);
}
}
return (
<div className="max-w-2xl mx-auto">
<Card>
<CardHeader>
<CardTitle>New Proposal</CardTitle>
<CardDescription>
Submit a proposal for the community to rank. If it reaches the threshold, it advances to pass/fail voting.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="title">Title</Label>
<Input
id="title"
placeholder="A clear, concise proposal title"
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={200}
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Describe your proposal in detail. What problem does it solve? What should be done?"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={6}
maxLength={10000}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Submitting..." : "Submit Proposal"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,128 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { notFound } from "next/navigation";
import { ProposalList } from "@/components/ProposalList";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { calculateAvailableCredits } from "@/lib/credits";
import { getEffectiveWeight } from "@/lib/voting";
import Link from "next/link";
import { Plus } from "lucide-react";
export default async function SpaceProposalsPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const session = await auth();
const space = await prisma.space.findUnique({ where: { slug } });
if (!space) notFound();
const [rankingProposals, votingProposals, completedProposals] = await Promise.all([
prisma.proposal.findMany({
where: { spaceId: space.id, status: "RANKING" },
orderBy: { score: "desc" },
include: { author: { select: { id: true, name: true, email: true } }, votes: true },
}),
prisma.proposal.findMany({
where: { spaceId: space.id, status: "VOTING" },
orderBy: { votingEndsAt: "asc" },
include: { author: { select: { id: true, name: true, email: true } }, votes: true },
}),
prisma.proposal.findMany({
where: { spaceId: space.id, status: { in: ["PASSED", "FAILED"] } },
orderBy: { updatedAt: "desc" },
include: { author: { select: { id: true, name: true, email: true } }, votes: true },
}),
]);
const allProposals = [...rankingProposals, ...votingProposals, ...completedProposals];
let availableCredits = 0;
let userVotes: { proposalId: string; weight: number; effectiveWeight: number }[] = [];
let isAuthenticated = false;
if (session?.user?.id) {
isAuthenticated = true;
const member = await prisma.spaceMember.findUnique({
where: { userId_spaceId: { userId: session.user.id, spaceId: space.id } },
});
if (member) {
availableCredits = calculateAvailableCredits(
member.credits, member.lastCreditAt, space.creditsPerDay, space.maxCredits
);
}
const votes = await prisma.vote.findMany({
where: {
userId: session.user.id,
proposalId: { in: allProposals.map((p) => p.id) },
},
});
userVotes = votes.map((v) => ({
proposalId: v.proposalId,
weight: v.weight,
effectiveWeight: getEffectiveWeight(v.weight, v.createdAt),
}));
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Proposals</h2>
<Button asChild>
<Link href="/proposals/new">
<Plus className="h-4 w-4 mr-2" />
New Proposal
</Link>
</Button>
</div>
<Tabs defaultValue="ranking">
<TabsList>
<TabsTrigger value="ranking">
Ranking <Badge variant="secondary" className="ml-2 text-xs">{rankingProposals.length}</Badge>
</TabsTrigger>
<TabsTrigger value="voting">
Voting <Badge variant="secondary" className="ml-2 text-xs">{votingProposals.length}</Badge>
</TabsTrigger>
<TabsTrigger value="completed">
Completed <Badge variant="secondary" className="ml-2 text-xs">{completedProposals.length}</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="ranking">
<ProposalList
proposals={rankingProposals}
userVotes={userVotes}
availableCredits={availableCredits}
isAuthenticated={isAuthenticated}
spaceSlug={slug}
emptyMessage="No proposals being ranked yet."
/>
</TabsContent>
<TabsContent value="voting">
<ProposalList
proposals={votingProposals}
userVotes={userVotes}
availableCredits={availableCredits}
isAuthenticated={isAuthenticated}
spaceSlug={slug}
emptyMessage="No proposals in voting stage."
/>
</TabsContent>
<TabsContent value="completed">
<ProposalList
proposals={completedProposals}
userVotes={userVotes}
availableCredits={availableCredits}
isAuthenticated={isAuthenticated}
spaceSlug={slug}
emptyMessage="No completed proposals yet."
/>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,133 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { useSpace } from "@/components/SpaceProvider";
import { Save } from "lucide-react";
export default function SpaceSettingsPage() {
const { space, membership } = useSpace();
const router = useRouter();
const [name, setName] = useState(space.name);
const [description, setDescription] = useState(space.description || "");
const [promotionThreshold, setPromotionThreshold] = useState(space.promotionThreshold.toString());
const [votingPeriodDays, setVotingPeriodDays] = useState(space.votingPeriodDays.toString());
const [creditsPerDay, setCreditsPerDay] = useState(space.creditsPerDay.toString());
const [maxCredits, setMaxCredits] = useState(space.maxCredits.toString());
const [startingCredits, setStartingCredits] = useState(space.startingCredits.toString());
const [loading, setLoading] = useState(false);
if (membership?.role !== "ADMIN") {
return (
<div className="text-center py-16 text-muted-foreground">
Only admins can access space settings.
</div>
);
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
try {
const res = await fetch(`/api/spaces/${space.slug}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: name.trim(),
description: description.trim() || null,
promotionThreshold: parseInt(promotionThreshold),
votingPeriodDays: parseInt(votingPeriodDays),
creditsPerDay: parseInt(creditsPerDay),
maxCredits: parseInt(maxCredits),
startingCredits: parseInt(startingCredits),
}),
});
if (res.ok) {
toast.success("Settings saved");
router.refresh();
} else {
const data = await res.json();
toast.error(data.error || "Failed to save settings");
}
} finally {
setLoading(false);
}
}
return (
<div className="max-w-2xl mx-auto space-y-6">
<h2 className="text-2xl font-bold">Space Settings</h2>
<form onSubmit={handleSubmit} className="space-y-6">
<Card>
<CardHeader>
<CardTitle>General</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Space Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="desc">Description</Label>
<Textarea id="desc" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} />
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Voting Configuration</CardTitle>
<CardDescription>Controls how proposals are ranked and promoted</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="threshold">Promotion Threshold</Label>
<Input id="threshold" type="number" value={promotionThreshold} onChange={(e) => setPromotionThreshold(e.target.value)} />
<p className="text-xs text-muted-foreground">Score needed to advance to voting</p>
</div>
<div className="space-y-2">
<Label htmlFor="period">Voting Period (days)</Label>
<Input id="period" type="number" value={votingPeriodDays} onChange={(e) => setVotingPeriodDays(e.target.value)} />
<p className="text-xs text-muted-foreground">Duration of pass/fail voting</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Credits</CardTitle>
<CardDescription>Controls credit allocation for members</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="cpd">Credits Per Day</Label>
<Input id="cpd" type="number" value={creditsPerDay} onChange={(e) => setCreditsPerDay(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="mc">Max Credits</Label>
<Input id="mc" type="number" value={maxCredits} onChange={(e) => setMaxCredits(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="sc">Starting Credits</Label>
<Input id="sc" type="number" value={startingCredits} onChange={(e) => setStartingCredits(e.target.value)} />
</div>
</CardContent>
</Card>
<Button type="submit" disabled={loading} className="w-full">
<Save className="h-4 w-4 mr-2" />
{loading ? "Saving..." : "Save Settings"}
</Button>
</form>
</div>
);
}

View File

@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { ArrowRight } from "lucide-react";
export default function NewSpacePage() {
const router = useRouter();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!name.trim()) {
toast.error("Space name is required");
return;
}
setLoading(true);
try {
const res = await fetch("/api/spaces", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name.trim(), description: description.trim() || undefined }),
});
if (res.ok) {
const data = await res.json();
const rootDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN || "rvote.online";
const protocol = window.location.protocol;
toast.success("Space created!");
window.location.href = `${protocol}//${data.space.slug}.${rootDomain}`;
} else {
const data = await res.json();
toast.error(data.error || "Failed to create space");
}
} finally {
setLoading(false);
}
}
return (
<div className="max-w-xl mx-auto">
<Card>
<CardHeader>
<CardTitle className="text-2xl">Create a New Space</CardTitle>
<CardDescription>
A Space is an independent community where members can propose, rank, and vote on ideas.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-2">
<Label htmlFor="name">Space Name</Label>
<Input
id="name"
placeholder="e.g. CryptoCommons"
value={name}
onChange={(e) => setName(e.target.value)}
maxLength={100}
/>
<p className="text-xs text-muted-foreground">
This will become your subdomain: <strong>{name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "your-space"}.rvote.online</strong>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description (optional)</Label>
<Textarea
id="description"
placeholder="What is this space about?"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
maxLength={500}
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Creating..." : "Create Space"}
{!loading && <ArrowRight className="ml-2 h-4 w-4" />}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

69
src/app/spaces/page.tsx Normal file
View File

@ -0,0 +1,69 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { SpaceCard } from "@/components/SpaceCard";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import Link from "next/link";
import { Plus } from "lucide-react";
export default async function SpacesPage() {
const session = await auth();
if (!session?.user?.id) redirect("/auth/signin");
const memberships = await prisma.spaceMember.findMany({
where: { userId: session.user.id },
include: {
space: {
include: {
_count: { select: { members: true, proposals: true } },
},
},
},
orderBy: { joinedAt: "desc" },
});
const spaces = memberships.map((m) => ({
...m.space,
role: m.role,
}));
return (
<div className="max-w-4xl mx-auto space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Your Spaces</h1>
<p className="text-muted-foreground mt-1">
Communities you belong to
</p>
</div>
<Button asChild>
<Link href="/spaces/new">
<Plus className="h-4 w-4 mr-2" />
Create Space
</Link>
</Button>
</div>
{spaces.length === 0 ? (
<div className="text-center py-16 space-y-4">
<p className="text-lg text-muted-foreground">
You haven&apos;t joined any spaces yet.
</p>
<Button asChild size="lg">
<Link href="/spaces/new">
<Plus className="h-4 w-4 mr-2" />
Create Your First Space
</Link>
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{spaces.map((space) => (
<SpaceCard key={space.id} space={space} />
))}
</div>
)}
</div>
);
}

View File

@ -12,14 +12,16 @@ import { toast } from "sonner";
interface FinalVotePanelProps {
proposalId: string;
votingEndsAt: Date | string;
votes: {
votingEndsAt?: Date | string;
votes?: {
yes: number;
no: number;
abstain: number;
total: number;
};
userVote?: VoteChoice;
isAuthenticated?: boolean;
spaceSlug?: string;
result?: "PASSED" | "FAILED" | null;
onVote?: (newVotes: { yes: number; no: number; abstain: number; total: number }, userVote: VoteChoice) => void;
}
@ -27,8 +29,10 @@ interface FinalVotePanelProps {
export function FinalVotePanel({
proposalId,
votingEndsAt,
votes: initialVotes,
votes: initialVotes = { yes: 0, no: 0, abstain: 0, total: 0 },
userVote: initialUserVote,
isAuthenticated = true,
spaceSlug,
result,
onVote,
}: FinalVotePanelProps) {
@ -36,8 +40,10 @@ export function FinalVotePanel({
const [userVote, setUserVote] = useState(initialUserVote);
const [isVoting, setIsVoting] = useState(false);
const endDate = typeof votingEndsAt === "string" ? new Date(votingEndsAt) : votingEndsAt;
const isEnded = endDate < new Date();
const endDate = votingEndsAt
? (typeof votingEndsAt === "string" ? new Date(votingEndsAt) : votingEndsAt)
: null;
const isEnded = endDate ? endDate < new Date() : false;
const canVote = !isEnded && !result;
const yesPercentage = votes.total > 0 ? (votes.yes / votes.total) * 100 : 50;
@ -81,7 +87,7 @@ export function FinalVotePanel({
) : (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
{isEnded ? "Voting ended" : `Ends ${formatDistanceToNow(endDate, { addSuffix: true })}`}
{!endDate ? "Open" : isEnded ? "Voting ended" : `Ends ${formatDistanceToNow(endDate, { addSuffix: true })}`}
</div>
)}
</div>

View File

@ -0,0 +1,372 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
ChevronUp,
ChevronDown,
Check,
X,
Minus,
RotateCcw,
Coins,
TrendingUp,
Clock,
} from "lucide-react";
interface DemoProposal {
id: number;
title: string;
description: string;
score: number;
userVote: number;
pendingVote: number;
stage: "ranking" | "voting";
yesVotes: number;
noVotes: number;
}
const initialProposals: DemoProposal[] = [
{
id: 1,
title: "Add dark mode toggle to the dashboard",
description: "Implement a system-aware dark/light theme switch so users can choose their preferred viewing mode",
score: 72, userVote: 0, pendingVote: 0, stage: "ranking", yesVotes: 0, noVotes: 0,
},
{
id: 2,
title: "Build mobile-responsive voting interface",
description: "Redesign the voting UI to work seamlessly on phones and tablets so members can vote on the go",
score: 58, userVote: 0, pendingVote: 0, stage: "ranking", yesVotes: 0, noVotes: 0,
},
{
id: 3,
title: "Add email notifications for promoted proposals",
description: "Send members an email when a proposal they voted on advances to the pass/fail voting stage",
score: 41, userVote: 0, pendingVote: 0, stage: "ranking", yesVotes: 0, noVotes: 0,
},
{
id: 4,
title: "Create public API for proposal data",
description: "Expose a read-only REST API so external tools and dashboards can display proposal rankings",
score: 35, userVote: 0, pendingVote: 0, stage: "ranking", yesVotes: 0, noVotes: 0,
},
{
id: 5,
title: "Add proposal tagging and filtering",
description: "Let authors tag proposals by category (feature, bug, process) and allow users to filter the list",
score: 23, userVote: 0, pendingVote: 0, stage: "ranking", yesVotes: 0, noVotes: 0,
},
];
export function InteractiveDemo() {
const [credits, setCredits] = useState(100);
const [proposals, setProposals] = useState<DemoProposal[]>(initialProposals);
const maxWeight = Math.floor(Math.sqrt(credits));
function handleUpvote(proposalId: number) {
setProposals((prev) =>
prev.map((p) => {
if (p.id !== proposalId) return p;
if (p.userVote > 0) {
const refund = p.userVote * p.userVote;
setCredits((c) => c + refund);
return { ...p, score: p.score - p.userVote, userVote: 0, pendingVote: 0 };
}
if (p.userVote < 0) return p;
const newPending = p.pendingVote + 1;
const newCost = newPending * newPending;
if (newCost <= credits && newPending <= maxWeight) {
return { ...p, pendingVote: newPending };
}
return p;
})
);
}
function handleDownvote(proposalId: number) {
setProposals((prev) =>
prev.map((p) => {
if (p.id !== proposalId) return p;
if (p.userVote < 0) {
const refund = p.userVote * p.userVote;
setCredits((c) => c + refund);
return { ...p, score: p.score - p.userVote, userVote: 0, pendingVote: 0 };
}
if (p.userVote > 0) return p;
const newPending = p.pendingVote - 1;
const newCost = newPending * newPending;
if (newCost <= credits && Math.abs(newPending) <= maxWeight) {
return { ...p, pendingVote: newPending };
}
return p;
})
);
}
function cancelPending(proposalId: number) {
setProposals((prev) =>
prev.map((p) => (p.id === proposalId ? { ...p, pendingVote: 0 } : p))
);
}
function confirmVote(proposalId: number) {
setProposals((prev) =>
prev.map((p) => {
if (p.id !== proposalId || p.pendingVote === 0) return p;
const cost = p.pendingVote * p.pendingVote;
const newScore = p.score + p.pendingVote;
const promoted = newScore >= 100 && p.stage === "ranking";
setCredits((c) => c - cost);
return {
...p, score: newScore, userVote: p.pendingVote, pendingVote: 0,
stage: promoted ? "voting" : p.stage,
yesVotes: promoted ? 8 : p.yesVotes, noVotes: promoted ? 3 : p.noVotes,
};
})
);
}
function castFinalVote(proposalId: number, vote: "yes" | "no" | "abstain") {
setProposals((prev) =>
prev.map((p) => {
if (p.id === proposalId) {
return {
...p,
yesVotes: vote === "yes" ? p.yesVotes + 1 : p.yesVotes,
noVotes: vote === "no" ? p.noVotes + 1 : p.noVotes,
};
}
return p;
})
);
}
function resetDemo() {
setCredits(100);
setProposals(initialProposals);
}
const rankingProposals = proposals.filter((p) => p.stage === "ranking").sort((a, b) => b.score - a.score);
const votingProposals = proposals.filter((p) => p.stage === "voting");
return (
<div className="space-y-6">
{/* Credits display */}
<Card className="border-2 border-orange-500/30 bg-gradient-to-r from-orange-500/10 to-amber-500/10">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Coins className="h-6 w-6 text-orange-500" />
<span className="font-bold text-2xl text-orange-600">{credits}</span>
<span className="text-muted-foreground">credits</span>
</div>
<Badge variant="outline" className="border-orange-500/30 text-orange-600">
Max vote: &plusmn;{maxWeight}
</Badge>
</div>
<Button variant="outline" size="sm" onClick={resetDemo} className="border-orange-500/30 hover:bg-orange-500/10">
<RotateCcw className="h-4 w-4 mr-2" />
Reset
</Button>
</div>
</CardContent>
</Card>
{/* Quadratic cost explainer */}
<Card className="border-muted">
<CardHeader className="pb-2">
<CardTitle className="text-lg flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-orange-500" />
Quadratic Voting Cost
</CardTitle>
<CardDescription>Each additional vote costs exponentially more credits</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-5 gap-2 text-center text-sm">
{[1, 2, 3, 4, 5].map((w) => (
<div
key={w}
className={`p-3 rounded-lg border-2 transition-all ${
w <= maxWeight
? "bg-orange-500/10 border-orange-500/40 text-orange-700"
: "bg-muted/50 border-muted text-muted-foreground"
}`}
>
<div className="font-bold text-xl">+{w}</div>
<div className="text-xs opacity-70">vote{w > 1 ? "s" : ""}</div>
<div className="font-mono text-sm mt-1 font-semibold">{w * w}&cent;</div>
</div>
))}
</div>
</CardContent>
</Card>
{/* Ranking stage */}
<section className="space-y-3">
<div className="flex items-center gap-3">
<Badge className="bg-orange-500 hover:bg-orange-600">Stage 1</Badge>
<h2 className="text-xl font-semibold">Quadratic Ranking</h2>
<span className="text-muted-foreground text-sm">Score +100 to advance &rarr;</span>
</div>
<div className="space-y-2">
{rankingProposals.map((proposal) => {
const hasPending = proposal.pendingVote !== 0;
const hasVoted = proposal.userVote !== 0;
const pendingCost = proposal.pendingVote * proposal.pendingVote;
const displayScore = hasPending ? proposal.score + proposal.pendingVote : proposal.score;
const progressPercent = Math.min((displayScore / 100) * 100, 100);
const isUpvoted = (hasVoted && proposal.userVote > 0) || proposal.pendingVote > 0;
const isDownvoted = (hasVoted && proposal.userVote < 0) || proposal.pendingVote < 0;
return (
<div
key={proposal.id}
className={`flex rounded-xl border bg-card shadow-sm overflow-hidden transition-all duration-200 ${
hasPending
? proposal.pendingVote > 0 ? "ring-2 ring-orange-500/50" : "ring-2 ring-blue-500/50"
: ""
}`}
>
<div className="flex flex-col items-center justify-center py-3 px-4 bg-muted/50 border-r min-w-[72px]">
<Button
variant="ghost" size="sm"
className={`h-10 w-10 p-0 rounded-md transition-all ${
isUpvoted ? "text-orange-500 bg-orange-500/10 hover:bg-orange-500/20"
: "text-muted-foreground hover:text-orange-500 hover:bg-orange-500/10"
} ${hasVoted && proposal.userVote < 0 ? "opacity-30 pointer-events-none" : ""}`}
onClick={() => handleUpvote(proposal.id)}
>
<ChevronUp className="h-7 w-7" strokeWidth={2.5} />
</Button>
<span className={`font-bold text-xl tabular-nums py-1 ${
isUpvoted ? "text-orange-500" : isDownvoted ? "text-blue-500" : "text-foreground"
}`}>{displayScore}</span>
<Button
variant="ghost" size="sm"
className={`h-10 w-10 p-0 rounded-md transition-all ${
isDownvoted ? "text-blue-500 bg-blue-500/10 hover:bg-blue-500/20"
: "text-muted-foreground hover:text-blue-500 hover:bg-blue-500/10"
} ${hasVoted && proposal.userVote > 0 ? "opacity-30 pointer-events-none" : ""}`}
onClick={() => handleDownvote(proposal.id)}
>
<ChevronDown className="h-7 w-7" strokeWidth={2.5} />
</Button>
</div>
<div className="flex-1 p-4 min-w-0">
<h3 className="font-semibold text-base leading-tight">{proposal.title}</h3>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{proposal.description}</p>
<div className="mt-3">
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
<span>Progress to voting stage</span>
<span className={isUpvoted ? "text-orange-500 font-medium" : isDownvoted ? "text-blue-500 font-medium" : ""}>
{displayScore}/100
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${
isUpvoted ? "bg-orange-500" : isDownvoted ? "bg-blue-500" : "bg-primary"
}`}
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>
{hasPending && (
<div className="mt-3 flex items-center gap-2">
<Badge variant="outline" className={proposal.pendingVote > 0
? "border-orange-500/50 text-orange-600 bg-orange-500/10"
: "border-blue-500/50 text-blue-600 bg-blue-500/10"
}>
{proposal.pendingVote > 0 ? "+" : ""}{proposal.pendingVote} vote = {pendingCost} credits
</Badge>
<Button size="sm" variant="ghost" className="h-7 px-2" onClick={() => cancelPending(proposal.id)}>
<X className="h-4 w-4 mr-1" />Cancel
</Button>
<Button
size="sm"
className={`h-7 ${proposal.pendingVote > 0 ? "bg-orange-500 hover:bg-orange-600" : "bg-blue-500 hover:bg-blue-600"}`}
onClick={() => confirmVote(proposal.id)}
>
<Check className="h-4 w-4 mr-1" />Confirm
</Button>
</div>
)}
{hasVoted && !hasPending && (
<div className="mt-3">
<Badge variant="secondary" className={proposal.userVote > 0
? "bg-orange-500/20 text-orange-600" : "bg-blue-500/20 text-blue-600"
}>
You voted: {proposal.userVote > 0 ? "+" : ""}{proposal.userVote}
</Badge>
</div>
)}
</div>
</div>
);
})}
</div>
</section>
{/* Voting stage */}
{votingProposals.length > 0 && (
<section className="space-y-3">
<div className="flex items-center gap-3">
<Badge variant="outline" className="border-green-500/50 text-green-600">Stage 2</Badge>
<h2 className="text-xl font-semibold">Pass/Fail Voting</h2>
<span className="text-muted-foreground text-sm">One member = one vote</span>
</div>
{votingProposals.map((proposal) => {
const total = proposal.yesVotes + proposal.noVotes;
const yesPercent = total > 0 ? (proposal.yesVotes / total) * 100 : 50;
return (
<Card key={proposal.id} className="border-green-500/30 bg-green-500/5">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{proposal.title}</CardTitle>
<div className="flex items-center gap-1 text-sm text-amber-600">
<Clock className="h-4 w-4" /><span>6 days left</span>
</div>
</div>
<CardDescription>{proposal.description}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="h-4 rounded-full overflow-hidden bg-muted flex">
<div className="h-full bg-green-500 transition-all" style={{ width: `${yesPercent}%` }} />
<div className="h-full bg-red-500 transition-all" style={{ width: `${100 - yesPercent}%` }} />
</div>
<div className="flex justify-between text-sm font-medium">
<span className="text-green-600">{proposal.yesVotes} Yes ({Math.round(yesPercent)}%)</span>
<span className="text-red-600">{proposal.noVotes} No ({Math.round(100 - yesPercent)}%)</span>
</div>
</div>
<div className="grid grid-cols-3 gap-2">
<Button variant="outline" className="flex-col h-auto py-3 border-green-500/30 hover:bg-green-500/10 hover:border-green-500"
onClick={() => castFinalVote(proposal.id, "yes")}>
<Check className="h-5 w-5 text-green-500" /><span className="text-xs mt-1">Yes</span>
</Button>
<Button variant="outline" className="flex-col h-auto py-3 border-red-500/30 hover:bg-red-500/10 hover:border-red-500"
onClick={() => castFinalVote(proposal.id, "no")}>
<X className="h-5 w-5 text-red-500" /><span className="text-xs mt-1">No</span>
</Button>
<Button variant="outline" className="flex-col h-auto py-3"
onClick={() => castFinalVote(proposal.id, "abstain")}>
<Minus className="h-5 w-5" /><span className="text-xs mt-1">Abstain</span>
</Button>
</div>
</CardContent>
</Card>
);
})}
</section>
)}
</div>
);
}

View File

@ -0,0 +1,129 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { toast } from "sonner";
import { Copy, Link, UserPlus } from "lucide-react";
interface InviteDialogProps {
spaceSlug: string;
}
export function InviteDialog({ spaceSlug }: InviteDialogProps) {
const [open, setOpen] = useState(false);
const [email, setEmail] = useState("");
const [maxUses, setMaxUses] = useState("");
const [inviteUrl, setInviteUrl] = useState("");
const [loading, setLoading] = useState(false);
async function createInvite() {
setLoading(true);
try {
const body: Record<string, unknown> = {};
if (email) body.email = email;
if (maxUses) body.maxUses = parseInt(maxUses);
const res = await fetch(`/api/spaces/${spaceSlug}/invites`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) {
const data = await res.json();
setInviteUrl(data.inviteUrl);
toast.success("Invite created");
} else {
const data = await res.json();
toast.error(data.error || "Failed to create invite");
}
} finally {
setLoading(false);
}
}
function copyUrl() {
navigator.clipboard.writeText(inviteUrl);
toast.success("Copied to clipboard");
}
function reset() {
setEmail("");
setMaxUses("");
setInviteUrl("");
setOpen(false);
}
return (
<Dialog open={open} onOpenChange={(v) => { setOpen(v); if (!v) reset(); }}>
<DialogTrigger asChild>
<Button>
<UserPlus className="h-4 w-4 mr-2" />
Create Invite
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Invite Link</DialogTitle>
<DialogDescription>
Generate a link that lets people join this space.
</DialogDescription>
</DialogHeader>
{!inviteUrl ? (
<div className="space-y-4">
<div>
<Label htmlFor="email">Restrict to email (optional)</Label>
<Input
id="email"
type="email"
placeholder="user@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div>
<Label htmlFor="maxUses">Max uses (optional)</Label>
<Input
id="maxUses"
type="number"
placeholder="Unlimited"
value={maxUses}
onChange={(e) => setMaxUses(e.target.value)}
/>
</div>
<DialogFooter>
<Button onClick={createInvite} disabled={loading}>
{loading ? "Creating..." : "Create Invite"}
</Button>
</DialogFooter>
</div>
) : (
<div className="space-y-4">
<div className="flex items-center gap-2 p-3 rounded-lg bg-muted">
<Link className="h-4 w-4 shrink-0 text-muted-foreground" />
<code className="text-sm break-all flex-1">{inviteUrl}</code>
<Button size="sm" variant="ghost" onClick={copyUrl}>
<Copy className="h-4 w-4" />
</Button>
</div>
<DialogFooter>
<Button variant="outline" onClick={reset}>Done</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,158 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { toast } from "sonner";
import { Crown, UserMinus, Coins, Shield } from "lucide-react";
interface Member {
id: string;
role: "ADMIN" | "MEMBER";
credits: number;
joinedAt: string;
user: {
id: string;
name: string | null;
email: string;
};
}
interface MemberListProps {
members: Member[];
spaceSlug: string;
isAdmin: boolean;
currentUserId: string;
}
export function MemberList({ members: initialMembers, spaceSlug, isAdmin, currentUserId }: MemberListProps) {
const [members, setMembers] = useState(initialMembers);
const [creditAmount, setCreditAmount] = useState<Record<string, string>>({});
async function toggleRole(userId: string, currentRole: string) {
const newRole = currentRole === "ADMIN" ? "MEMBER" : "ADMIN";
const res = await fetch(`/api/spaces/${spaceSlug}/members/${userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ role: newRole }),
});
if (res.ok) {
setMembers((prev) =>
prev.map((m) => (m.user.id === userId ? { ...m, role: newRole as "ADMIN" | "MEMBER" } : m))
);
toast.success(`Role updated to ${newRole}`);
} else {
const data = await res.json();
toast.error(data.error || "Failed to update role");
}
}
async function removeMember(userId: string) {
const res = await fetch(`/api/spaces/${spaceSlug}/members/${userId}`, {
method: "DELETE",
});
if (res.ok) {
setMembers((prev) => prev.filter((m) => m.user.id !== userId));
toast.success("Member removed");
} else {
const data = await res.json();
toast.error(data.error || "Failed to remove member");
}
}
async function allotCredits(userId: string) {
const amount = parseInt(creditAmount[userId] || "0");
if (!amount || amount <= 0) {
toast.error("Enter a positive number of credits");
return;
}
const res = await fetch(`/api/spaces/${spaceSlug}/members/credits`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId, amount }),
});
if (res.ok) {
const data = await res.json();
setMembers((prev) =>
prev.map((m) => (m.user.id === userId ? { ...m, credits: data.newCredits } : m))
);
setCreditAmount((prev) => ({ ...prev, [userId]: "" }));
toast.success(`Allotted ${amount} credits`);
} else {
const data = await res.json();
toast.error(data.error || "Failed to allot credits");
}
}
return (
<div className="space-y-3">
{members.map((member) => (
<Card key={member.id}>
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10">
<AvatarFallback>
{(member.user.name || member.user.email)[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div>
<div className="flex items-center gap-2">
<span className="font-medium">{member.user.name || member.user.email}</span>
{member.role === "ADMIN" && (
<Badge variant="secondary" className="text-xs">
<Crown className="h-3 w-3 mr-1" />
Admin
</Badge>
)}
</div>
<div className="text-sm text-muted-foreground flex items-center gap-3">
<span>{member.user.email}</span>
<span className="flex items-center gap-1">
<Coins className="h-3 w-3 text-orange-500" />
{member.credits} credits
</span>
</div>
</div>
</div>
{isAdmin && member.user.id !== currentUserId && (
<div className="flex items-center gap-2">
<div className="flex items-center gap-1">
<Input
type="number"
placeholder="Credits"
className="w-20 h-8 text-sm"
value={creditAmount[member.user.id] || ""}
onChange={(e) =>
setCreditAmount((prev) => ({ ...prev, [member.user.id]: e.target.value }))
}
/>
<Button size="sm" variant="outline" className="h-8" onClick={() => allotCredits(member.user.id)}>
<Coins className="h-3 w-3 mr-1" />
Allot
</Button>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => toggleRole(member.user.id, member.role)}
title={member.role === "ADMIN" ? "Demote to member" : "Promote to admin"}
>
<Shield className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" className="text-destructive" onClick={() => removeMember(member.user.id)}>
<UserMinus className="h-4 w-4" />
</Button>
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
);
}

View File

@ -32,10 +32,10 @@ export function Navbar() {
Proposals
</Link>
<Link
href="/voting"
href="/spaces"
className="text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
Voting
Spaces
</Link>
</div>
</div>

View File

@ -29,6 +29,8 @@ interface ProposalCardProps {
effectiveWeight: number;
};
availableCredits: number;
isAuthenticated?: boolean;
spaceSlug?: string;
showVoting?: boolean;
}
@ -52,6 +54,8 @@ export function ProposalCard({
proposal,
userVote,
availableCredits,
isAuthenticated = false,
spaceSlug,
showVoting = true,
}: ProposalCardProps) {
const [score, setScore] = useState(proposal.score);
@ -96,6 +100,7 @@ export function ProposalCard({
currentScore={score}
userVote={currentVote}
availableCredits={availableCredits}
isAuthenticated={isAuthenticated}
onVote={handleVote}
/>
</div>

View File

@ -28,6 +28,8 @@ interface ProposalListProps {
proposals: Proposal[];
userVotes?: UserVote[];
availableCredits: number;
isAuthenticated?: boolean;
spaceSlug?: string;
emptyMessage?: string;
}
@ -35,6 +37,8 @@ export function ProposalList({
proposals,
userVotes = [],
availableCredits,
isAuthenticated = false,
spaceSlug,
emptyMessage = "No proposals found.",
}: ProposalListProps) {
if (proposals.length === 0) {
@ -55,6 +59,8 @@ export function ProposalList({
proposal={proposal}
userVote={voteMap.get(proposal.id)}
availableCredits={availableCredits}
isAuthenticated={isAuthenticated}
spaceSlug={spaceSlug}
/>
))}
</div>

View File

@ -0,0 +1,59 @@
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Users, FileText } from "lucide-react";
import Link from "next/link";
interface SpaceCardProps {
space: {
name: string;
slug: string;
description: string | null;
isPublic: boolean;
_count: {
members: number;
proposals: number;
};
role?: string;
};
}
export function SpaceCard({ space }: SpaceCardProps) {
const rootDomain = process.env.NEXT_PUBLIC_ROOT_DOMAIN || "rvote.online";
const protocol = process.env.NODE_ENV === "production" ? "https" : "http";
const spaceUrl = `${protocol}://${space.slug}.${rootDomain}`;
return (
<Link href={spaceUrl}>
<Card className="hover:border-primary/40 transition-colors cursor-pointer">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{space.name}</CardTitle>
<div className="flex gap-2">
{space.role === "ADMIN" && (
<Badge variant="secondary" className="text-xs">Admin</Badge>
)}
{space.isPublic && (
<Badge variant="outline" className="text-xs">Public</Badge>
)}
</div>
</div>
{space.description && (
<CardDescription className="line-clamp-2">{space.description}</CardDescription>
)}
</CardHeader>
<CardContent>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Users className="h-4 w-4" />
<span>{space._count.members} member{space._count.members !== 1 ? "s" : ""}</span>
</div>
<div className="flex items-center gap-1">
<FileText className="h-4 w-4" />
<span>{space._count.proposals} proposal{space._count.proposals !== 1 ? "s" : ""}</span>
</div>
</div>
</CardContent>
</Card>
</Link>
);
}

View File

@ -0,0 +1,59 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { useSpace } from "@/components/SpaceProvider";
import { FileText, Users, Settings, LayoutDashboard, Coins } from "lucide-react";
export function SpaceNav() {
const pathname = usePathname();
const { space, membership } = useSpace();
const links = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
{ href: "/proposals", label: "Proposals", icon: FileText },
{ href: "/members", label: "Members", icon: Users },
...(membership?.role === "ADMIN"
? [{ href: "/settings", label: "Settings", icon: Settings }]
: []),
];
// Strip the /s/[slug] prefix to match against subdomain-style paths
const cleanPath = pathname.replace(/^\/s\/[^/]+/, "") || "/";
return (
<div className="border-b bg-card/50">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-12">
<div className="flex items-center gap-1">
{links.map((link) => {
const isActive = cleanPath === link.href || (link.href !== "/" && cleanPath.startsWith(link.href));
return (
<Link
key={link.href}
href={link.href}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm transition-colors ${
isActive
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
<link.icon className="h-4 w-4" />
{link.label}
</Link>
);
})}
</div>
{membership && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Coins className="h-4 w-4 text-orange-500" />
<span className="font-medium text-orange-600">{membership.credits}</span>
<span>credits</span>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,43 @@
"use client";
import { createContext, useContext, ReactNode } from "react";
interface SpaceContextValue {
space: {
id: string;
name: string;
slug: string;
description: string | null;
isPublic: boolean;
promotionThreshold: number;
votingPeriodDays: number;
creditsPerDay: number;
maxCredits: number;
startingCredits: number;
};
membership: {
id: string;
role: "ADMIN" | "MEMBER";
credits: number;
} | null;
}
const SpaceContext = createContext<SpaceContextValue | null>(null);
export function SpaceProvider({
space,
membership,
children,
}: SpaceContextValue & { children: ReactNode }) {
return (
<SpaceContext.Provider value={{ space, membership }}>
{children}
</SpaceContext.Provider>
);
}
export function useSpace() {
const ctx = useContext(SpaceContext);
if (!ctx) throw new Error("useSpace must be used within SpaceProvider");
return ctx;
}

View File

@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ChevronUp, ChevronDown, Loader2, Check, X } from "lucide-react";
@ -15,6 +16,8 @@ interface VoteButtonsProps {
effectiveWeight: number;
};
availableCredits: number;
isAuthenticated?: boolean;
spaceSlug?: string;
onVote?: (newScore: number, newWeight: number) => void;
disabled?: boolean;
}
@ -24,9 +27,12 @@ export function VoteButtons({
currentScore,
userVote,
availableCredits,
isAuthenticated = true,
spaceSlug,
onVote,
disabled = false,
}: VoteButtonsProps) {
const router = useRouter();
const [isVoting, setIsVoting] = useState(false);
const [pendingWeight, setPendingWeight] = useState(0);
@ -85,7 +91,17 @@ export function VoteButtons({
}
}
function requireAuth(): boolean {
if (!isAuthenticated) {
toast.error("Sign in to vote on proposals");
router.push("/auth/signin");
return false;
}
return true;
}
function incrementVote() {
if (!requireAuth()) return;
const newWeight = pendingWeight + 1;
const newCost = calculateVoteCost(Math.abs(newWeight));
if (newCost <= availableCredits) {
@ -94,6 +110,7 @@ export function VoteButtons({
}
function decrementVote() {
if (!requireAuth()) return;
const newWeight = pendingWeight - 1;
const newCost = calculateVoteCost(Math.abs(newWeight));
if (newCost <= availableCredits) {

View File

@ -1,27 +1,29 @@
import { differenceInDays, differenceInHours } from "date-fns";
import { differenceInHours } from "date-fns";
// Credits earned per day
// Default credits earned per day (can be overridden per-space)
export const CREDITS_PER_DAY = 10;
// Maximum credits a user can accumulate
// Default maximum credits a user can accumulate (can be overridden per-space)
export const MAX_CREDITS = 500;
/**
* Calculate total available credits for a user
* Includes stored credits plus earned credits since last claim
* Calculate total available credits for a user.
* Accepts optional per-space config overrides.
*/
export function calculateAvailableCredits(
storedCredits: number,
lastCreditAt: Date
lastCreditAt: Date,
creditsPerDay: number = CREDITS_PER_DAY,
maxCredits: number = MAX_CREDITS
): number {
const hoursSinceLastClaim = differenceInHours(new Date(), lastCreditAt);
const earnedCredits = Math.floor((hoursSinceLastClaim / 24) * CREDITS_PER_DAY);
const earnedCredits = Math.floor((hoursSinceLastClaim / 24) * creditsPerDay);
const totalCredits = storedCredits + earnedCredits;
return Math.min(totalCredits, MAX_CREDITS);
return Math.min(totalCredits, maxCredits);
}
/**
* Calculate the quadratic cost of a vote
* Calculate the quadratic cost of a vote.
* Cost = weight^2 (1 vote = 1 credit, 2 votes = 4, 3 votes = 9, etc.)
*/
export function calculateVoteCost(weight: number): number {
@ -29,16 +31,15 @@ export function calculateVoteCost(weight: number): number {
}
/**
* Calculate the maximum vote weight a user can afford
* Calculate the maximum vote weight a user can afford.
*/
export function maxAffordableWeight(availableCredits: number): number {
return Math.floor(Math.sqrt(availableCredits));
}
/**
* Calculate credits to return when a vote fully decays
* Calculate credits to return when a vote fully decays.
*/
export function calculateDecayedCredits(creditCost: number): number {
// Return the full credit cost when vote decays
return creditCost;
}

89
src/lib/spaces.ts Normal file
View File

@ -0,0 +1,89 @@
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import type { Space, SpaceMember } from "@prisma/client";
/**
* Read the space slug from the x-space-slug header set by middleware.
*/
export async function getSpaceSlugFromHeaders(): Promise<string | null> {
const headerList = await headers();
return headerList.get("x-space-slug");
}
/**
* Resolve a space by its slug.
*/
export async function getSpaceBySlug(slug: string): Promise<Space | null> {
return prisma.space.findUnique({ where: { slug } });
}
/**
* Verify the user is a member of the space. Returns space + membership.
* Throws if not a member.
*/
export async function requireSpaceMembership(
userId: string,
spaceSlug: string
): Promise<{ space: Space; membership: SpaceMember }> {
const space = await prisma.space.findUnique({ where: { slug: spaceSlug } });
if (!space) {
throw new SpaceError("Space not found", 404);
}
const membership = await prisma.spaceMember.findUnique({
where: { userId_spaceId: { userId, spaceId: space.id } },
});
if (!membership) {
throw new SpaceError("You are not a member of this space", 403);
}
return { space, membership };
}
/**
* Verify the user is an admin of the space. Returns space + membership.
* Throws if not an admin.
*/
export async function requireSpaceAdmin(
userId: string,
spaceSlug: string
): Promise<{ space: Space; membership: SpaceMember }> {
const { space, membership } = await requireSpaceMembership(userId, spaceSlug);
if (membership.role !== "ADMIN") {
throw new SpaceError("Admin access required", 403);
}
return { space, membership };
}
/**
* Generate a URL-safe slug from a name, checking uniqueness.
*/
export async function generateUniqueSlug(name: string): Promise<string> {
let slug = name
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
if (!slug) slug = "space";
// Check uniqueness
const existing = await prisma.space.findUnique({ where: { slug } });
if (!existing) return slug;
// Append random suffix if collision
const suffix = Math.random().toString(36).substring(2, 6);
return `${slug}-${suffix}`;
}
/**
* Typed error for space operations (includes HTTP status code).
*/
export class SpaceError extends Error {
status: number;
constructor(message: string, status: number = 400) {
super(message);
this.name = "SpaceError";
this.status = status;
}
}

View File

@ -74,15 +74,15 @@ export function getFullDecayDate(createdAt: Date): Date {
/**
* Check if a proposal should be promoted to voting stage
*/
export function shouldPromote(score: number): boolean {
return score >= PROMOTION_THRESHOLD;
export function shouldPromote(score: number, threshold: number = PROMOTION_THRESHOLD): boolean {
return score >= threshold;
}
/**
* Calculate the voting end date from promotion time
*/
export function getVotingEndDate(promotedAt: Date = new Date()): Date {
return addDays(promotedAt, VOTING_PERIOD_DAYS);
export function getVotingEndDate(promotedAt: Date = new Date(), periodDays: number = VOTING_PERIOD_DAYS): Date {
return addDays(promotedAt, periodDays);
}
/**

78
src/middleware.ts Normal file
View File

@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
/**
* Subdomain routing middleware.
*
* - `rvote.online` (no subdomain) pass through to root app
* - `myspace.rvote.online` rewrite to /s/myspace/*
* - Sets x-space-slug header for server components
*/
const ROOT_DOMAIN = process.env.ROOT_DOMAIN || "rvote.online";
function getSubdomain(host: string): string | null {
// Remove port if present
const hostname = host.split(":")[0];
// localhost development: use query param ?space=slug instead
if (hostname === "localhost" || hostname === "127.0.0.1") {
return null; // Handled via search params in dev
}
// Check if hostname ends with root domain
if (!hostname.endsWith(ROOT_DOMAIN)) {
return null;
}
// Extract subdomain: "crypto.rvote.online" → "crypto"
const subdomain = hostname.slice(0, -(ROOT_DOMAIN.length + 1)); // +1 for the dot
if (!subdomain || subdomain === "www") {
return null;
}
return subdomain;
}
export function middleware(request: NextRequest) {
const host = request.headers.get("host") || "";
const { pathname, search } = request.nextUrl;
// Dev mode: support ?space=slug query param for local testing
const devSpaceSlug = request.nextUrl.searchParams.get("space");
const subdomain = getSubdomain(host) || devSpaceSlug;
// No subdomain → root domain, pass through
if (!subdomain) {
return NextResponse.next();
}
// Don't rewrite paths that are already under /s/ (avoid double rewrite)
if (pathname.startsWith("/s/")) {
return NextResponse.next();
}
// Skip Next.js internals and static files
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/favicon") ||
pathname.includes(".")
) {
return NextResponse.next();
}
// Rewrite to internal /s/[slug]/* route
const url = request.nextUrl.clone();
url.pathname = `/s/${subdomain}${pathname}`;
const response = NextResponse.rewrite(url);
response.headers.set("x-space-slug", subdomain);
return response;
}
export const config = {
matcher: [
// Match all paths except Next.js internals and static files
"/((?!_next/static|_next/image|favicon.ico).*)",
],
};