Fix demo page voting - use Button components and proper flex layout

- Replace raw <button> elements with Button components for proper hydration
- Use plain div instead of Card for proposal items to avoid flex-col conflicts
- Simplify vote handlers into handleUpvote/handleDownvote functions
- Reddit-style layout: vote column (72px) on left with border separator
- Orange upvotes, blue downvotes with proper hover/active states

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-05 22:34:54 +00:00
parent eebd6a4349
commit bc56e5bdb6
1 changed files with 171 additions and 198 deletions

View File

@ -4,7 +4,6 @@ import { useState } from "react";
import { Button } from "@/components/ui/button"; 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 Link from "next/link"; import Link from "next/link";
import { import {
ChevronUp, ChevronUp,
@ -95,30 +94,52 @@ export default function DemoPage() {
const maxWeight = Math.floor(Math.sqrt(credits)); const maxWeight = Math.floor(Math.sqrt(credits));
function incrementVote(proposalId: number) { function handleUpvote(proposalId: number) {
setProposals((prev) => setProposals((prev) =>
prev.map((p) => { prev.map((p) => {
if (p.id === proposalId) { if (p.id !== proposalId) return p;
const newPending = p.pendingVote + 1;
const newCost = newPending * newPending; // If already voted up, remove the vote
if (newCost <= credits) { if (p.userVote > 0) {
return { ...p, pendingVote: newPending }; 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; return p;
}) })
); );
} }
function decrementVote(proposalId: number) { function handleDownvote(proposalId: number) {
setProposals((prev) => setProposals((prev) =>
prev.map((p) => { prev.map((p) => {
if (p.id === proposalId) { if (p.id !== proposalId) return p;
const newPending = p.pendingVote - 1;
const newCost = newPending * newPending; // If already voted down, remove the vote
if (newCost <= credits) { if (p.userVote < 0) {
return { ...p, pendingVote: newPending }; 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; return p;
}) })
@ -131,44 +152,26 @@ export default function DemoPage() {
); );
} }
function castVote(proposalId: number) { function confirmVote(proposalId: number) {
setProposals((prev) => setProposals((prev) =>
prev.map((p) => { prev.map((p) => {
if (p.id === proposalId && p.pendingVote !== 0) { 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); const cost = p.pendingVote * p.pendingVote;
const newScore = p.score + p.pendingVote;
const promoted = newScore >= 100 && p.stage === "ranking";
return { setCredits((c) => c - cost);
...p,
score: newScore,
userVote: p.pendingVote,
pendingVote: 0,
stage: promoted ? "voting" : p.stage,
yesVotes: promoted ? 8 : p.yesVotes,
noVotes: promoted ? 3 : p.noVotes,
};
}
return p;
})
);
}
function removeVote(proposalId: number) { return {
setProposals((prev) => ...p,
prev.map((p) => { score: newScore,
if (p.id === proposalId && p.userVote !== 0) { userVote: p.pendingVote,
const refund = p.userVote * p.userVote; pendingVote: 0,
setCredits((c) => c + refund); stage: promoted ? "voting" : p.stage,
return { yesVotes: promoted ? 8 : p.yesVotes,
...p, noVotes: promoted ? 3 : p.noVotes,
score: p.score - p.userVote, };
userVote: 0,
};
}
return p;
}) })
); );
} }
@ -255,7 +258,7 @@ export default function DemoPage() {
: "bg-muted/50 border-muted text-muted-foreground" : "bg-muted/50 border-muted text-muted-foreground"
}`} }`}
> >
<div className="font-bold text-xl">{w > 0 ? "+" : ""}{w}</div> <div className="font-bold text-xl">+{w}</div>
<div className="text-xs opacity-70">vote{w > 1 ? "s" : ""}</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 className="font-mono text-sm mt-1 font-semibold">{w * w}¢</div>
</div> </div>
@ -279,163 +282,135 @@ export default function DemoPage() {
const hasPending = proposal.pendingVote !== 0; const hasPending = proposal.pendingVote !== 0;
const hasVoted = proposal.userVote !== 0; const hasVoted = proposal.userVote !== 0;
const pendingCost = proposal.pendingVote * proposal.pendingVote; const pendingCost = proposal.pendingVote * proposal.pendingVote;
const previewScore = proposal.score + proposal.pendingVote; const displayScore = hasPending ? proposal.score + proposal.pendingVote : proposal.score;
const progressPercent = Math.min((proposal.score / 100) * 100, 100); const progressPercent = Math.min((displayScore / 100) * 100, 100);
const previewPercent = Math.min((previewScore / 100) * 100, 100);
const isUpvoted = (hasVoted && proposal.userVote > 0) || proposal.pendingVote > 0;
const isDownvoted = (hasVoted && proposal.userVote < 0) || proposal.pendingVote < 0;
return ( return (
<Card <div
key={proposal.id} key={proposal.id}
className={`transition-all duration-200 overflow-hidden ${ className={`flex rounded-xl border bg-card shadow-sm overflow-hidden transition-all duration-200 ${
hasPending hasPending
? proposal.pendingVote > 0 ? proposal.pendingVote > 0
? "ring-2 ring-orange-500/50 bg-orange-500/5" ? "ring-2 ring-orange-500/50"
: "ring-2 ring-blue-500/50 bg-blue-500/5" : "ring-2 ring-blue-500/50"
: hasVoted : ""
? proposal.userVote > 0
? "bg-orange-500/5"
: "bg-blue-500/5"
: ""
}`} }`}
> >
<div className="flex"> {/* Reddit-style vote column */}
{/* 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]">
<div className="flex flex-col items-center justify-center py-4 px-3 bg-muted/40 min-w-[70px] gap-1"> <Button
{/* Upvote button */} variant="ghost"
<button size="sm"
onClick={() => { className={`h-10 w-10 p-0 rounded-md transition-all ${
if (hasVoted && proposal.userVote > 0) { isUpvoted
removeVote(proposal.id); ? "text-orange-500 bg-orange-500/10 hover:bg-orange-500/20"
} else if (!hasVoted || proposal.pendingVote >= 0) { : "text-muted-foreground hover:text-orange-500 hover:bg-orange-500/10"
incrementVote(proposal.id); } ${hasVoted && proposal.userVote < 0 ? "opacity-30 pointer-events-none" : ""}`}
} onClick={() => handleUpvote(proposal.id)}
}} >
disabled={hasVoted && proposal.userVote < 0} <ChevronUp className="h-7 w-7" strokeWidth={2.5} />
className={`p-1 rounded transition-all hover:scale-110 ${ </Button>
(hasVoted && proposal.userVote > 0) || proposal.pendingVote > 0
? "text-orange-500"
: "text-muted-foreground hover:text-orange-500"
} ${hasVoted && proposal.userVote < 0 ? "opacity-30 cursor-not-allowed" : "cursor-pointer"}`}
>
<ChevronUp className="h-8 w-8" strokeWidth={3} />
</button>
{/* Score display */} <span className={`font-bold text-xl tabular-nums py-1 ${
<div className={`font-bold text-xl tabular-nums min-w-[3ch] text-center ${ isUpvoted ? "text-orange-500" : isDownvoted ? "text-blue-500" : "text-foreground"
hasPending }`}>
? proposal.pendingVote > 0 {displayScore}
? "text-orange-500" </span>
: "text-blue-500"
: hasVoted
? proposal.userVote > 0
? "text-orange-500"
: "text-blue-500"
: "text-foreground"
}`}>
{hasPending ? previewScore : proposal.score}
</div>
{/* Downvote button */} <Button
<button variant="ghost"
onClick={() => { size="sm"
if (hasVoted && proposal.userVote < 0) { className={`h-10 w-10 p-0 rounded-md transition-all ${
removeVote(proposal.id); isDownvoted
} else if (!hasVoted || proposal.pendingVote <= 0) { ? "text-blue-500 bg-blue-500/10 hover:bg-blue-500/20"
decrementVote(proposal.id); : "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)}
disabled={hasVoted && proposal.userVote > 0} >
className={`p-1 rounded transition-all hover:scale-110 ${ <ChevronDown className="h-7 w-7" strokeWidth={2.5} />
(hasVoted && proposal.userVote < 0) || proposal.pendingVote < 0 </Button>
? "text-blue-500"
: "text-muted-foreground hover:text-blue-500"
} ${hasVoted && proposal.userVote > 0 ? "opacity-30 cursor-not-allowed" : "cursor-pointer"}`}
>
<ChevronDown className="h-8 w-8" strokeWidth={3} />
</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={hasPending ? (proposal.pendingVote > 0 ? "text-orange-500" : "text-blue-500") : ""}>
{hasPending ? previewScore : proposal.score}/100
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${
hasPending
? proposal.pendingVote > 0
? "bg-orange-500"
: "bg-blue-500"
: "bg-primary"
}`}
style={{ width: `${hasPending ? previewPercent : progressPercent}%` }}
/>
</div>
</div>
{/* Vote status / pending confirmation */}
{(hasPending || hasVoted) && (
<div className="mt-3 flex items-center gap-3">
{hasPending ? (
<>
<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 text-muted-foreground hover:text-foreground"
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={() => castVote(proposal.id)}
>
<Check className="h-4 w-4 mr-1" />
Confirm
</Button>
</>
) : hasVoted && (
<Badge
variant="secondary"
className={proposal.userVote > 0
? "bg-orange-500/20 text-orange-600 border-orange-500/30"
: "bg-blue-500/20 text-blue-600 border-blue-500/30"
}
>
You voted: {proposal.userVote > 0 ? "+" : ""}{proposal.userVote}
</Badge>
)}
</div>
)}
</div>
</div> </div>
</Card>
{/* 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> </div>
@ -468,7 +443,6 @@ export default function DemoPage() {
<CardDescription>{proposal.description}</CardDescription> <CardDescription>{proposal.description}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Vote bar */}
<div className="space-y-2"> <div className="space-y-2">
<div className="h-4 rounded-full overflow-hidden bg-muted flex"> <div className="h-4 rounded-full overflow-hidden bg-muted flex">
<div <div
@ -490,7 +464,6 @@ export default function DemoPage() {
</div> </div>
</div> </div>
{/* Vote buttons */}
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<Button <Button
variant="outline" variant="outline"