From e22063c09227604ad36c68a2baa95f9d6dbf9e52 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 10 Feb 2026 22:10:19 +0000 Subject: [PATCH] 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 --- Dockerfile | 2 + docker-compose.yml | 4 +- .../20260210220827_add_spaces/migration.sql | 219 ++++++++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 78 +++ prisma/seed-spaces.ts | 84 +++ src/app/api/spaces/[slug]/credits/route.ts | 42 ++ .../api/spaces/[slug]/invites/[id]/route.ts | 32 ++ src/app/api/spaces/[slug]/invites/route.ts | 71 +++ .../spaces/[slug]/members/[userId]/route.ts | 90 ++++ .../spaces/[slug]/members/credits/route.ts | 57 ++ src/app/api/spaces/[slug]/members/route.ts | 92 ++++ src/app/api/spaces/[slug]/route.ts | 88 ++++ src/app/api/spaces/join/[token]/route.ts | 110 ++++ src/app/api/spaces/route.ts | 86 +++ src/app/demo/page.tsx | 488 +----------------- src/app/page.tsx | 159 ++---- src/app/proposals/[id]/page.tsx | 327 +----------- src/app/proposals/new/page.tsx | 130 +---- src/app/proposals/page.tsx | 139 +---- .../api/proposals/[id]/final-vote/route.ts | 105 ++++ src/app/s/[slug]/api/proposals/[id]/route.ts | 106 ++++ .../s/[slug]/api/proposals/[id]/vote/route.ts | 173 +++++++ src/app/s/[slug]/api/proposals/route.ts | 100 ++++ src/app/s/[slug]/join/page.tsx | 105 ++++ src/app/s/[slug]/layout.tsx | 67 +++ src/app/s/[slug]/members/page.tsx | 59 +++ src/app/s/[slug]/page.tsx | 110 ++++ src/app/s/[slug]/proposals/[id]/page.tsx | 160 ++++++ src/app/s/[slug]/proposals/new/page.tsx | 87 ++++ src/app/s/[slug]/proposals/page.tsx | 128 +++++ src/app/s/[slug]/settings/page.tsx | 133 +++++ src/app/spaces/new/page.tsx | 95 ++++ src/app/spaces/page.tsx | 69 +++ src/components/FinalVotePanel.tsx | 18 +- src/components/InteractiveDemo.tsx | 372 +++++++++++++ src/components/InviteDialog.tsx | 129 +++++ src/components/MemberList.tsx | 158 ++++++ src/components/Navbar.tsx | 4 +- src/components/ProposalCard.tsx | 5 + src/components/ProposalList.tsx | 6 + src/components/SpaceCard.tsx | 59 +++ src/components/SpaceNav.tsx | 59 +++ src/components/SpaceProvider.tsx | 43 ++ src/components/VoteButtons.tsx | 17 + src/lib/credits.ts | 25 +- src/lib/spaces.ts | 89 ++++ src/lib/voting.ts | 8 +- src/middleware.ts | 78 +++ 49 files changed, 3653 insertions(+), 1215 deletions(-) create mode 100644 prisma/migrations/20260210220827_add_spaces/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/seed-spaces.ts create mode 100644 src/app/api/spaces/[slug]/credits/route.ts create mode 100644 src/app/api/spaces/[slug]/invites/[id]/route.ts create mode 100644 src/app/api/spaces/[slug]/invites/route.ts create mode 100644 src/app/api/spaces/[slug]/members/[userId]/route.ts create mode 100644 src/app/api/spaces/[slug]/members/credits/route.ts create mode 100644 src/app/api/spaces/[slug]/members/route.ts create mode 100644 src/app/api/spaces/[slug]/route.ts create mode 100644 src/app/api/spaces/join/[token]/route.ts create mode 100644 src/app/api/spaces/route.ts create mode 100644 src/app/s/[slug]/api/proposals/[id]/final-vote/route.ts create mode 100644 src/app/s/[slug]/api/proposals/[id]/route.ts create mode 100644 src/app/s/[slug]/api/proposals/[id]/vote/route.ts create mode 100644 src/app/s/[slug]/api/proposals/route.ts create mode 100644 src/app/s/[slug]/join/page.tsx create mode 100644 src/app/s/[slug]/layout.tsx create mode 100644 src/app/s/[slug]/members/page.tsx create mode 100644 src/app/s/[slug]/page.tsx create mode 100644 src/app/s/[slug]/proposals/[id]/page.tsx create mode 100644 src/app/s/[slug]/proposals/new/page.tsx create mode 100644 src/app/s/[slug]/proposals/page.tsx create mode 100644 src/app/s/[slug]/settings/page.tsx create mode 100644 src/app/spaces/new/page.tsx create mode 100644 src/app/spaces/page.tsx create mode 100644 src/components/InteractiveDemo.tsx create mode 100644 src/components/InviteDialog.tsx create mode 100644 src/components/MemberList.tsx create mode 100644 src/components/SpaceCard.tsx create mode 100644 src/components/SpaceNav.tsx create mode 100644 src/components/SpaceProvider.tsx create mode 100644 src/lib/spaces.ts create mode 100644 src/middleware.ts diff --git a/Dockerfile b/Dockerfile index 41ed2df..f376c6c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 3de2b50..0d5792e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/prisma/migrations/20260210220827_add_spaces/migration.sql b/prisma/migrations/20260210220827_add_spaces/migration.sql new file mode 100644 index 0000000..98c3d86 --- /dev/null +++ b/prisma/migrations/20260210220827_add_spaces/migration.sql @@ -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; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -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" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0d9095..d81b495 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 { diff --git a/prisma/seed-spaces.ts b/prisma/seed-spaces.ts new file mode 100644 index 0000000..6dc34f2 --- /dev/null +++ b/prisma/seed-spaces.ts @@ -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()); diff --git a/src/app/api/spaces/[slug]/credits/route.ts b/src/app/api/spaces/[slug]/credits/route.ts new file mode 100644 index 0000000..b74ec85 --- /dev/null +++ b/src/app/api/spaces/[slug]/credits/route.ts @@ -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, + }); +} diff --git a/src/app/api/spaces/[slug]/invites/[id]/route.ts b/src/app/api/spaces/[slug]/invites/[id]/route.ts new file mode 100644 index 0000000..1db1edc --- /dev/null +++ b/src/app/api/spaces/[slug]/invites/[id]/route.ts @@ -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 }); +} diff --git a/src/app/api/spaces/[slug]/invites/route.ts b/src/app/api/spaces/[slug]/invites/route.ts new file mode 100644 index 0000000..85ffbf2 --- /dev/null +++ b/src/app/api/spaces/[slug]/invites/route.ts @@ -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 }); +} diff --git a/src/app/api/spaces/[slug]/members/[userId]/route.ts b/src/app/api/spaces/[slug]/members/[userId]/route.ts new file mode 100644 index 0000000..ca03d00 --- /dev/null +++ b/src/app/api/spaces/[slug]/members/[userId]/route.ts @@ -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 }); +} diff --git a/src/app/api/spaces/[slug]/members/credits/route.ts b/src/app/api/spaces/[slug]/members/credits/route.ts new file mode 100644 index 0000000..13b9a9c --- /dev/null +++ b/src/app/api/spaces/[slug]/members/credits/route.ts @@ -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, + }); +} diff --git a/src/app/api/spaces/[slug]/members/route.ts b/src/app/api/spaces/[slug]/members/route.ts new file mode 100644 index 0000000..3e40d71 --- /dev/null +++ b/src/app/api/spaces/[slug]/members/route.ts @@ -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 }); +} diff --git a/src/app/api/spaces/[slug]/route.ts b/src/app/api/spaces/[slug]/route.ts new file mode 100644 index 0000000..c2a7a37 --- /dev/null +++ b/src/app/api/spaces/[slug]/route.ts @@ -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 = {}; + 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 }); +} diff --git a/src/app/api/spaces/join/[token]/route.ts b/src/app/api/spaces/join/[token]/route.ts new file mode 100644 index 0000000..a0e7d91 --- /dev/null +++ b/src/app/api/spaces/join/[token]/route.ts @@ -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, + }); +} diff --git a/src/app/api/spaces/route.ts b/src/app/api/spaces/route.ts new file mode 100644 index 0000000..f15b8f4 --- /dev/null +++ b/src/app/api/spaces/route.ts @@ -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 }); +} diff --git a/src/app/demo/page.tsx b/src/app/demo/page.tsx index 2709c31..2acaf67 100644 --- a/src/app/demo/page.tsx +++ b/src/app/demo/page.tsx @@ -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(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 (
@@ -214,296 +19,15 @@ export default function DemoPage() {

- {/* Credits display */} - - -
-
-
- - {credits} - credits -
- - Max vote: ±{maxWeight} - -
- -
-
-
- - {/* Quadratic cost explainer */} - - - - - Quadratic Voting Cost - - - Each additional vote costs exponentially more credits - - - -
- {[1, 2, 3, 4, 5].map((w) => ( -
-
+{w}
-
vote{w > 1 ? "s" : ""}
-
{w * w}¢
-
- ))} -
-
-
- - {/* Ranking stage */} -
-
- Stage 1 -

Quadratic Ranking

- - Score +100 to advance → - -
- -
- {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 ( -
0 - ? "ring-2 ring-orange-500/50" - : "ring-2 ring-blue-500/50" - : "" - }`} - > - {/* Reddit-style vote column */} -
- - - - {displayScore} - - - -
- - {/* Proposal content */} -
-

{proposal.title}

-

- {proposal.description} -

- - {/* Progress bar */} -
-
- Progress to voting stage - - {displayScore}/100 - -
-
-
-
-
- - {/* Pending vote confirmation */} - {hasPending && ( -
- 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 - - - -
- )} - - {/* Existing vote indicator */} - {hasVoted && !hasPending && ( -
- 0 - ? "bg-orange-500/20 text-orange-600" - : "bg-blue-500/20 text-blue-600" - } - > - You voted: {proposal.userVote > 0 ? "+" : ""}{proposal.userVote} - -
- )} -
-
- ); - })} -
-
- - {/* Voting stage */} - {votingProposals.length > 0 && ( -
-
- Stage 2 -

Pass/Fail Voting

- - One member = one vote - -
- - {votingProposals.map((proposal) => { - const total = proposal.yesVotes + proposal.noVotes; - const yesPercent = total > 0 ? (proposal.yesVotes / total) * 100 : 50; - return ( - - -
- {proposal.title} -
- - 6 days left -
-
- {proposal.description} -
- -
-
-
-
-
-
- - {proposal.yesVotes} Yes ({Math.round(yesPercent)}%) - - - {proposal.noVotes} No ({Math.round(100 - yesPercent)}%) - -
-
- -
- - - -
- - - ); - })} -
- )} + {/* CTA */}

Ready to try it for real?

- Create an account to start ranking and voting on community proposals. - You'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.

- {!session?.user ? ( - - ) : ( - - )} +
@@ -137,7 +75,7 @@ export default async function HomePage() {
- +

Quadratic

@@ -182,6 +120,22 @@ export default async function HomePage() {
+ {/* Interactive Demo inline */} +
+
+ + Interactive Demo + +

Try It Yourself

+

+ Click the vote arrows to rank proposals. Watch how quadratic costs scale in real-time. +

+
+
+ +
+
+ {/* What is Quadratic Proposal Ranking */}
@@ -431,57 +385,6 @@ export default async function HomePage() {
- {/* Stats */} - {(rankingCount > 0 || userCount > 1) && ( -
-
- - -
{userCount}
-

Members

-
-
- - -
{rankingCount}
-

Being Ranked

-
-
- - -
{votingCount}
-

In Voting

-
-
- - -
{passedCount}
-

Passed

-
-
-
-
- )} - - {/* Active proposals */} - {proposals.length > 0 && ( -
-
-

Active Proposals

- -
- -
- )} - {/* CTA */}
@@ -491,24 +394,22 @@ export default async function HomePage() { Join the rSpace Ecosystem

Ready to prioritize democratically?

- Experience Quadratic Proposal Ranking firsthand. Try the interactive demo or - create an account to start building your community'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.

+ - {!session?.user && ( - - )}
diff --git a/src/app/proposals/[id]/page.tsx b/src/app/proposals/[id]/page.tsx index 1d4908a..f33317a 100644 --- a/src/app/proposals/[id]/page.tsx +++ b/src/app/proposals/[id]/page.tsx @@ -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 = { - 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 ( -
- - -
- {/* Main content */} -
- - -
- {proposal.title} - - {proposal.status} - -
-
- - - {proposal.author.name || proposal.author.email.split("@")[0]} - - - - {formatDistanceToNow(proposal.createdAt, { addSuffix: true })} - -
-
- -
-

{proposal.description}

-
-
-
- - {/* Votes list for ranking stage */} - {isRanking && proposal.votes.length > 0 && ( - - - Recent Votes - - -
- {proposal.votes.slice(0, 10).map((vote) => { - const effectiveWeight = getEffectiveWeight( - vote.weight, - vote.createdAt - ); - const decayPct = getDecayPercentage(vote.createdAt); - return ( -
-
- 0 ? "default" : "destructive"} - > - {vote.weight > 0 ? "+" : ""} - {effectiveWeight} - - - {vote.user.name || "Anonymous"} - -
-
- {decayPct > 0 && ( - - {decayPct}% decayed •{" "} - - )} - {formatDistanceToNow(vote.createdAt, { - addSuffix: true, - })} -
-
- ); - })} -
-
-
- )} -
- - {/* Sidebar */} -
- {/* Ranking vote panel */} - {isRanking && ( - - - Ranking - - -
- -
- - {!session?.user && ( -

- - Sign in - {" "} - to vote -

- )} - -
-
- - - Progress to voting - - {effectiveScore}/100 -
-
-
-
-
- -
-

- {proposal.votes.length} votes cast -

- {session?.user && ( -

- You have {availableCredits} credits -

- )} -
- - - )} - - {/* Pass/fail vote panel */} - {isVoting && proposal.votingEndsAt && ( - - )} - - {/* Completed result */} - {isCompleted && ( - - - Result - - -
- - {proposal.status} - -
- -
-
-
- {finalVoteCounts.yes} -
-
Yes
-
-
-
- {finalVoteCounts.no} -
-
No
-
-
-
- {finalVoteCounts.abstain} -
-
Abstain
-
-
- - {proposal.votingEndsAt && ( -

- Voting ended {format(proposal.votingEndsAt, "PPP")} -

- )} -
-
- )} - - {/* Info card */} - - - Details - - -
- Created - {format(proposal.createdAt, "PPP")} -
-
- Last updated - {format(proposal.updatedAt, "PPP")} -
- {proposal.votingEndsAt && ( -
- - {isVoting ? "Voting ends" : "Voting ended"} - - {format(proposal.votingEndsAt, "PPP")} -
- )} -
-
-
-
-
- ); + // Fallback: redirect to spaces list if no space assigned + redirect("/spaces"); } diff --git a/src/app/proposals/new/page.tsx b/src/app/proposals/new/page.tsx index afd6b99..0086d59 100644 --- a/src/app/proposals/new/page.tsx +++ b/src/app/proposals/new/page.tsx @@ -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 ( -
- - - - - Create New Proposal - - 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. - - -
- -
- - setTitle(e.target.value)} - maxLength={200} - required - disabled={isLoading} - /> -

- {title.length}/200 characters -

-
- -
- -