From 5b0f6f1bf1dfb99d199658406c0dc2c86ceb184a Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 5 Feb 2026 11:37:13 +0000 Subject: [PATCH] Redesign voting UI with inline click-to-count interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace dialog-based vote weight selector with inline incrementing - Click up/down arrows to add votes, cost updates in real-time - Score badge shows preview (current → new) while pending - Small confirm/cancel buttons appear below pending votes - Same UX for both VoteButtons component and demo page - More intuitive and immediate feedback Co-Authored-By: Claude Opus 4.5 --- src/app/demo/page.tsx | 320 +++++++++++++++++++-------------- src/components/VoteButtons.tsx | 211 ++++++++++++---------- 2 files changed, 293 insertions(+), 238 deletions(-) diff --git a/src/app/demo/page.tsx b/src/app/demo/page.tsx index b541ee4..bfec7c4 100644 --- a/src/app/demo/page.tsx +++ b/src/app/demo/page.tsx @@ -5,8 +5,6 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Progress } from "@/components/ui/progress"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import Link from "next/link"; import { ChevronUp, @@ -19,7 +17,6 @@ import { Coins, TrendingUp, Clock, - Users, } from "lucide-react"; interface DemoProposal { @@ -28,6 +25,7 @@ interface DemoProposal { description: string; score: number; userVote: number; + pendingVote: number; stage: "ranking" | "voting"; yesVotes: number; noVotes: number; @@ -40,6 +38,7 @@ const initialProposals: DemoProposal[] = [ description: "Implement a dark theme option for better nighttime usage", score: 87, userVote: 0, + pendingVote: 0, stage: "ranking", yesVotes: 0, noVotes: 0, @@ -50,6 +49,7 @@ const initialProposals: DemoProposal[] = [ description: "Host weekly video calls to discuss proposals and progress", score: 42, userVote: 0, + pendingVote: 0, stage: "ranking", yesVotes: 0, noVotes: 0, @@ -60,6 +60,7 @@ const initialProposals: DemoProposal[] = [ description: "Build native iOS and Android apps for on-the-go voting", score: 103, userVote: 0, + pendingVote: 0, stage: "voting", yesVotes: 12, noVotes: 5, @@ -69,32 +70,60 @@ const initialProposals: DemoProposal[] = [ export default function DemoPage() { const [credits, setCredits] = useState(100); const [proposals, setProposals] = useState(initialProposals); - const [voteWeight, setVoteWeight] = useState(1); - const [activeProposal, setActiveProposal] = useState(null); - const voteCost = voteWeight * voteWeight; const maxWeight = Math.floor(Math.sqrt(credits)); - function castVote(proposalId: number, direction: "up" | "down") { - const weight = direction === "up" ? voteWeight : -voteWeight; - const cost = voteCost; - - if (cost > credits) return; - + function incrementVote(proposalId: number) { setProposals((prev) => prev.map((p) => { if (p.id === proposalId) { - // Return old vote credits if changing vote - const oldCost = p.userVote !== 0 ? p.userVote * p.userVote : 0; - const newScore = p.score - p.userVote + weight; + const newPending = p.pendingVote + 1; + const newCost = newPending * newPending; + if (newCost <= credits) { + return { ...p, pendingVote: newPending }; + } + } + return p; + }) + ); + } - // Check if promoted + function decrementVote(proposalId: number) { + setProposals((prev) => + prev.map((p) => { + if (p.id === proposalId) { + const newPending = p.pendingVote - 1; + const newCost = newPending * newPending; + if (newCost <= credits) { + return { ...p, pendingVote: newPending }; + } + } + return p; + }) + ); + } + + function cancelPending(proposalId: number) { + setProposals((prev) => + prev.map((p) => (p.id === proposalId ? { ...p, pendingVote: 0 } : p)) + ); + } + + function castVote(proposalId: number) { + setProposals((prev) => + prev.map((p) => { + if (p.id === proposalId && p.pendingVote !== 0) { + 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: weight, + userVote: p.pendingVote, + pendingVote: 0, stage: promoted ? "voting" : p.stage, yesVotes: promoted ? 8 : p.yesVotes, noVotes: promoted ? 3 : p.noVotes, @@ -103,10 +132,23 @@ export default function DemoPage() { return p; }) ); + } - // Deduct credits (simplified - doesn't return old vote credits in demo) - setCredits((prev) => prev - cost); - setActiveProposal(null); + function removeVote(proposalId: number) { + setProposals((prev) => + prev.map((p) => { + if (p.id === proposalId && p.userVote !== 0) { + const refund = p.userVote * p.userVote; + setCredits((c) => c + refund); + return { + ...p, + score: p.score - p.userVote, + userVote: 0, + }; + } + return p; + }) + ); } function castFinalVote(proposalId: number, vote: "yes" | "no" | "abstain") { @@ -127,8 +169,6 @@ export default function DemoPage() { function resetDemo() { setCredits(100); setProposals(initialProposals); - setVoteWeight(1); - setActiveProposal(null); } const rankingProposals = proposals.filter((p) => p.stage === "ranking"); @@ -142,8 +182,8 @@ export default function DemoPage() {

Try Quadratic Proposal Ranking

- Experience how rVote works without creating an account. Rank these - sample proposals and see the quadratic cost in action. + Experience how rVote works without creating an account. Click the arrows + to add votes and see the quadratic cost increase in real-time.

@@ -176,8 +216,7 @@ export default function DemoPage() { Quadratic Ranking Cost - The more votes you put on one proposal, the more each additional - vote costs + Click the arrows to add votes. Each additional vote costs more! @@ -214,130 +253,133 @@ export default function DemoPage() { - {rankingProposals.map((proposal) => ( - -
-
- - - {proposal.score} - - -
-
-

{proposal.title}

-

- {proposal.description} -

-
-
- Progress to voting - {proposal.score}/100 -
- -
- {proposal.userVote !== 0 && ( -

- Your vote: {proposal.userVote > 0 ? "+" : ""} - {proposal.userVote} ({Math.abs(proposal.userVote * proposal.userVote)} credits) -

- )} -
-
+ {rankingProposals.map((proposal) => { + const hasPending = proposal.pendingVote !== 0; + const hasVoted = proposal.userVote !== 0; + const pendingCost = proposal.pendingVote * proposal.pendingVote; + const previewScore = proposal.score + proposal.pendingVote; - {/* Vote weight selector */} - {(activeProposal === proposal.id || - activeProposal === -proposal.id) && ( -
-
- - - setVoteWeight( - Math.max(1, Math.min(maxWeight, parseInt(e.target.value) || 1)) - ) - } - className="w-20" - /> - - Cost: {voteCost} credits - -
+ return ( + +
+
+ {/* Up arrow */} + + {/* Score display */} + 0 + ? "bg-primary text-primary-foreground" + : "bg-destructive text-destructive-foreground" + : "" + }`} + > + {hasPending ? ( + + {proposal.score} + + {previewScore} + + ) : ( + proposal.score + )} + + + {/* Down arrow */} + + {/* Pending vote info */} + {hasPending && ( +
+ + {proposal.pendingVote > 0 ? "+" : ""}{proposal.pendingVote} vote{Math.abs(proposal.pendingVote) !== 1 ? "s" : ""} + + + {pendingCost} credit{pendingCost !== 1 ? "s" : ""} + +
+ + +
+
+ )} + + {/* Existing vote display */} + {hasVoted && !hasPending && ( + + Your vote: {proposal.userVote > 0 ? "+" : ""}{proposal.userVote} + + )} +
+ +
+

{proposal.title}

+

+ {proposal.description} +

+
+
+ Progress to voting + {hasPending ? previewScore : proposal.score}/100 +
+ +
- )} -
- ))} + + ); + })} {/* Voting stage */} diff --git a/src/components/VoteButtons.tsx b/src/components/VoteButtons.tsx index 729af19..446c224 100644 --- a/src/components/VoteButtons.tsx +++ b/src/components/VoteButtons.tsx @@ -3,18 +3,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { ChevronUp, ChevronDown, Loader2 } from "lucide-react"; +import { ChevronUp, ChevronDown, Loader2, Check, X } from "lucide-react"; import { calculateVoteCost, maxAffordableWeight } from "@/lib/credits"; import { toast } from "sonner"; @@ -39,21 +28,21 @@ export function VoteButtons({ disabled = false, }: VoteButtonsProps) { const [isVoting, setIsVoting] = useState(false); - const [dialogOpen, setDialogOpen] = useState(false); - const [voteDirection, setVoteDirection] = useState<"up" | "down">("up"); - const [voteWeight, setVoteWeight] = useState(1); + const [pendingWeight, setPendingWeight] = useState(0); const maxWeight = maxAffordableWeight(availableCredits); - const voteCost = calculateVoteCost(voteWeight); + const pendingCost = calculateVoteCost(Math.abs(pendingWeight)); + const canAfford = pendingCost <= availableCredits; async function submitVote() { + if (pendingWeight === 0) return; + setIsVoting(true); try { - const weight = voteDirection === "up" ? voteWeight : -voteWeight; const res = await fetch(`/api/proposals/${proposalId}/vote`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ weight }), + body: JSON.stringify({ weight: pendingWeight }), }); if (!res.ok) { @@ -62,9 +51,9 @@ export function VoteButtons({ } const data = await res.json(); - toast.success(`Vote cast! Cost: ${voteCost} credits`); - onVote?.(data.newScore, weight); - setDialogOpen(false); + toast.success(`Vote cast! Cost: ${pendingCost} credits`); + onVote?.(data.newScore, pendingWeight); + setPendingWeight(0); } catch (error) { toast.error(error instanceof Error ? error.message : "Failed to vote"); } finally { @@ -96,25 +85,50 @@ export function VoteButtons({ } } - function openVoteDialog(direction: "up" | "down") { - setVoteDirection(direction); - setVoteWeight(1); - setDialogOpen(true); + function incrementVote() { + const newWeight = pendingWeight + 1; + const newCost = calculateVoteCost(Math.abs(newWeight)); + if (newCost <= availableCredits) { + setPendingWeight(newWeight); + } + } + + function decrementVote() { + const newWeight = pendingWeight - 1; + const newCost = calculateVoteCost(Math.abs(newWeight)); + if (newCost <= availableCredits) { + setPendingWeight(newWeight); + } + } + + function cancelPending() { + setPendingWeight(0); } const hasVoted = userVote && userVote.weight !== 0; const votedUp = hasVoted && userVote.weight > 0; const votedDown = hasVoted && userVote.weight < 0; + const hasPending = pendingWeight !== 0; + + // Preview score with pending vote + const previewScore = hasPending ? currentScore + pendingWeight : currentScore; return (
+ {/* Up arrow */} - - {currentScore} + {/* Score display */} + 0 + ? "bg-primary text-primary-foreground" + : "bg-destructive text-destructive-foreground" + : "" + }`} + > + {hasPending ? ( + + {currentScore} + + {previewScore} + + ) : ( + currentScore + )} + {/* Down arrow */} - {hasVoted && ( + {/* Existing vote display */} + {hasVoted && !hasPending && ( Your vote: {userVote.effectiveWeight > 0 ? "+" : ""} {userVote.effectiveWeight} )} - - - - - {voteDirection === "up" ? "Upvote" : "Downvote"} Proposal - - - Choose your vote weight. Cost increases quadratically (weight² credits). - - - -
-
- - - setVoteWeight( - Math.max(1, Math.min(maxWeight, parseInt(e.target.value) || 1)) - ) - } - className="col-span-3" - /> -
- -
- - Cost - - - {voteCost} credits (you have {availableCredits}) - -
- -
-

Quick reference:

-
    -
  • 1 vote = 1 credit
  • -
  • 2 votes = 4 credits
  • -
  • 3 votes = 9 credits
  • -
  • Max you can afford: {maxWeight} votes ({calculateVoteCost(maxWeight)} credits)
  • -
-
-
- - - - -
-
+
+
+ )}
); }