11"use client" ;
22
3- import { useMemo , useState } from "react" ;
3+ import { useCallback , useEffect , useMemo , useState } from "react" ;
44import { ArrowLeft , ExternalLink , Minus , Plus } from "lucide-react" ;
55import { useActiveAccount } from "thirdweb/react" ;
66import { Button } from "@/components/ui/button" ;
77import { createRoundActionMessage } from "@/features/rounds/signature" ;
88import { getRoundState , getRoundStateLabel } from "@/features/rounds/state" ;
9- import type { RoundSubmission , RoundWithSubmissions } from "@/features/rounds/types" ;
9+ import type { RoundState , RoundSubmission , RoundWithSubmissions } from "@/features/rounds/types" ;
1010import { useUserAddress } from "@/hooks/use-user-address" ;
11- import { Link } from "@/i18n/navigation" ;
11+ import { Link , useRouter } from "@/i18n/navigation" ;
1212import { cn } from "@/lib/utils" ;
1313import { RoundStatusPill } from "./RoundStatusPill" ;
1414import { RoundTimeline } from "./RoundTimeline" ;
1515
16+ type RoundVotingPower = {
17+ walletAddress : string ;
18+ votingPower : number ;
19+ usedVotes : number ;
20+ remainingVotes : number ;
21+ } ;
22+
1623export function RoundDetailView ( {
1724 round,
1825 databaseConfigured,
@@ -23,16 +30,70 @@ export function RoundDetailView({
2330 const state = getRoundState ( round ) ;
2431 const { address, isConnected } = useUserAddress ( ) ;
2532 const account = useActiveAccount ( ) ;
33+ const router = useRouter ( ) ;
2634 const [ allocations , setAllocations ] = useState < Record < string , number > > ( { } ) ;
2735 const [ message , setMessage ] = useState ( "" ) ;
2836 const [ isVoting , setIsVoting ] = useState ( false ) ;
29- const votingPower = state === "voting_open" && isConnected ? round . votesPerWallet : 0 ;
37+ const [ votingPowerStatus , setVotingPowerStatus ] = useState < RoundVotingPower | null > ( null ) ;
38+ const [ isLoadingVotingPower , setIsLoadingVotingPower ] = useState ( false ) ;
39+ const [ votingPowerError , setVotingPowerError ] = useState ( "" ) ;
40+ const votingPower = state === "voting_open" && isConnected ? votingPowerStatus ?. votingPower || 0 : 0 ;
41+ const remainingVotes =
42+ state === "voting_open" && isConnected ? votingPowerStatus ?. remainingVotes || 0 : 0 ;
3043 const allocatedVotes = useMemo (
3144 ( ) => Object . values ( allocations ) . reduce ( ( total , count ) => total + count , 0 ) ,
3245 [ allocations ] ,
3346 ) ;
47+ const availableVotes = Math . max ( remainingVotes - allocatedVotes , 0 ) ;
48+ const showVotingControls = canShowRoundVotingControls ( { state, votingPower } ) ;
49+ const votingStatusMessage = getVotingStatusMessage ( {
50+ isConnected,
51+ isLoadingVotingPower,
52+ votingPowerError,
53+ votingPower,
54+ availableVotes,
55+ remainingVotes,
56+ } ) ;
3457 const winners = state === "ended" ? round . submissions . slice ( 0 , round . winnerCount ) : [ ] ;
3558
59+ const fetchVotingPower = useCallback ( async ( ) => {
60+ if ( state !== "voting_open" || ! isConnected || ! address ) {
61+ setVotingPowerStatus ( null ) ;
62+ setVotingPowerError ( "" ) ;
63+ return ;
64+ }
65+
66+ setIsLoadingVotingPower ( true ) ;
67+ setVotingPowerError ( "" ) ;
68+
69+ try {
70+ const response = await fetch (
71+ `/api/rounds/${ round . slug } /voting-power?wallet=${ encodeURIComponent ( address ) } ` ,
72+ ) ;
73+ const result = await response . json ( ) ;
74+ if ( ! response . ok ) throw new Error ( result . error || "Unable to load voting power." ) ;
75+
76+ setVotingPowerStatus ( result as RoundVotingPower ) ;
77+ } catch ( error ) {
78+ setVotingPowerStatus ( null ) ;
79+ setVotingPowerError ( error instanceof Error ? error . message : "Unable to load voting power." ) ;
80+ } finally {
81+ setIsLoadingVotingPower ( false ) ;
82+ }
83+ } , [
84+ address ,
85+ isConnected ,
86+ round . slug ,
87+ setIsLoadingVotingPower ,
88+ setVotingPowerError ,
89+ setVotingPowerStatus ,
90+ state ,
91+ ] ) ;
92+
93+ useEffect ( ( ) => {
94+ void fetchVotingPower ( ) ;
95+ } , [ fetchVotingPower ] ) ;
96+
3697 const updateAllocation = ( submissionId : string , delta : number ) => {
3798 setMessage ( "" ) ;
3899 setAllocations ( ( current ) => {
@@ -44,13 +105,13 @@ export function RoundDetailView({
44105 ) ;
45106 return {
46107 ...current ,
47- [ submissionId ] : Math . min ( next , Math . max ( votingPower - totalWithoutCurrent , 0 ) ) ,
108+ [ submissionId ] : Math . min ( next , Math . max ( remainingVotes - totalWithoutCurrent , 0 ) ) ,
48109 } ;
49110 } ) ;
50111 } ;
51112
52113 const submitVotes = async ( ) => {
53- if ( ! address || ! account || allocatedVotes <= 0 ) return ;
114+ if ( ! address || ! account || allocatedVotes <= 0 || allocatedVotes > remainingVotes ) return ;
54115
55116 setIsVoting ( true ) ;
56117 setMessage ( "" ) ;
@@ -83,7 +144,9 @@ export function RoundDetailView({
83144 if ( ! response . ok ) throw new Error ( result . error || "Unable to submit votes." ) ;
84145
85146 setAllocations ( { } ) ;
86- setMessage ( "Votes submitted. Refreshing will show the updated totals." ) ;
147+ setMessage ( "Votes submitted." ) ;
148+ await fetchVotingPower ( ) ;
149+ router . refresh ( ) ;
87150 } catch ( error ) {
88151 setMessage ( error instanceof Error ? error . message : "Unable to submit votes." ) ;
89152 } finally {
@@ -190,15 +253,19 @@ export function RoundDetailView({
190253 < div className = "flex flex-col gap-3 md:flex-row md:items-center md:justify-between" >
191254 < div >
192255 < h2 className = "text-xl font-semibold tracking-tight" > Voting</ h2 >
193- < p className = "mt-1 text-sm text-muted-foreground" >
194- { isConnected
195- ? `${ Math . max ( votingPower - allocatedVotes , 0 ) } of ${ votingPower } votes remaining`
196- : "Connect your wallet to vote." }
197- </ p >
256+ < p className = "mt-1 text-sm text-muted-foreground" > { votingStatusMessage } </ p >
198257 </ div >
199258 < Button
200259 onClick = { submitVotes }
201- disabled = { ! databaseConfigured || ! isConnected || allocatedVotes <= 0 || isVoting }
260+ disabled = {
261+ ! databaseConfigured ||
262+ ! isConnected ||
263+ ! showVotingControls ||
264+ allocatedVotes <= 0 ||
265+ allocatedVotes > remainingVotes ||
266+ isVoting ||
267+ isLoadingVotingPower
268+ }
202269 >
203270 { isVoting ? "Submitting..." : "Submit votes" }
204271 </ Button >
@@ -221,10 +288,11 @@ export function RoundDetailView({
221288 key = { submission . id }
222289 submission = { submission }
223290 rank = { index + 1 }
224- showVoting = { state === "voting_open" && votingPower > 0 }
291+ showVoting = { showVotingControls }
225292 allocation = { allocations [ submission . id ] || 0 }
226293 onMinus = { ( ) => updateAllocation ( submission . id , - 1 ) }
227294 onPlus = { ( ) => updateAllocation ( submission . id , 1 ) }
295+ disablePlus = { availableVotes <= 0 }
228296 />
229297 ) ) }
230298 </ div >
@@ -285,13 +353,15 @@ function SubmissionCard({
285353 allocation,
286354 onMinus,
287355 onPlus,
356+ disablePlus,
288357} : {
289358 submission : RoundSubmission ;
290359 rank : number ;
291360 showVoting : boolean ;
292361 allocation : number ;
293362 onMinus : ( ) => void ;
294363 onPlus : ( ) => void ;
364+ disablePlus ?: boolean ;
295365} ) {
296366 return (
297367 < article
@@ -357,7 +427,7 @@ function SubmissionCard({
357427 < span className = "w-6 text-center text-sm font-semibold tabular-nums" >
358428 { allocation }
359429 </ span >
360- < Button size = "icon-sm" variant = "outline" onClick = { onPlus } >
430+ < Button size = "icon-sm" variant = "outline" onClick = { onPlus } disabled = { disablePlus } >
361431 < Plus className = "size-3.5" />
362432 </ Button >
363433 </ div >
@@ -373,3 +443,38 @@ function getVotingStrategyLabel(round: RoundWithSubmissions) {
373443 if ( round . votingStrategy === "one_per_nft" ) return "1 vote per Gnars NFT" ;
374444 return `${ round . votesPerWallet } votes per wallet` ;
375445}
446+
447+ export function canShowRoundVotingControls ( {
448+ state,
449+ votingPower,
450+ } : {
451+ state : RoundState ;
452+ votingPower : number ;
453+ } ) {
454+ return state === "voting_open" && votingPower > 0 ;
455+ }
456+
457+ function getVotingStatusMessage ( {
458+ isConnected,
459+ isLoadingVotingPower,
460+ votingPowerError,
461+ votingPower,
462+ availableVotes,
463+ remainingVotes,
464+ } : {
465+ isConnected : boolean ;
466+ isLoadingVotingPower : boolean ;
467+ votingPowerError : string ;
468+ votingPower : number ;
469+ availableVotes : number ;
470+ remainingVotes : number ;
471+ } ) {
472+ if ( ! isConnected ) return "Connect your wallet to vote." ;
473+ if ( isLoadingVotingPower ) return "Checking delegated Gnars voting power..." ;
474+ if ( votingPowerError ) return votingPowerError ;
475+ if ( votingPower <= 0 ) {
476+ return "This wallet needs at least 1 delegated Gnars DAO vote to vote in this round." ;
477+ }
478+
479+ return `${ availableVotes } of ${ remainingVotes } votes remaining` ;
480+ }
0 commit comments