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:
parent
333230cea9
commit
e22063c092
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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'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">
|
||||
|
|
|
|||
159
src/app/page.tsx
159
src/app/page.tsx
|
|
@ -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²</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'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>
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} ·{" "}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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: ±{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}¢</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"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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).*)",
|
||||
],
|
||||
};
|
||||
Loading…
Reference in New Issue