Redesign voting UI with inline click-to-count interface

- 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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-05 11:37:13 +00:00
parent 1a5bcc6266
commit 5b0f6f1bf1
2 changed files with 293 additions and 238 deletions

View File

@ -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<DemoProposal[]>(initialProposals);
const [voteWeight, setVoteWeight] = useState(1);
const [activeProposal, setActiveProposal] = useState<number | null>(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() {
</Badge>
<h1 className="text-4xl font-bold">Try Quadratic Proposal Ranking</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
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.
</p>
</div>
@ -176,8 +216,7 @@ export default function DemoPage() {
Quadratic Ranking Cost
</CardTitle>
<CardDescription>
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!
</CardDescription>
</CardHeader>
<CardContent>
@ -214,130 +253,133 @@ export default function DemoPage() {
</span>
</div>
{rankingProposals.map((proposal) => (
<Card key={proposal.id}>
<div className="flex">
<div className="flex flex-col items-center justify-center px-4 border-r bg-muted/30 min-w-[80px]">
<Button
variant={proposal.userVote > 0 ? "default" : "ghost"}
size="sm"
className="h-8 w-8 p-0"
onClick={() => {
if (proposal.userVote > 0) {
// Remove vote (simplified)
setProposals((prev) =>
prev.map((p) =>
p.id === proposal.id
? { ...p, score: p.score - p.userVote, userVote: 0 }
: p
)
);
setCredits((c) => c + proposal.userVote * proposal.userVote);
} else {
setActiveProposal(proposal.id);
}
}}
disabled={credits < 1 && proposal.userVote === 0}
>
<ChevronUp className="h-5 w-5" />
</Button>
<Badge variant="outline" className="font-mono text-lg my-1">
{proposal.score}
</Badge>
<Button
variant={proposal.userVote < 0 ? "destructive" : "ghost"}
size="sm"
className="h-8 w-8 p-0"
onClick={() => {
if (proposal.userVote < 0) {
setProposals((prev) =>
prev.map((p) =>
p.id === proposal.id
? { ...p, score: p.score - p.userVote, userVote: 0 }
: p
)
);
setCredits((c) => c + proposal.userVote * proposal.userVote);
} else {
setActiveProposal(-proposal.id); // Negative for downvote
}
}}
disabled={credits < 1 && proposal.userVote === 0}
>
<ChevronDown className="h-5 w-5" />
</Button>
</div>
<div className="flex-1 p-4">
<h3 className="font-semibold">{proposal.title}</h3>
<p className="text-sm text-muted-foreground">
{proposal.description}
</p>
<div className="mt-3">
<div className="flex items-center justify-between text-xs mb-1">
<span>Progress to voting</span>
<span>{proposal.score}/100</span>
</div>
<Progress
value={Math.min((proposal.score / 100) * 100, 100)}
className="h-2"
/>
</div>
{proposal.userVote !== 0 && (
<p className="text-xs text-muted-foreground mt-2">
Your vote: {proposal.userVote > 0 ? "+" : ""}
{proposal.userVote} ({Math.abs(proposal.userVote * proposal.userVote)} credits)
</p>
)}
</div>
</div>
{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) && (
<div className="border-t p-4 bg-muted/30">
<div className="flex items-center gap-4">
<Label>Vote weight:</Label>
<Input
type="number"
min={1}
max={maxWeight}
value={voteWeight}
onChange={(e) =>
setVoteWeight(
Math.max(1, Math.min(maxWeight, parseInt(e.target.value) || 1))
)
}
className="w-20"
/>
<span className="text-sm text-muted-foreground">
Cost: {voteCost} credits
</span>
<div className="flex-1" />
return (
<Card key={proposal.id}>
<div className="flex">
<div className="flex flex-col items-center justify-center px-4 border-r bg-muted/30 min-w-[100px]">
{/* Up arrow */}
<Button
variant="outline"
variant={proposal.userVote > 0 ? "default" : proposal.pendingVote > 0 ? "outline" : "ghost"}
size="sm"
onClick={() => setActiveProposal(null)}
className={`h-8 w-8 p-0 ${proposal.pendingVote > 0 ? "border-primary bg-primary/10" : ""}`}
onClick={() => {
if (proposal.userVote > 0) {
removeVote(proposal.id);
} else if (!hasPending || proposal.pendingVote > 0) {
incrementVote(proposal.id);
}
}}
disabled={hasVoted && proposal.userVote < 0}
>
Cancel
<ChevronUp className="h-5 w-5" />
</Button>
{/* Score display */}
<Badge
variant={hasPending ? "default" : "outline"}
className={`font-mono text-lg my-1 min-w-[4rem] justify-center transition-all ${
hasPending
? proposal.pendingVote > 0
? "bg-primary text-primary-foreground"
: "bg-destructive text-destructive-foreground"
: ""
}`}
>
{hasPending ? (
<span className="flex items-center gap-1 text-sm">
<span className="opacity-70">{proposal.score}</span>
<span></span>
<span>{previewScore}</span>
</span>
) : (
proposal.score
)}
</Badge>
{/* Down arrow */}
<Button
variant={proposal.userVote < 0 ? "destructive" : proposal.pendingVote < 0 ? "outline" : "ghost"}
size="sm"
variant={activeProposal > 0 ? "default" : "destructive"}
onClick={() =>
castVote(
Math.abs(activeProposal),
activeProposal > 0 ? "up" : "down"
)
}
disabled={voteCost > credits}
className={`h-8 w-8 p-0 ${proposal.pendingVote < 0 ? "border-destructive bg-destructive/10" : ""}`}
onClick={() => {
if (proposal.userVote < 0) {
removeVote(proposal.id);
} else if (!hasPending || proposal.pendingVote < 0) {
decrementVote(proposal.id);
}
}}
disabled={hasVoted && proposal.userVote > 0}
>
{activeProposal > 0 ? "Upvote" : "Downvote"} ({voteCost} credits)
<ChevronDown className="h-5 w-5" />
</Button>
{/* Pending vote info */}
{hasPending && (
<div className="flex flex-col items-center gap-0.5 mt-2">
<span className="text-xs font-medium">
{proposal.pendingVote > 0 ? "+" : ""}{proposal.pendingVote} vote{Math.abs(proposal.pendingVote) !== 1 ? "s" : ""}
</span>
<span className="text-xs text-muted-foreground">
{pendingCost} credit{pendingCost !== 1 ? "s" : ""}
</span>
<div className="flex gap-1 mt-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => cancelPending(proposal.id)}
title="Cancel"
>
<X className="h-3 w-3" />
</Button>
<Button
variant={proposal.pendingVote > 0 ? "default" : "destructive"}
size="sm"
className="h-6 px-2 text-xs"
onClick={() => castVote(proposal.id)}
title="Confirm vote"
>
<Check className="h-3 w-3 mr-1" />
Cast
</Button>
</div>
</div>
)}
{/* Existing vote display */}
{hasVoted && !hasPending && (
<span className="text-xs text-muted-foreground mt-2">
Your vote: {proposal.userVote > 0 ? "+" : ""}{proposal.userVote}
</span>
)}
</div>
<div className="flex-1 p-4">
<h3 className="font-semibold">{proposal.title}</h3>
<p className="text-sm text-muted-foreground">
{proposal.description}
</p>
<div className="mt-3">
<div className="flex items-center justify-between text-xs mb-1">
<span>Progress to voting</span>
<span>{hasPending ? previewScore : proposal.score}/100</span>
</div>
<Progress
value={Math.min(((hasPending ? previewScore : proposal.score) / 100) * 100, 100)}
className="h-2"
/>
</div>
</div>
</div>
)}
</Card>
))}
</Card>
);
})}
</section>
{/* Voting stage */}

View File

@ -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 (
<div className="flex flex-col items-center gap-1">
{/* Up arrow */}
<Button
variant={votedUp ? "default" : "ghost"}
variant={votedUp ? "default" : pendingWeight > 0 ? "outline" : "ghost"}
size="sm"
className="h-8 w-8 p-0"
onClick={() => (votedUp ? removeVote() : openVoteDialog("up"))}
disabled={disabled || isVoting || (!votedUp && maxWeight < 1)}
title={votedUp ? "Remove upvote" : "Upvote"}
className={`h-8 w-8 p-0 ${pendingWeight > 0 ? "border-primary bg-primary/10" : ""}`}
onClick={() => {
if (votedUp) {
removeVote();
} else if (!hasPending || pendingWeight > 0) {
incrementVote();
}
}}
disabled={disabled || isVoting || (hasVoted && !votedUp)}
title={votedUp ? "Remove upvote" : "Add upvote"}
>
{isVoting ? (
<Loader2 className="h-4 w-4 animate-spin" />
@ -123,17 +137,42 @@ export function VoteButtons({
)}
</Button>
<Badge variant="outline" className="font-mono text-lg px-2 min-w-[3rem] justify-center">
{currentScore}
{/* Score display */}
<Badge
variant={hasPending ? "default" : "outline"}
className={`font-mono text-lg px-2 min-w-[3rem] justify-center transition-all ${
hasPending
? pendingWeight > 0
? "bg-primary text-primary-foreground"
: "bg-destructive text-destructive-foreground"
: ""
}`}
>
{hasPending ? (
<span className="flex items-center gap-1">
<span className="text-xs opacity-70">{currentScore}</span>
<span></span>
<span>{previewScore}</span>
</span>
) : (
currentScore
)}
</Badge>
{/* Down arrow */}
<Button
variant={votedDown ? "destructive" : "ghost"}
variant={votedDown ? "destructive" : pendingWeight < 0 ? "outline" : "ghost"}
size="sm"
className="h-8 w-8 p-0"
onClick={() => (votedDown ? removeVote() : openVoteDialog("down"))}
disabled={disabled || isVoting || (!votedDown && maxWeight < 1)}
title={votedDown ? "Remove downvote" : "Downvote"}
className={`h-8 w-8 p-0 ${pendingWeight < 0 ? "border-destructive bg-destructive/10" : ""}`}
onClick={() => {
if (votedDown) {
removeVote();
} else if (!hasPending || pendingWeight < 0) {
decrementVote();
}
}}
disabled={disabled || isVoting || (hasVoted && !votedDown)}
title={votedDown ? "Remove downvote" : "Add downvote"}
>
{isVoting ? (
<Loader2 className="h-4 w-4 animate-spin" />
@ -142,79 +181,53 @@ export function VoteButtons({
)}
</Button>
{hasVoted && (
{/* Existing vote display */}
{hasVoted && !hasPending && (
<span className="text-xs text-muted-foreground">
Your vote: {userVote.effectiveWeight > 0 ? "+" : ""}
{userVote.effectiveWeight}
</span>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{voteDirection === "up" ? "Upvote" : "Downvote"} Proposal
</DialogTitle>
<DialogDescription>
Choose your vote weight. Cost increases quadratically (weight² credits).
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="weight" className="text-right">
Weight
</Label>
<Input
id="weight"
type="number"
min={1}
max={maxWeight}
value={voteWeight}
onChange={(e) =>
setVoteWeight(
Math.max(1, Math.min(maxWeight, parseInt(e.target.value) || 1))
)
}
className="col-span-3"
/>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<span className="text-right text-sm text-muted-foreground">
Cost
</span>
<span className="col-span-3 font-mono">
{voteCost} credits (you have {availableCredits})
</span>
</div>
<div className="text-sm text-muted-foreground">
<p>Quick reference:</p>
<ul className="list-disc list-inside mt-1">
<li>1 vote = 1 credit</li>
<li>2 votes = 4 credits</li>
<li>3 votes = 9 credits</li>
<li>Max you can afford: {maxWeight} votes ({calculateVoteCost(maxWeight)} credits)</li>
</ul>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}>
Cancel
{/* Pending vote info and confirm/cancel */}
{hasPending && (
<div className="flex flex-col items-center gap-1 mt-1">
<span className="text-xs font-medium">
{pendingWeight > 0 ? "+" : ""}{pendingWeight} vote{Math.abs(pendingWeight) !== 1 ? "s" : ""}
</span>
<span className="text-xs text-muted-foreground">
{pendingCost} credit{pendingCost !== 1 ? "s" : ""}
</span>
<div className="flex gap-1 mt-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={cancelPending}
title="Cancel"
>
<X className="h-3 w-3" />
</Button>
<Button
variant={pendingWeight > 0 ? "default" : "destructive"}
size="sm"
className="h-6 px-2 text-xs"
onClick={submitVote}
disabled={isVoting || voteWeight < 1 || voteCost > availableCredits}
variant={voteDirection === "up" ? "default" : "destructive"}
disabled={isVoting || !canAfford}
title="Confirm vote"
>
{isVoting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{voteDirection === "up" ? "Upvote" : "Downvote"} ({voteCost} credits)
{isVoting ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<>
<Check className="h-3 w-3 mr-1" />
Cast
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
)}
</div>
);
}