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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import Link from "next/link"; import Link from "next/link";
import { import {
ChevronUp, ChevronUp,
@ -19,7 +17,6 @@ import {
Coins, Coins,
TrendingUp, TrendingUp,
Clock, Clock,
Users,
} from "lucide-react"; } from "lucide-react";
interface DemoProposal { interface DemoProposal {
@ -28,6 +25,7 @@ interface DemoProposal {
description: string; description: string;
score: number; score: number;
userVote: number; userVote: number;
pendingVote: number;
stage: "ranking" | "voting"; stage: "ranking" | "voting";
yesVotes: number; yesVotes: number;
noVotes: number; noVotes: number;
@ -40,6 +38,7 @@ const initialProposals: DemoProposal[] = [
description: "Implement a dark theme option for better nighttime usage", description: "Implement a dark theme option for better nighttime usage",
score: 87, score: 87,
userVote: 0, userVote: 0,
pendingVote: 0,
stage: "ranking", stage: "ranking",
yesVotes: 0, yesVotes: 0,
noVotes: 0, noVotes: 0,
@ -50,6 +49,7 @@ const initialProposals: DemoProposal[] = [
description: "Host weekly video calls to discuss proposals and progress", description: "Host weekly video calls to discuss proposals and progress",
score: 42, score: 42,
userVote: 0, userVote: 0,
pendingVote: 0,
stage: "ranking", stage: "ranking",
yesVotes: 0, yesVotes: 0,
noVotes: 0, noVotes: 0,
@ -60,6 +60,7 @@ const initialProposals: DemoProposal[] = [
description: "Build native iOS and Android apps for on-the-go voting", description: "Build native iOS and Android apps for on-the-go voting",
score: 103, score: 103,
userVote: 0, userVote: 0,
pendingVote: 0,
stage: "voting", stage: "voting",
yesVotes: 12, yesVotes: 12,
noVotes: 5, noVotes: 5,
@ -69,32 +70,60 @@ const initialProposals: DemoProposal[] = [
export default function DemoPage() { export default function DemoPage() {
const [credits, setCredits] = useState(100); const [credits, setCredits] = useState(100);
const [proposals, setProposals] = useState<DemoProposal[]>(initialProposals); 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)); const maxWeight = Math.floor(Math.sqrt(credits));
function castVote(proposalId: number, direction: "up" | "down") { function incrementVote(proposalId: number) {
const weight = direction === "up" ? voteWeight : -voteWeight;
const cost = voteCost;
if (cost > credits) return;
setProposals((prev) => setProposals((prev) =>
prev.map((p) => { prev.map((p) => {
if (p.id === proposalId) { if (p.id === proposalId) {
// Return old vote credits if changing vote const newPending = p.pendingVote + 1;
const oldCost = p.userVote !== 0 ? p.userVote * p.userVote : 0; const newCost = newPending * newPending;
const newScore = p.score - p.userVote + weight; 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"; const promoted = newScore >= 100 && p.stage === "ranking";
setCredits((c) => c - cost);
return { return {
...p, ...p,
score: newScore, score: newScore,
userVote: weight, userVote: p.pendingVote,
pendingVote: 0,
stage: promoted ? "voting" : p.stage, stage: promoted ? "voting" : p.stage,
yesVotes: promoted ? 8 : p.yesVotes, yesVotes: promoted ? 8 : p.yesVotes,
noVotes: promoted ? 3 : p.noVotes, noVotes: promoted ? 3 : p.noVotes,
@ -103,10 +132,23 @@ export default function DemoPage() {
return p; return p;
}) })
); );
}
// Deduct credits (simplified - doesn't return old vote credits in demo) function removeVote(proposalId: number) {
setCredits((prev) => prev - cost); setProposals((prev) =>
setActiveProposal(null); 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") { function castFinalVote(proposalId: number, vote: "yes" | "no" | "abstain") {
@ -127,8 +169,6 @@ export default function DemoPage() {
function resetDemo() { function resetDemo() {
setCredits(100); setCredits(100);
setProposals(initialProposals); setProposals(initialProposals);
setVoteWeight(1);
setActiveProposal(null);
} }
const rankingProposals = proposals.filter((p) => p.stage === "ranking"); const rankingProposals = proposals.filter((p) => p.stage === "ranking");
@ -142,8 +182,8 @@ export default function DemoPage() {
</Badge> </Badge>
<h1 className="text-4xl font-bold">Try Quadratic Proposal Ranking</h1> <h1 className="text-4xl font-bold">Try Quadratic Proposal Ranking</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto"> <p className="text-xl text-muted-foreground max-w-2xl mx-auto">
Experience how rVote works without creating an account. Rank these Experience how rVote works without creating an account. Click the arrows
sample proposals and see the quadratic cost in action. to add votes and see the quadratic cost increase in real-time.
</p> </p>
</div> </div>
@ -176,8 +216,7 @@ export default function DemoPage() {
Quadratic Ranking Cost Quadratic Ranking Cost
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
The more votes you put on one proposal, the more each additional Click the arrows to add votes. Each additional vote costs more!
vote costs
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -214,130 +253,133 @@ export default function DemoPage() {
</span> </span>
</div> </div>
{rankingProposals.map((proposal) => ( {rankingProposals.map((proposal) => {
<Card key={proposal.id}> const hasPending = proposal.pendingVote !== 0;
<div className="flex"> const hasVoted = proposal.userVote !== 0;
<div className="flex flex-col items-center justify-center px-4 border-r bg-muted/30 min-w-[80px]"> const pendingCost = proposal.pendingVote * proposal.pendingVote;
<Button const previewScore = proposal.score + proposal.pendingVote;
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>
{/* Vote weight selector */} return (
{(activeProposal === proposal.id || <Card key={proposal.id}>
activeProposal === -proposal.id) && ( <div className="flex">
<div className="border-t p-4 bg-muted/30"> <div className="flex flex-col items-center justify-center px-4 border-r bg-muted/30 min-w-[100px]">
<div className="flex items-center gap-4"> {/* Up arrow */}
<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" />
<Button <Button
variant="outline" variant={proposal.userVote > 0 ? "default" : proposal.pendingVote > 0 ? "outline" : "ghost"}
size="sm" 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> </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 <Button
variant={proposal.userVote < 0 ? "destructive" : proposal.pendingVote < 0 ? "outline" : "ghost"}
size="sm" size="sm"
variant={activeProposal > 0 ? "default" : "destructive"} className={`h-8 w-8 p-0 ${proposal.pendingVote < 0 ? "border-destructive bg-destructive/10" : ""}`}
onClick={() => onClick={() => {
castVote( if (proposal.userVote < 0) {
Math.abs(activeProposal), removeVote(proposal.id);
activeProposal > 0 ? "up" : "down" } else if (!hasPending || proposal.pendingVote < 0) {
) decrementVote(proposal.id);
} }
disabled={voteCost > credits} }}
disabled={hasVoted && proposal.userVote > 0}
> >
{activeProposal > 0 ? "Upvote" : "Downvote"} ({voteCost} credits) <ChevronDown className="h-5 w-5" />
</Button> </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>
</div> </div>
)} </Card>
</Card> );
))} })}
</section> </section>
{/* Voting stage */} {/* Voting stage */}

View File

@ -3,18 +3,7 @@
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import { ChevronUp, ChevronDown, Loader2, Check, X } from "lucide-react";
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 { calculateVoteCost, maxAffordableWeight } from "@/lib/credits"; import { calculateVoteCost, maxAffordableWeight } from "@/lib/credits";
import { toast } from "sonner"; import { toast } from "sonner";
@ -39,21 +28,21 @@ export function VoteButtons({
disabled = false, disabled = false,
}: VoteButtonsProps) { }: VoteButtonsProps) {
const [isVoting, setIsVoting] = useState(false); const [isVoting, setIsVoting] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false); const [pendingWeight, setPendingWeight] = useState(0);
const [voteDirection, setVoteDirection] = useState<"up" | "down">("up");
const [voteWeight, setVoteWeight] = useState(1);
const maxWeight = maxAffordableWeight(availableCredits); const maxWeight = maxAffordableWeight(availableCredits);
const voteCost = calculateVoteCost(voteWeight); const pendingCost = calculateVoteCost(Math.abs(pendingWeight));
const canAfford = pendingCost <= availableCredits;
async function submitVote() { async function submitVote() {
if (pendingWeight === 0) return;
setIsVoting(true); setIsVoting(true);
try { try {
const weight = voteDirection === "up" ? voteWeight : -voteWeight;
const res = await fetch(`/api/proposals/${proposalId}/vote`, { const res = await fetch(`/api/proposals/${proposalId}/vote`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ weight }), body: JSON.stringify({ weight: pendingWeight }),
}); });
if (!res.ok) { if (!res.ok) {
@ -62,9 +51,9 @@ export function VoteButtons({
} }
const data = await res.json(); const data = await res.json();
toast.success(`Vote cast! Cost: ${voteCost} credits`); toast.success(`Vote cast! Cost: ${pendingCost} credits`);
onVote?.(data.newScore, weight); onVote?.(data.newScore, pendingWeight);
setDialogOpen(false); setPendingWeight(0);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to vote"); toast.error(error instanceof Error ? error.message : "Failed to vote");
} finally { } finally {
@ -96,25 +85,50 @@ export function VoteButtons({
} }
} }
function openVoteDialog(direction: "up" | "down") { function incrementVote() {
setVoteDirection(direction); const newWeight = pendingWeight + 1;
setVoteWeight(1); const newCost = calculateVoteCost(Math.abs(newWeight));
setDialogOpen(true); 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 hasVoted = userVote && userVote.weight !== 0;
const votedUp = hasVoted && userVote.weight > 0; const votedUp = hasVoted && userVote.weight > 0;
const votedDown = 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 ( return (
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
{/* Up arrow */}
<Button <Button
variant={votedUp ? "default" : "ghost"} variant={votedUp ? "default" : pendingWeight > 0 ? "outline" : "ghost"}
size="sm" size="sm"
className="h-8 w-8 p-0" className={`h-8 w-8 p-0 ${pendingWeight > 0 ? "border-primary bg-primary/10" : ""}`}
onClick={() => (votedUp ? removeVote() : openVoteDialog("up"))} onClick={() => {
disabled={disabled || isVoting || (!votedUp && maxWeight < 1)} if (votedUp) {
title={votedUp ? "Remove upvote" : "Upvote"} removeVote();
} else if (!hasPending || pendingWeight > 0) {
incrementVote();
}
}}
disabled={disabled || isVoting || (hasVoted && !votedUp)}
title={votedUp ? "Remove upvote" : "Add upvote"}
> >
{isVoting ? ( {isVoting ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@ -123,17 +137,42 @@ export function VoteButtons({
)} )}
</Button> </Button>
<Badge variant="outline" className="font-mono text-lg px-2 min-w-[3rem] justify-center"> {/* Score display */}
{currentScore} <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> </Badge>
{/* Down arrow */}
<Button <Button
variant={votedDown ? "destructive" : "ghost"} variant={votedDown ? "destructive" : pendingWeight < 0 ? "outline" : "ghost"}
size="sm" size="sm"
className="h-8 w-8 p-0" className={`h-8 w-8 p-0 ${pendingWeight < 0 ? "border-destructive bg-destructive/10" : ""}`}
onClick={() => (votedDown ? removeVote() : openVoteDialog("down"))} onClick={() => {
disabled={disabled || isVoting || (!votedDown && maxWeight < 1)} if (votedDown) {
title={votedDown ? "Remove downvote" : "Downvote"} removeVote();
} else if (!hasPending || pendingWeight < 0) {
decrementVote();
}
}}
disabled={disabled || isVoting || (hasVoted && !votedDown)}
title={votedDown ? "Remove downvote" : "Add downvote"}
> >
{isVoting ? ( {isVoting ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
@ -142,79 +181,53 @@ export function VoteButtons({
)} )}
</Button> </Button>
{hasVoted && ( {/* Existing vote display */}
{hasVoted && !hasPending && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
Your vote: {userVote.effectiveWeight > 0 ? "+" : ""} Your vote: {userVote.effectiveWeight > 0 ? "+" : ""}
{userVote.effectiveWeight} {userVote.effectiveWeight}
</span> </span>
)} )}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> {/* Pending vote info and confirm/cancel */}
<DialogContent> {hasPending && (
<DialogHeader> <div className="flex flex-col items-center gap-1 mt-1">
<DialogTitle> <span className="text-xs font-medium">
{voteDirection === "up" ? "Upvote" : "Downvote"} Proposal {pendingWeight > 0 ? "+" : ""}{pendingWeight} vote{Math.abs(pendingWeight) !== 1 ? "s" : ""}
</DialogTitle> </span>
<DialogDescription> <span className="text-xs text-muted-foreground">
Choose your vote weight. Cost increases quadratically (weight² credits). {pendingCost} credit{pendingCost !== 1 ? "s" : ""}
</DialogDescription> </span>
</DialogHeader> <div className="flex gap-1 mt-1">
<Button
<div className="grid gap-4 py-4"> variant="ghost"
<div className="grid grid-cols-4 items-center gap-4"> size="sm"
<Label htmlFor="weight" className="text-right"> className="h-6 w-6 p-0"
Weight onClick={cancelPending}
</Label> title="Cancel"
<Input >
id="weight" <X className="h-3 w-3" />
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
</Button> </Button>
<Button <Button
variant={pendingWeight > 0 ? "default" : "destructive"}
size="sm"
className="h-6 px-2 text-xs"
onClick={submitVote} onClick={submitVote}
disabled={isVoting || voteWeight < 1 || voteCost > availableCredits} disabled={isVoting || !canAfford}
variant={voteDirection === "up" ? "default" : "destructive"} title="Confirm vote"
> >
{isVoting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />} {isVoting ? (
{voteDirection === "up" ? "Upvote" : "Downvote"} ({voteCost} credits) <Loader2 className="h-3 w-3 animate-spin" />
) : (
<>
<Check className="h-3 w-3 mr-1" />
Cast
</>
)}
</Button> </Button>
</DialogFooter> </div>
</DialogContent> </div>
</Dialog> )}
</div> </div>
); );
} }