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:
parent
1a5bcc6266
commit
5b0f6f1bf1
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue