Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
"use client";

import { Check, Hourglass, PenLine } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type { Address } from "viem";
import { useAccount, useWalletClient } from "wagmi";

import type { ProposalViewData } from "@/features/governance/types";
import { showCustomToast } from "@/features/governance/utils/showCustomToast";
import {
executeProposal,
queueProposal,
type GovernanceAction,
} from "@/features/governance/utils/submitGovernanceAction";
import { Modal } from "@/shared/components/design-system/modal/Modal";
import { SpinIcon } from "@/shared/components/icons/SpinIcon";
import { DividerDefault } from "@/shared/components/design-system/divider/DividerDefault";
import { cn } from "@/shared/utils/cn";
import daoConfigByDaoId from "@/shared/dao-config";
import type { DaoIdEnum } from "@/shared/types/daos";

type ActionStep = "waiting-signature" | "pending-tx" | "success" | "error";

interface GovernanceActionModalProps {
isOpen: boolean;
onClose: () => void;
action: GovernanceAction;
proposal: ProposalViewData;
daoId: DaoIdEnum;
}

export const GovernanceActionModal = ({
isOpen,
onClose,
action,
proposal,
daoId,
}: GovernanceActionModalProps) => {
const [step, setStep] = useState<ActionStep>("waiting-signature");
const [error, setError] = useState<string | null>(null);

const { address } = useAccount();
const chain = daoConfigByDaoId[daoId].daoOverview.chain;
const { data: walletClient } = useWalletClient({ chainId: chain.id });

const title = action === "queue" ? "Confirm Queue" : "Confirm Execution";
const confirmLabel =
action === "queue"
? "Confirm queuing in your wallet"
: "Confirm execution in your wallet";

const handleAction = useCallback(async () => {
if (!address || !walletClient) return;
if (step === "pending-tx" || step === "success") return;

setError(null);
setStep("waiting-signature");

try {
const handler = action === "queue" ? queueProposal : executeProposal;
const calldatas = proposal.calldatas ?? [];
const validIndices = proposal.targets.reduce<number[]>((acc, t, i) => {
if (
t !== null &&
proposal.values[i] !== null &&
(calldatas[i] ?? null) !== null
) {
acc.push(i);
}
return acc;
}, []);
await handler(
validIndices.map((i) => proposal.targets[i] as Address),
validIndices.map((i) => proposal.values[i] as string),
validIndices.map((i) => calldatas[i] as Address),
proposal.description,
address,
daoId,
walletClient,
() => setStep("pending-tx"),
proposal.id,
);
setStep("success");
showCustomToast(
action === "queue"
? "Proposal queued successfully!"
: "Proposal executed successfully!",
"success",
);
onClose();
setTimeout(() => window.location.reload(), 2000);
} catch (err) {
const message =
err instanceof Error
? (err.message.split("\n")[0]?.slice(0, 120) ?? "Action failed.")
: "Action failed.";
setError(message);
setStep("error");
}
}, [address, walletClient, step, action, proposal, daoId, onClose, chain]);

useEffect(() => {
if (!isOpen || step !== "waiting-signature") return;
if (!walletClient) {
setError(`Please switch your wallet to the ${chain.name} network.`);
setStep("error");
return;
}
handleAction();
}, [isOpen, walletClient, handleAction, step, chain.name]);

const handleClose = () => {
setStep("waiting-signature");
setError(null);
onClose();
};

return (
<Modal
open={isOpen}
onOpenChange={(open) => !open && handleClose()}
title={title}
>
{/* Proposal info */}
<div className="flex flex-col gap-2 pb-4">
<div className="flex items-start gap-2">
<span className="text-secondary w-32 shrink-0 text-xs font-medium leading-5">
Proposal ID
</span>
<span className="text-primary text-sm leading-5">{proposal.id}</span>
</div>
<div className="flex items-start gap-2">
<span className="text-secondary w-32 shrink-0 text-xs font-medium leading-5">
Proposal name
</span>
<span className="text-primary text-sm leading-5">
{proposal.title}
</span>
</div>
</div>

{/* Stepper */}
<div className="border-border-default flex flex-col gap-1.5 border p-3">
<StepRow
done={step === "success" || step === "pending-tx"}
active={step === "waiting-signature"}
icon={<PenLine className="size-3.5 text-black" />}
label={confirmLabel}
error={step === "error" ? error : undefined}
/>

<DividerDefault isVertical className="ml-3.5 h-6 w-0.5" />

<StepRow
done={step === "success"}
active={step === "pending-tx"}
icon={<Hourglass className="size-3.5 text-black" />}
label="Wait for transaction to complete"
/>
</div>
</Modal>
);
};

interface StepRowProps {
done: boolean;
active: boolean;
icon: React.ReactNode;
label: string;
error?: string | null;
}

const StepRow = ({ done, active, icon, label, error }: StepRowProps) => {
const getBackgroundColor = () => {
if (done) return "bg-surface-opacity-success";
if (active) return "bg-primary";
return "bg-border-default";
};

return (
<div className="flex w-full flex-col gap-1">
<div className="flex w-full items-center gap-2">
<div className="relative flex size-8 shrink-0 items-center justify-center">
{active && (
<SpinIcon className="absolute inset-0 size-8 animate-spin text-orange-500" />
)}
<div
className={cn(
"flex size-6 shrink-0 items-center justify-center rounded-full",
getBackgroundColor(),
)}
>
<div className="border-border-default flex items-center justify-center rounded-full border p-2">
{done ? <Check className="text-success size-3.5" /> : icon}
</div>
</div>
</div>
<p className="text-primary text-sm leading-5">{label}</p>
</div>

{error && (
<p className="text-error ml-11 break-words text-xs leading-4">
{error}
</p>
)}
</div>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import Link from "next/link";
import { Button, IconButton } from "@/shared/components";
import { DaoAvatarIcon } from "@/shared/components/icons";
import { ConnectWalletCustom } from "@/shared/components/wallet/ConnectWalletCustom";
import type { DaoIdEnum } from "@/shared/types/daos";
import { DaoIdEnum } from "@/shared/types/daos";

interface ProposalHeaderProps {
daoId: string;
setIsVotingModalOpen: (isOpen: boolean) => void;
setIsQueueModalOpen: (isOpen: boolean) => void;
setIsExecuteModalOpen: (isOpen: boolean) => void;
votingPower: string;
votes: GetAccountPowerQuery["votesByProposalId"] | null;
address: string | undefined;
Expand Down Expand Up @@ -68,6 +70,8 @@ export const ProposalHeader = ({
votingPower,
votes,
setIsVotingModalOpen,
setIsQueueModalOpen,
setIsExecuteModalOpen,
address,
proposalStatus,
snapshotLink,
Expand Down Expand Up @@ -148,6 +152,28 @@ export const ProposalHeader = ({
proposalStatus={proposalStatus}
setIsVotingModalOpen={setIsVotingModalOpen}
/>
{address &&
proposalStatus === "succeeded" &&
daoId.toUpperCase() !== DaoIdEnum.SHU && (
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is Shutter different??

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SHU uses Azorius governance which has no queue step, proposals go directly to execute

<Button
className="hidden lg:flex"
onClick={() => setIsQueueModalOpen(true)}
Comment thread
brunod-e marked this conversation as resolved.
>
Queue Proposal
</Button>
)}
{address &&
(proposalStatus === "pending_execution" ||
proposalStatus === "queued" ||
(proposalStatus === "succeeded" &&
daoId.toUpperCase() === DaoIdEnum.SHU)) && (
<Button
className="hidden lg:flex"
onClick={() => setIsExecuteModalOpen(true)}
>
Execute Proposal
</Button>
)}
</>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useParams } from "next/navigation";
import { useState, useCallback, useMemo } from "react";
import { useAccount } from "wagmi";

import { GovernanceActionModal } from "@/features/governance/components/modals/GovernanceActionModal";
import { OffchainVotingModal } from "@/features/governance/components/modals/OffchainVotingModal";
import { VotingModal } from "@/features/governance/components/modals/VotingModal";
import {
Expand Down Expand Up @@ -51,6 +52,8 @@ export const ProposalSection = ({

const { address } = useAccount();
const [isVotingModalOpen, setIsVotingModalOpen] = useState(false);
const [isQueueModalOpen, setIsQueueModalOpen] = useState(false);
const [isExecuteModalOpen, setIsExecuteModalOpen] = useState(false);
const [drawerAddress, setDrawerAddress] = useState<string | null>(null);
const daoEnum = (daoId as string).toUpperCase() as DaoIdEnum;
const { decimals } = daoConfig[daoEnum];
Expand Down Expand Up @@ -133,7 +136,7 @@ export const ProposalSection = ({

const proposal: ProposalViewData | null = isOffchain
? adaptedOffchainProposal
: (onchainProposal ?? null);
: onchainProposal;
const snapshotLink = isOffchain
? (rawOffchainProposal?.link ?? null)
: undefined;
Expand All @@ -160,6 +163,8 @@ export const ProposalSection = ({
<ProposalHeader
daoId={daoId as string}
setIsVotingModalOpen={setIsVotingModalOpen}
setIsQueueModalOpen={setIsQueueModalOpen}
setIsExecuteModalOpen={setIsExecuteModalOpen}
votingPower={votingPower}
votes={votes}
address={address}
Expand Down Expand Up @@ -210,6 +215,22 @@ export const ProposalSection = ({
daoId={daoEnum}
/>

<GovernanceActionModal
isOpen={isQueueModalOpen}
onClose={() => setIsQueueModalOpen(false)}
action="queue"
proposal={proposal}
daoId={daoEnum}
/>

<GovernanceActionModal
isOpen={isExecuteModalOpen}
onClose={() => setIsExecuteModalOpen(false)}
action="execute"
proposal={proposal}
daoId={daoEnum}
/>

<HoldersAndDelegatesDrawer
isOpen={!!drawerAddress}
onClose={handleCloseDrawer}
Expand All @@ -219,7 +240,6 @@ export const ProposalSection = ({
/>
</>
)}

{isOffchain && rawOffchainProposal && (
<OffchainVotingModal
isOpen={isVotingModalOpen}
Expand Down
Loading
Loading