diff --git a/src/components/pages/wallet/governance/ballot/FloatingBallotSidebar.tsx b/src/components/pages/wallet/governance/ballot/FloatingBallotSidebar.tsx new file mode 100644 index 0000000..ded4ab2 --- /dev/null +++ b/src/components/pages/wallet/governance/ballot/FloatingBallotSidebar.tsx @@ -0,0 +1,201 @@ +import React, { useEffect, useState } from "react"; +import BallotCard from "./ballot"; +import type { UTxO } from "@meshsdk/core"; +import { Vote, Minimize2 } from "lucide-react"; + +interface FloatingBallotSidebarProps { + appWallet: any; + selectedBallotId?: string; + onSelectBallot: (id: string) => void; + ballotCount: number; + totalProposalCount: number; + proposalCount: number; + manualUtxos: UTxO[]; + /** + * Optional controlled open state for the sidebar. + * If provided together with onOpenChange, the sidebar becomes controlled. + */ + open?: boolean; + onOpenChange?: (open: boolean) => void; + /** + * Optional current proposal context (used on the proposal page). + * When provided, the ballot card can show contextual UI like + * an \"Add to ballot\" button and highlighting. + */ + currentProposalId?: string; + currentProposalTitle?: string; +} + +export default function FloatingBallotSidebar({ + appWallet, + selectedBallotId, + onSelectBallot, + ballotCount, + totalProposalCount, + proposalCount, + manualUtxos, + open: controlledOpen, + onOpenChange, + currentProposalId, + currentProposalTitle, +}: FloatingBallotSidebarProps) { + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const [isMobile, setIsMobile] = useState(false); + + const isControlled = controlledOpen !== undefined && !!onOpenChange; + const open = isControlled ? controlledOpen : uncontrolledOpen; + + const setOpen = (value: boolean | ((prev: boolean) => boolean)) => { + if (isControlled && onOpenChange) { + const next = typeof value === "function" ? value(open) : value; + onOpenChange(next); + } else { + setUncontrolledOpen(value as boolean); + } + }; + + useEffect(() => { + function handleResize() { + setIsMobile( + typeof window !== "undefined" ? window.innerWidth < 768 : false, + ); + } + handleResize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); + + if (isMobile) { + return ( + <> + + + {open && ( +
+
+ Your Ballots + {proposalCount > 0 && ( + + {proposalCount} + + )} + +
+
+ +
+
+ )} + + ); + } + + return ( +
+
+ + {open && ( +
+ +
+ )} +
+
+ ); +} + + diff --git a/src/components/pages/wallet/governance/ballot/ballot.tsx b/src/components/pages/wallet/governance/ballot/ballot.tsx index ffcb793..79fecdf 100644 --- a/src/components/pages/wallet/governance/ballot/ballot.tsx +++ b/src/components/pages/wallet/governance/ballot/ballot.tsx @@ -21,6 +21,13 @@ import { import { Button } from "@/components/ui/button"; import { api } from "@/utils/api"; import { ToastAction } from "@/components/ui/toast"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { useProxy } from "@/hooks/useProxy"; import { MeshProxyContract } from "@/components/multisig/proxy/offchain"; @@ -44,18 +51,34 @@ export default function BallotCard({ selectedBallotId, onBallotChanged, utxos, + currentProposalId, + currentProposalTitle, }: { appWallet: any; onSelectBallot?: (id: string) => void; selectedBallotId?: string; onBallotChanged?: () => void; utxos: UTxO[]; + /** + * Optional current proposal context from the proposal page. + * When provided, the ballot card can render an "Add to ballot" + * button and highlight that proposal in the table. + */ + currentProposalId?: string; + currentProposalTitle?: string; }) { const [description, setDescription] = useState(""); const [submitting, setSubmitting] = useState(false); const [ballots, setBallots] = useState([]); const [creating, setCreating] = useState(false); + // State for adding the current proposal to a ballot (including move flow) + const [moveModal, setMoveModal] = useState<{ + targetBallotId: string; + conflictBallots: BallotType[]; + } | null>(null); + const [moveLoading, setMoveLoading] = useState(false); + const { toast } = useToast(); // Ballot voting state @@ -94,17 +117,53 @@ export default function BallotCard({ // Delete ballot mutation const deleteBallot = api.ballot.delete.useMutation(); - // Refresh ballots after submit or on load + // Add / remove proposal mutations for managing ballot contents + const addProposalMutation = api.ballot.addProposalToBallot.useMutation(); + const moveRemoveProposalMutation = + api.ballot.removeProposalFromBallot.useMutation(); + + // Refresh ballots after submit or on load and ensure a sensible default is selected React.useEffect(() => { - if (getBallots.data) { - // Sort newest first - const sorted = [...getBallots.data].sort( - (a, b) => - new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), - ); - setBallots(sorted); + if (!getBallots.data) return; + + // Sort newest first + const sorted = [...getBallots.data].sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + setBallots(sorted); + + // If nothing is selected yet (or the selection no longer exists), + // automatically select a sensible default: + // 1. Prefer the ballot that already contains the current proposal (when on a proposal page) + // 2. Otherwise, prefer the newest ballot that already has proposals + // 3. Fallback to the newest ballot + if (!onSelectBallot) return; + + const hasSelected = + selectedBallotId && sorted.some((b) => b.id === selectedBallotId); + + if (!hasSelected) { + let ballotToSelect: BallotType | undefined; + + if (currentProposalId) { + ballotToSelect = sorted.find( + (b) => Array.isArray(b.items) && b.items.includes(currentProposalId), + ); + } + + if (!ballotToSelect) { + ballotToSelect = + sorted.find( + (b) => Array.isArray(b.items) && b.items.length > 0, + ) ?? sorted[0]; + } + + if (!ballotToSelect) return; + + onSelectBallot(ballotToSelect.id); } - }, [getBallots.data]); + }, [getBallots.data, onSelectBallot, selectedBallotId, currentProposalId]); // Proxy ballot vote submission logic @@ -407,11 +466,95 @@ export default function BallotCard({ ? ballots.find((b) => b.id === selectedBallotId) : undefined; + const currentProposalAlreadyInSelected = + !!currentProposalId && + !!selectedBallot && + Array.isArray(selectedBallot.items) && + selectedBallot.items.includes(currentProposalId); + + async function performAddCurrentProposal(targetBallotId: string) { + if (!currentProposalId) return; + await addProposalMutation.mutateAsync({ + ballotId: targetBallotId, + itemDescription: + currentProposalTitle ?? + (selectedBallot?.description ?? "Untitled proposal"), + item: currentProposalId, + // Default to Abstain; user can fine-tune per-proposal choice in the table. + choice: "Abstain", + }); + } + + async function handleAddCurrentProposalToSelectedBallot() { + if (!currentProposalId || !selectedBallotId || !getBallots.data) return; + + // Ensure a proposal can only exist on a single ballot at a time. + const ballotsWithProposal = + getBallots.data.filter( + (b) => + b.id !== selectedBallotId && + Array.isArray(b.items) && + b.items.includes(currentProposalId), + ) ?? []; + + if (ballotsWithProposal.length > 0) { + setMoveModal({ + targetBallotId: selectedBallotId, + conflictBallots: ballotsWithProposal, + }); + return; + } + + await performAddCurrentProposal(selectedBallotId); + toast({ + title: "Added to Ballot", + description: "Proposal successfully added to the ballot.", + duration: 800, + }); + await getBallots.refetch(); + onBallotChanged?.(); + } + + async function confirmMoveCurrentProposal() { + if (!moveModal || !currentProposalId) return; + + try { + setMoveLoading(true); + + // Remove proposal from all other ballots before adding to the selected one + for (const b of moveModal.conflictBallots) { + const index = b.items.findIndex( + (item: string) => item === currentProposalId, + ); + if (index >= 0) { + await moveRemoveProposalMutation.mutateAsync({ + ballotId: b.id, + index, + }); + } + } + + await performAddCurrentProposal(moveModal.targetBallotId); + + toast({ + title: "Proposal moved", + description: + "Proposal was moved from the other ballot to the selected ballot.", + duration: 1500, + }); + await getBallots.refetch(); + onBallotChanged?.(); + } finally { + setMoveLoading(false); + setMoveModal(null); + } + } + return (
@@ -420,19 +563,36 @@ export default function BallotCard({ key={b.id} className={`px-3 py-1 rounded-t-md font-medium transition ${ b.id === selectedBallotId - ? "bg-white text-blue-600 border border-b-0 border-blue-400 dark:bg-gray-900 dark:text-blue-400" - : "bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600" + ? "bg-white text-gray-900 border border-b-0 border-gray-300 shadow-sm dark:bg-slate-900 dark:text-gray-100 dark:border-slate-700 hover:border-gray-400 dark:hover:border-slate-500" + : "bg-white/70 text-gray-700 hover:bg-white dark:bg-slate-800 dark:text-gray-200 dark:hover:bg-slate-700 border border-transparent hover:border-gray-400 dark:hover:border-slate-500" }`} onClick={() => onSelectBallot && onSelectBallot(b.id)} > - {b.description || "Untitled"} + + {b.description || "Untitled"} + - ))} -
{creating && ( @@ -448,7 +608,7 @@ export default function BallotCard({ @@ -462,8 +622,30 @@ export default function BallotCard({ ) : null}
{/* Ballot overview table for selected ballot */} -
- +
+ {/* Contextual 'Add to ballot' button only on proposal pages + (i.e. when currentProposalId is provided). + - If not on this ballot yet: show Add button + - If already on this ballot: show an informational message */} + {selectedBallot && currentProposalId && ( +
+ {!currentProposalAlreadyInSelected ? ( + + ) : ( + + Proposal is on this ballot + + )} +
+ )} + {selectedBallot ? ( Array.isArray(selectedBallot.items) && selectedBallot.items.length > 0 ? ( @@ -472,6 +654,7 @@ export default function BallotCard({ ballotId={selectedBallot.id} refetchBallots={getBallots.refetch} onBallotChanged={onBallotChanged} + currentProposalId={currentProposalId} /> ) : (
No proposals added yet.
@@ -493,16 +676,16 @@ export default function BallotCard({ )}
+ {/* Modal to confirm moving proposal between ballots when adding from a proposal page */} + { + if (!open) setMoveModal(null); + }} + > + + + Move proposal to selected ballot? + + {moveModal && ( + + This proposal is already on ballot{" "} + {moveModal.conflictBallots + .map((b) => b.description || "Untitled ballot") + .join(", ")} + . If you continue, it will be removed from that ballot and + added to the currently selected ballot. + + )} + + +
+ + +
+
+
); } @@ -534,11 +759,13 @@ function BallotOverviewTable({ ballotId, refetchBallots, onBallotChanged, + currentProposalId, }: { ballot: BallotType; ballotId: string; refetchBallots: () => Promise; onBallotChanged?: () => void; + currentProposalId?: string; }) { // Add state for updating and the update mutation const updateChoiceMutation = api.ballot.updateChoice.useMutation(); @@ -560,75 +787,84 @@ function BallotOverviewTable({ return (
- +
- + - + - {ballot.items.map((item: string, idx: number) => ( - - - - - - ))} + {ballot.items.map((item: string, idx: number) => { + const isCurrent = !!currentProposalId && item === currentProposalId; + return ( + + + + + + ); + })}
# TitleChoice / DeleteChoice / Delete
{idx + 1} - {ballot.itemDescriptions?.[idx] || ( - - - )} - -
- - - - - -
-
{idx + 1} + {ballot.itemDescriptions?.[idx] || ( + - + )} + +
+ + + +
+
diff --git a/src/components/pages/wallet/governance/index.tsx b/src/components/pages/wallet/governance/index.tsx index caac526..781a3ee 100644 --- a/src/components/pages/wallet/governance/index.tsx +++ b/src/components/pages/wallet/governance/index.tsx @@ -1,5 +1,4 @@ import CardInfo from "./card-info"; -import { useWalletsStore } from "@/lib/zustand/wallets"; import { useSiteStore } from "@/lib/zustand/site"; import AllProposals from "./proposals"; import useAppWallet from "@/hooks/useAppWallet"; @@ -7,11 +6,10 @@ import VoteCard from "./vote-card"; import ClarityCard from "./clarity/card-clarity"; import VoteCC from "./cCommitee/voteCC"; import UTxOSelector from "../new-transaction/utxoSelector"; -import { UTxO } from "@meshsdk/core"; -import BallotCard from "./ballot/ballot"; -import { useState, useEffect, useMemo } from "react"; +import type { UTxO } from "@meshsdk/core"; +import { useState } from "react"; import { useBallot } from "@/hooks/useBallot"; -import { Vote } from "lucide-react"; +import FloatingBallotSidebar from "./ballot/FloatingBallotSidebar"; export default function PageGovernance() { const { appWallet } = useAppWallet(); @@ -20,9 +18,14 @@ export default function PageGovernance() { const [manualSelected, setManualSelected] = useState(false); const [selectedBallotId, setSelectedBallotId] = useState(undefined); - const { refresh, ballots } = useBallot(appWallet?.id); + const { ballots } = useBallot(appWallet?.id); const selected = ballots?.find((b) => b.id === selectedBallotId); const proposalCount = selected?.items?.length ?? 0; + const totalProposalCount = + ballots?.reduce( + (sum, b) => sum + (Array.isArray(b.items) ? b.items.length : 0), + 0, + ) ?? 0; if (appWallet === undefined) return <>; return ( @@ -71,127 +74,10 @@ export default function PageGovernance() { selectedBallotId={selectedBallotId} onSelectBallot={setSelectedBallotId} ballotCount={ballots?.length ?? 0} + totalProposalCount={totalProposalCount} proposalCount={proposalCount} manualUtxos={manualUtxos} /> ); } - -// FloatingBallotSidebar component -interface FloatingBallotSidebarProps { - appWallet: any; - selectedBallotId?: string; - onSelectBallot: (id: string) => void; - ballotCount: number; - proposalCount: number; - manualUtxos: UTxO[]; -} - -function FloatingBallotSidebar({ - appWallet, - selectedBallotId, - onSelectBallot, - ballotCount, - proposalCount, - manualUtxos -}: FloatingBallotSidebarProps) { - const [open, setOpen] = useState(false); - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - function handleResize() { - setIsMobile(typeof window !== "undefined" ? window.innerWidth < 768 : false); - } - handleResize(); - window.addEventListener("resize", handleResize); - return () => window.removeEventListener("resize", handleResize); - }, []); - - if (isMobile) { - return ( - <> - - - {open && ( -
-
- Your Ballots - {proposalCount > 0 && ( - - {proposalCount} - - )} - -
-
- -
-
- )} - - ); - } - - return ( -
-
- - {open && ( -
- -
- ) } -
-
- ); -} diff --git a/src/components/pages/wallet/governance/proposal/index.tsx b/src/components/pages/wallet/governance/proposal/index.tsx index 64e0358..c18c2c1 100644 --- a/src/components/pages/wallet/governance/proposal/index.tsx +++ b/src/components/pages/wallet/governance/proposal/index.tsx @@ -2,47 +2,52 @@ import CardUI from "@/components/ui/card-content"; import { getProvider } from "@/utils/get-provider"; import RowLabelInfo from "@/components/common/row-label-info"; import { useSiteStore } from "@/lib/zustand/site"; -import { ProposalMetadata } from "@/types/governance"; +import type { ProposalMetadata } from "@/types/governance"; import { useEffect, useState } from "react"; import Link from "next/link"; import Button from "@/components/common/button"; -import { useWalletsStore } from "@/lib/zustand/wallets"; import useAppWallet from "@/hooks/useAppWallet"; import VoteCard from "../vote-card"; -import { UTxO } from "@meshsdk/core"; +import type { UTxO } from "@meshsdk/core"; import UTxOSelector from "../../new-transaction/utxoSelector"; -import BallotCard from "../ballot/ballot"; +import FloatingBallotSidebar from "../ballot/FloatingBallotSidebar"; +import { useBallot } from "@/hooks/useBallot"; -export default function WalletGovernanceProposal({ - id, -}: { - id: string; -}) { +export default function WalletGovernanceProposal({ id }: { id: string }) { const network = useSiteStore((state) => state.network); const [proposalMetadata, setProposalMetadata] = useState< ProposalMetadata | undefined >(undefined); - const drepInfo = useWalletsStore((state) => state.drepInfo); const { appWallet } = useAppWallet(); - const loading = useSiteStore((state) => state.loading); const [manualUtxos, setManualUtxos] = useState([]); - const [manualSelected, setManualSelected] = useState(false); - const [selectedBallotId, setSelectedBallotId] = useState(undefined); + const [selectedBallotId, setSelectedBallotId] = useState( + undefined, + ); + const [isBallotSidebarOpen, setIsBallotSidebarOpen] = useState(false); + + const { ballots } = useBallot(appWallet?.id); + const selected = ballots?.find((b) => b.id === selectedBallotId); + const proposalCount = selected?.items?.length ?? 0; + const totalProposalCount = + ballots?.reduce( + (sum, b) => sum + (Array.isArray(b.items) ? b.items.length : 0), + 0, + ) ?? 0; useEffect(() => { const blockchainProvider = getProvider(network); async function get() { const [txHash, certIndex] = id.split(":"); - const proposalData = await blockchainProvider.get( + const proposalData = (await blockchainProvider.get( `/governance/proposals/${txHash}/${certIndex}/metadata`, - ); + )) as ProposalMetadata; if (proposalData) { setProposalMetadata(proposalData); } } - get(); - }, []); + void get(); + }, [id, network]); if (!proposalMetadata) return <>; @@ -76,8 +81,8 @@ export default function WalletGovernanceProposal({ > author.name) + value={(proposalMetadata.json_metadata.authors as { name: string }[]) + .map((author) => author.name) .join(", ")} allowOverflow={true} /> @@ -104,26 +109,34 @@ export default function WalletGovernanceProposal({ { - setManualUtxos(utxos); - setManualSelected(manual); - }} + onSelectionChange={(utxos) => { + setManualUtxos(utxos); + }} /> )} {appWallet && ( - setSelectedBallotId(ballotId)} + proposalId={`${proposalMetadata.tx_hash}#${proposalMetadata.cert_index}`} + selectedBallotId={selectedBallotId} + proposalTitle={proposalMetadata.json_metadata.body.title} + onOpenBallotSidebar={() => setIsBallotSidebarOpen(true)} /> )} {appWallet && ( - )} diff --git a/src/components/pages/wallet/governance/proposal/voteButtton.tsx b/src/components/pages/wallet/governance/proposal/voteButtton.tsx index 8c99ab2..9b3d2f5 100644 --- a/src/components/pages/wallet/governance/proposal/voteButtton.tsx +++ b/src/components/pages/wallet/governance/proposal/voteButtton.tsx @@ -18,11 +18,19 @@ import { import { ToastAction } from "@/components/ui/toast"; import useMultisigWallet from "@/hooks/useMultisigWallet"; import { api } from "@/utils/api"; -import {useBallot} from "@/hooks/useBallot"; +import { useBallot } from "@/hooks/useBallot"; import { useProxy } from "@/hooks/useProxy"; import { MeshProxyContract } from "@/components/multisig/proxy/offchain"; import { useWallet } from "@meshsdk/react"; import { useUserStore } from "@/lib/zustand/user"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { BallotType } from "../ballot/ballot"; interface VoteButtonProps { appWallet: Wallet; @@ -32,6 +40,12 @@ interface VoteButtonProps { utxos: UTxO[]; selectedBallotId?: string; proposalTitle?: string; + /** + * Optional handler from the proposal page to open the ballot sidebar. + * When provided, the \"Add proposal to ballot\" button will simply + * open the ballot card instead of mutating ballots directly. + */ + onOpenBallotSidebar?: () => void; } export default function VoteButton({ @@ -42,27 +56,21 @@ export default function VoteButton({ utxos, selectedBallotId, proposalTitle, + onOpenBallotSidebar, }: VoteButtonProps) { - // Use the custom hook for ballots - const { ballots, refresh } = useBallot(appWallet?.id); - const selectedBallot = useMemo(() => { - return ballots?.find((b) => b.id === selectedBallotId); - }, [ballots, selectedBallotId]); - - const proposalIndex = selectedBallot?.items.findIndex((item) => item === proposalId); - const isInBallot = proposalIndex !== undefined && proposalIndex >= 0; - - const addProposalMutation = api.ballot.addProposalToBallot.useMutation({ - onSuccess: () => { - refresh(); - }, - }); + // Use the custom hook for ballots (still used for proxy / context where needed) + const { ballots } = useBallot(appWallet?.id); - const removeProposalMutation = api.ballot.removeProposalFromBallot.useMutation({ - onSuccess: () => { - refresh(); - }, - }); + // Determine if this proposal already exists on any ballot + const isOnAnyBallot = useMemo( + () => + !!proposalId && + Array.isArray(ballots) && + ballots.some( + (b: BallotType) => Array.isArray(b.items) && b.items.includes(proposalId), + ), + [ballots, proposalId], + ); const drepInfo = useWalletsStore((state) => state.drepInfo); const [loading, setLoading] = useState(false); @@ -315,52 +323,6 @@ export default function VoteButton({ } } - async function addProposalToBallot() { - if (!selectedBallotId) return; - try { - await addProposalMutation.mutateAsync({ - ballotId: selectedBallotId, - itemDescription: proposalTitle ?? description, - item: proposalId, - choice: voteKind, - }); - toast({ - title: "Added to Ballot", - description: "Proposal successfully added to the ballot.", - duration: 500, - }); - } catch (error) { - toast({ - title: "Failed to Add to Ballot", - description: `Error: ${error}`, - duration: 10000, - variant: "destructive", - }); - } - } - - async function removeProposalFromBallot() { - if (!selectedBallotId || proposalIndex === undefined || proposalIndex < 0) return; - try { - await removeProposalMutation.mutateAsync({ - ballotId: selectedBallotId, - index: proposalIndex, - }); - toast({ - title: "Removed from Ballot", - description: "Proposal successfully removed from the ballot.", - duration: 500, - }); - } catch (error) { - toast({ - title: "Failed to Remove from Ballot", - description: `Error: ${error}`, - duration: 10000, - variant: "destructive", - }); - } - } - return (