diff --git a/apps/web/src/app/api/dao/[daoId]/auctions/route.ts b/apps/web/src/app/api/dao/[daoId]/auctions/route.ts index 408237e..4be8c38 100644 --- a/apps/web/src/app/api/dao/[daoId]/auctions/route.ts +++ b/apps/web/src/app/api/dao/[daoId]/auctions/route.ts @@ -35,6 +35,8 @@ export async function GET(_request: Request, { params }: { params: Promise<{ dao const auction = jsonValue(auctionTx.result) as { token_id?: string } | null; const paused = Boolean(pausedTx.result); if (!auction || typeof auction.token_id === 'undefined') { + // No auction token exists yet. Determine if we've never launched or if we paused after launching. + // If there's auction history, we've been paused. Otherwise, we've never launched. return NextResponse.json({ status: paused && history.length > 0 ? 'paused' : 'not-launched', auction: null, diff --git a/apps/web/src/app/dao/[daoId]/admin/auction/page.tsx b/apps/web/src/app/dao/[daoId]/admin/auction/page.tsx index 0553ea5..9d91358 100644 --- a/apps/web/src/app/dao/[daoId]/admin/auction/page.tsx +++ b/apps/web/src/app/dao/[daoId]/admin/auction/page.tsx @@ -9,6 +9,7 @@ import useSWR from 'swr'; import { AdminPaymentTokenForm, AdminReservePriceForm } from '@/components/admin/admin-action-forms'; import { AdminProposalDraftDialog } from '@/components/admin/admin-proposal-draft-dialog'; import { AdminSectionNav } from '@/components/admin/admin-section-nav'; +import { type AuctionAutoPauseAction, AuctionAutoPauseDialog } from '@/components/admin/auction-auto-pause-dialog'; import { PageSection } from '@/components/page-section'; import { Badge, Button, Callout, Card, Heading, ShortId, Skeleton, Text } from '@/components/ui'; import { useDaoContext } from '@/contexts/dao-context'; @@ -20,17 +21,26 @@ import { useGoldskyMintAuthorities } from '@/lib/goldsky-queries'; import { getActionHandler } from '@/lib/proposal-actions/registry'; import { waitForConfirmation } from '@/lib/transaction-confirmation'; import { useTransactionFeedback } from '@/lib/transaction-feedback'; +import { useAdminDraftStatus } from '@/lib/use-admin-draft-status'; import { useAdminProposalDraft } from '@/lib/use-admin-proposal-draft'; import { getStellarAddressError, isValidStellarAddress } from '@/lib/validation'; import { useDaoSessionStore } from '@/stores/dao-session-store'; -type AuctionStatus = { paused: boolean; config: { reserve_price: string; payment_token: string | null } }; +type AuctionStatus = { + status: 'not-launched' | 'paused' | 'active'; + paused: boolean; + config: { reserve_price: string; payment_token: string | null }; +}; const fetcher = async (url: string): Promise => { const response = await fetch(url, { cache: 'no-store' }); const payload = await response.json(); if (!response.ok) throw new Error(payload.message || 'Auction status unavailable'); - return { paused: Boolean(payload.paused), config: payload.config }; + return { + status: payload.status || (payload.paused ? 'paused' : 'active'), + paused: Boolean(payload.paused), + config: payload.config + }; }; export default function AuctionAdminPage() { @@ -41,7 +51,15 @@ export default function AuctionAdminPage() { const [formMessage, setFormMessage] = useState(''); const [reservePrice, setReservePrice] = useState(''); const [paymentToken, setPaymentToken] = useState(''); + const [autoPauseDialogOpen, setAutoPauseDialogOpen] = useState(false); + const [autoPauseAction, setAutoPauseAction] = useState(null); const proposalDraft = useAdminProposalDraft(); + const draftStatus = useAdminDraftStatus(daoId, [ + 'set-auction-reserve-price', + 'set-auction-payment-token', + 'pause-auction', + 'unpause-auction' + ]); const { data, error, mutate, isLoading } = useSWR( `/api/dao/${encodeURIComponent(daoId)}/auctions`, fetcher @@ -56,25 +74,35 @@ export default function AuctionAdminPage() { async function updatePaused(nextPaused: boolean) { if (!session.address || (!isOwner && !canProposeAuction)) return; + + // Determine action type and messaging based on auction status + const isLaunching = data?.status === 'not-launched' && !nextPaused; + const actionType = nextPaused ? 'pause-auction' : 'unpause-auction'; + const actionTitle = isLaunching ? 'Launch auctions' : nextPaused ? 'Pause auctions' : 'Resume auctions'; + const actionDescription = isLaunching + ? 'Launch auctions and create the first token.' + : nextPaused + ? 'Pause auction activity.' + : 'Resume auction activity.'; + if (!isOwner && canProposeAuction) { - const type = nextPaused ? 'pause-auction' : 'unpause-auction'; - const handler = getActionHandler(type); + const handler = getActionHandler(actionType); const action = handler.serialize({}, { config, session: { address: session.address, kit: StellarWalletsKit } }); proposalDraft.requestAdd({ daoId, action, - source: `admin/auction/${type}`, + source: `admin/auction/${actionType}`, metadata: { - title: nextPaused ? 'Pause auctions' : 'Resume auctions', - description: nextPaused ? 'Pause auction activity.' : 'Resume auction activity.', + title: actionTitle, + description: actionDescription, url: '' }, - onAdded: () => setFormMessage(`${nextPaused ? 'Pause' : 'Resume'} auctions added to the proposal draft.`) + onAdded: () => setFormMessage(`${actionTitle} added to the proposal draft.`) }); return; } setBusy(true); - tx.start(nextPaused ? 'Pausing auctions...' : 'Resuming auctions...'); + tx.start(isLaunching ? 'Launching auctions...' : nextPaused ? 'Pausing auctions...' : 'Resuming auctions...'); try { const client = new AuctionClient({ contractId: config.auctionContractId, @@ -92,9 +120,15 @@ export default function AuctionAdminPage() { : await client.unpause({ caller: session.address }); const sent = await assembled.signAndSend(); const hash = sent.sendTransactionResponse?.hash ?? ''; - tx.submitted(nextPaused ? 'Auction pause submitted' : 'Auction resume submitted', hash); + const submittedMessage = nextPaused + ? 'Auction pause submitted' + : isLaunching + ? 'Auction launch submitted' + : 'Auction resume submitted'; + const successMessage = nextPaused ? 'Auctions paused' : isLaunching ? 'Auctions launched' : 'Auctions resumed'; + tx.submitted(submittedMessage, hash); await waitForConfirmation(hash, config.rpcUrl); - tx.success(nextPaused ? 'Auctions paused' : 'Auctions resumed', hash); + tx.success(successMessage, hash); await mutate(); } catch (updateError) { tx.fail(updateError, 'Auction control failed'); @@ -103,8 +137,16 @@ export default function AuctionAdminPage() { } } - async function updateReservePrice() { - if (!session.address || (!isOwner && !canProposeAuction) || !data || data.paused !== true) return; + async function updateReservePrice(skipPauseCheck = false) { + if (!session.address || (!isOwner && !canProposeAuction) || !data) return; + + // If auctions are active and not skipping pause check, show confirmation dialog + if (!skipPauseCheck && data.paused === false) { + setAutoPauseAction('reserve-price'); + setAutoPauseDialogOpen(true); + return; + } + const match = reservePrice.trim().match(/^(\d+)(?:\.(\d{1,7}))?$/); if (!match) return tx.fail(new Error('Enter a valid reserve price with up to 7 decimal places.'), 'Invalid reserve price'); @@ -112,27 +154,74 @@ export default function AuctionAdminPage() { if (amount < 1000n) return tx.fail(new Error('Reserve price must be at least 0.0001 payment tokens.'), 'Invalid reserve price'); if (!isOwner && canProposeAuction) { + const actions = []; + // Add pause action if auctions are active + if (data.paused === false) { + const pauseHandler = getActionHandler('pause-auction'); + const pauseAction = pauseHandler.serialize( + {}, + { config, session: { address: session.address, kit: StellarWalletsKit } } + ); + actions.push(pauseAction); + } const handler = getActionHandler('set-auction-reserve-price'); const action = handler.serialize( { reservePrice: reservePrice.trim() }, { config, session: { address: session.address, kit: StellarWalletsKit } } ); - proposalDraft.requestAdd({ - daoId, - action, - source: 'admin/auction/set-auction-reserve-price', - metadata: { - title: 'Update auction reserve price', - description: `Set the next auction reserve price to ${reservePrice.trim()}.`, - url: '' - }, - onAdded: () => setFormMessage('Auction reserve price update added to the proposal draft.') - }); + actions.push(action); + + // Add all actions to draft + for (let i = 0; i < actions.length; i++) { + proposalDraft.requestAdd({ + daoId, + action: actions[i], + source: `admin/auction/${i === 0 && data.paused === false ? 'pause-auction' : 'set-auction-reserve-price'}`, + metadata: { + title: i === 0 && data.paused === false ? 'Pause auctions' : 'Update auction reserve price', + description: + i === 0 && data.paused === false + ? 'Pause auction activity.' + : `Set the next auction reserve price to ${reservePrice.trim()}.`, + url: '' + }, + onAdded: () => { + if (i === actions.length - 1) { + setFormMessage( + `${data.paused === false ? 'Pause and update' : 'Update'} reserve price added to the proposal draft.` + ); + setReservePrice(''); + } + } + }); + } return; } setBusy(true); tx.start('Updating reserve price...'); try { + // If auctions are active, pause them first + if (data.paused === false) { + const pauseClient = new AuctionClient({ + contractId: config.auctionContractId, + rpcUrl: config.rpcUrl, + networkPassphrase: config.passphrase, + publicKey: session.address, + signTransaction: async (xdr: string, opts?: { networkPassphrase?: string; address?: string }) => + StellarWalletsKit.signTransaction(xdr, { + networkPassphrase: opts?.networkPassphrase ?? config.passphrase, + address: opts?.address ?? session.address + }) + }); + tx.start('Pausing auctions...'); + const pauseAssembled = await pauseClient.pause({ caller: session.address }); + const pauseSent = await pauseAssembled.signAndSend(); + const pauseHash = pauseSent.sendTransactionResponse?.hash ?? ''; + tx.submitted('Auction pause submitted', pauseHash); + await waitForConfirmation(pauseHash, config.rpcUrl); + tx.submitted('Auctions paused, updating reserve price...', pauseHash); + } + const client = new AuctionClient({ contractId: config.auctionContractId, rpcUrl: config.rpcUrl, @@ -158,8 +247,16 @@ export default function AuctionAdminPage() { } } - async function updatePaymentToken() { - if (!session.address || (!isOwner && !canProposeAuction) || !data || data.paused !== true) return; + async function updatePaymentToken(skipPauseCheck = false) { + if (!session.address || (!isOwner && !canProposeAuction) || !data) return; + + // If auctions are active and not skipping pause check, show confirmation dialog + if (!skipPauseCheck && data.paused === false) { + setAutoPauseAction('payment-token'); + setAutoPauseDialogOpen(true); + return; + } + const value = paymentToken.trim(); if (!value) return tx.fail(new Error('Payment token contract address is required.'), 'Invalid payment token'); if (!isValidStellarAddress(value)) { @@ -169,27 +266,74 @@ export default function AuctionAdminPage() { ); } if (!isOwner && canProposeAuction) { + const actions = []; + // Add pause action if auctions are active + if (data.paused === false) { + const pauseHandler = getActionHandler('pause-auction'); + const pauseAction = pauseHandler.serialize( + {}, + { config, session: { address: session.address, kit: StellarWalletsKit } } + ); + actions.push(pauseAction); + } const handler = getActionHandler('set-auction-payment-token'); const action = handler.serialize( { paymentToken: value }, { config, session: { address: session.address, kit: StellarWalletsKit } } ); - proposalDraft.requestAdd({ - daoId, - action, - source: 'admin/auction/set-auction-payment-token', - metadata: { - title: 'Update auction payment token', - description: `Set the auction payment token to ${value}.`, - url: '' - }, - onAdded: () => setFormMessage('Auction payment token update added to the proposal draft.') - }); + actions.push(action); + + // Add all actions to draft + for (let i = 0; i < actions.length; i++) { + proposalDraft.requestAdd({ + daoId, + action: actions[i], + source: `admin/auction/${i === 0 && data.paused === false ? 'pause-auction' : 'set-auction-payment-token'}`, + metadata: { + title: i === 0 && data.paused === false ? 'Pause auctions' : 'Update auction payment token', + description: + i === 0 && data.paused === false + ? 'Pause auction activity.' + : `Set the auction payment token to ${value}.`, + url: '' + }, + onAdded: () => { + if (i === actions.length - 1) { + setFormMessage( + `${data.paused === false ? 'Pause and update' : 'Update'} payment token added to the proposal draft.` + ); + setPaymentToken(''); + } + } + }); + } return; } setBusy(true); tx.start('Updating payment token...'); try { + // If auctions are active, pause them first + if (data.paused === false) { + const pauseClient = new AuctionClient({ + contractId: config.auctionContractId, + rpcUrl: config.rpcUrl, + networkPassphrase: config.passphrase, + publicKey: session.address, + signTransaction: async (xdr: string, opts?: { networkPassphrase?: string; address?: string }) => + StellarWalletsKit.signTransaction(xdr, { + networkPassphrase: opts?.networkPassphrase ?? config.passphrase, + address: opts?.address ?? session.address + }) + }); + tx.start('Pausing auctions...'); + const pauseAssembled = await pauseClient.pause({ caller: session.address }); + const pauseSent = await pauseAssembled.signAndSend(); + const pauseHash = pauseSent.sendTransactionResponse?.hash ?? ''; + tx.submitted('Auction pause submitted', pauseHash); + await waitForConfirmation(pauseHash, config.rpcUrl); + tx.submitted('Auctions paused, updating payment token...', pauseHash); + } + const client = new AuctionClient({ contractId: config.auctionContractId, rpcUrl: config.rpcUrl, @@ -232,6 +376,20 @@ export default function AuctionAdminPage() { onCancel={proposalDraft.cancel} onResolve={proposalDraft.resolve} /> + setAutoPauseDialogOpen(false)} + onConfirm={() => { + setAutoPauseDialogOpen(false); + if (autoPauseAction === 'payment-token') { + void updatePaymentToken(true); + } else { + void updateReservePrice(true); + } + }} + /> ) : ( - {data?.paused - ? 'Bidding and automatic settlement are paused.' - : 'Auctions are active and accepting bids.'} + {data?.status === 'not-launched' + ? 'Auctions have not yet been launched. Click launch to create the first auction.' + : data?.paused + ? 'Bidding and automatic settlement are paused.' + : 'Auctions are active and accepting bids.'} )} {data ? ( <> - {data.paused && !auctionCanMint ? ( + {(data.status === 'not-launched' || data.paused) && !auctionCanMint ? ( ) : null} ) : null}
- - + {data?.status === 'not-launched' ? ( + + ) : ( + <> + + + + )}
Auction payment token {data?.config.payment_token ? `${getTreasuryAssets(config.name).find((asset) => asset.contractId === data.config.payment_token)?.code ?? 'Unknown SAC'} · ${data.config.payment_token}` : 'Not configured'} - . Changes apply after the next auction is created. + . Changes apply after the next auction is created. If auctions are active, they will be paused + automatically.
setPaymentToken(value.paymentToken)} network={config.name} - disabled={busy || data?.paused !== true} + disabled={busy} + draftPreview={draftStatus.actionsInDraft.find((a) => a.type === 'set-auction-payment-token')} /> -
@@ -317,19 +483,17 @@ export default function AuctionAdminPage() { ? (getTreasuryAssets(config.name).find((asset) => asset.contractId === data.config.payment_token) ?.code ?? 'SAC') : 'SAC'}{' '} - units. Changes apply after the next auction is created. + units. Changes apply after the next auction is created. If auctions are active, they will be paused + automatically.
setReservePrice(value.reservePrice)} - disabled={busy || data?.paused !== true} + disabled={busy} + draftPreview={draftStatus.actionsInDraft.find((a) => a.type === 'set-auction-reserve-price')} /> -
diff --git a/apps/web/src/app/dao/[daoId]/admin/governance/page.tsx b/apps/web/src/app/dao/[daoId]/admin/governance/page.tsx index 4c72dc8..ec6c499 100644 --- a/apps/web/src/app/dao/[daoId]/admin/governance/page.tsx +++ b/apps/web/src/app/dao/[daoId]/admin/governance/page.tsx @@ -10,6 +10,8 @@ import { AdminValueForm } from '@/components/admin/admin-action-forms'; import { AdminProposalDraftDialog } from '@/components/admin/admin-proposal-draft-dialog'; import { AdminSectionNav } from '@/components/admin/admin-section-nav'; import { AuthorityPanel } from '@/components/admin/authority-panel'; +import { DurationInput } from '@/components/admin/duration-input'; +import { PercentageInput } from '@/components/admin/percentage-input'; import { PageSection } from '@/components/page-section'; import { Badge, Button, Callout, Card, Heading, Skeleton, Text } from '@/components/ui'; import { useDaoContext } from '@/contexts/dao-context'; @@ -20,14 +22,15 @@ import { useGoldskyGovernorAuthorities } from '@/lib/goldsky-queries'; import { getActionHandler } from '@/lib/proposal-actions/registry'; import { waitForConfirmation } from '@/lib/transaction-confirmation'; import { useTransactionFeedback } from '@/lib/transaction-feedback'; +import { useAdminDraftStatus } from '@/lib/use-admin-draft-status'; import { useAdminProposalDraft } from '@/lib/use-admin-proposal-draft'; import { useDaoSessionStore } from '@/stores/dao-session-store'; type Drafts = Partial<{ - votingDelay: string; - votingPeriod: string; + votingDelay: number; + votingPeriod: number; proposalThreshold: string; - quorumBps: string; + quorumBps: number; }>; type GovernorSettingKey = 'votingDelay' | 'votingPeriod' | 'proposalThreshold' | 'quorumBps'; @@ -38,15 +41,6 @@ function formatThreshold(value: bigint) { return value.toString(); } -function parseWholeNumber(value: string) { - const trimmed = value.trim(); - if (!/^\d+$/.test(trimmed)) { - return null; - } - - return Number(trimmed); -} - function parseBigIntValue(value: string) { const trimmed = value.trim(); if (!/^\d+$/.test(trimmed)) { @@ -68,6 +62,12 @@ export default function GovernanceAdminPage() { const [busy, setBusy] = useState(false); const [activeAction, setActiveAction] = useState(''); const proposalDraft = useAdminProposalDraft(); + const draftStatus = useAdminDraftStatus(daoId, [ + 'set-voting-delay', + 'set-voting-period', + 'set-proposal-threshold', + 'set-quorum-bps' + ]); const tx = useTransactionFeedback(config.name); const { data: settings, @@ -180,11 +180,7 @@ export default function GovernanceAdminPage() { async function applyVotingDelay() { if (!settings) return; - const value = parseWholeNumber(drafts.votingDelay ?? String(settings.votingDelay)); - if (value === null) { - setFormMessage('Voting delay must be a whole number.'); - return; - } + const value = typeof drafts.votingDelay === 'number' ? drafts.votingDelay : settings.votingDelay; if (value === settings.votingDelay) { setFormMessage('Voting delay is unchanged.'); @@ -205,11 +201,7 @@ export default function GovernanceAdminPage() { async function applyVotingPeriod() { if (!settings) return; - const value = parseWholeNumber(drafts.votingPeriod ?? String(settings.votingPeriod)); - if (value === null) { - setFormMessage('Voting period must be a whole number.'); - return; - } + const value = typeof drafts.votingPeriod === 'number' ? drafts.votingPeriod : settings.votingPeriod; if (value === settings.votingPeriod) { setFormMessage('Voting period is unchanged.'); @@ -258,11 +250,7 @@ export default function GovernanceAdminPage() { async function applyQuorumBps() { if (!settings) return; - const value = parseWholeNumber(drafts.quorumBps ?? String(settings.quorumBps)); - if (value === null) { - setFormMessage('Quorum must be a whole number.'); - return; - } + const value = typeof drafts.quorumBps === 'number' ? drafts.quorumBps : settings.quorumBps; if (value === settings.quorumBps) { setFormMessage('Quorum is unchanged.'); @@ -353,14 +341,26 @@ export default function GovernanceAdminPage() {
Voting delay
- - Current: {settings ? formatSecondsValue(settings.votingDelay) : '—'} · Measured in seconds. - - setDrafts((current) => ({ ...current, votingDelay: value.value }))} + + + Current: {settings ? formatSecondsValue(settings.votingDelay) : '—'} + + + setDrafts((current) => ({ ...current, votingDelay: value }))} disabled={busy} + helperText="Time between proposal creation and when voting begins. Minimum 5 minutes. Example: 1 day gives members time to see new proposals." /> + + {typeof drafts.votingDelay === 'number' && settings && drafts.votingDelay !== settings.votingDelay ? ( + + Change: {formatSecondsValue(settings.votingDelay)} → {formatSecondsValue(drafts.votingDelay)} + + ) : null} +
- - Current:{' '} - {settings ? ( - `${settings.quorumBps} bps` - ) : ( - - )} - - setDrafts((current) => ({ ...current, quorumBps: value.value }))} + + + Current:{' '} + {settings ? ( + `${(settings.quorumBps / 100).toFixed(2)}%` + ) : ( + + )} + + + setDrafts((current) => ({ ...current, quorumBps: value }))} disabled={busy} + helperText="Percentage of total votes needed for a proposal to pass. Example: 10% means 10 out of 100 votes required." /> - - {settings ? ( - 'Apply this change in a single transaction.' - ) : ( - - )} - + + {typeof drafts.quorumBps === 'number' && settings && drafts.quorumBps !== settings.quorumBps ? ( + + Change: {(settings.quorumBps / 100).toFixed(2)}% → {(drafts.quorumBps / 100).toFixed(2)}% + + ) : null} +
+ )} + {compact && (expanded ? : )} +
+ + + {expanded && ( +
+ + {label} + + {summary} + + {(onRemove || onViewDraft) && ( +
+ {onRemove && ( + + )} + {onViewDraft && ( + + )} +
+ )} +
+
+ )} + + ); +} diff --git a/apps/web/src/components/admin/auction-auto-pause-dialog.tsx b/apps/web/src/components/admin/auction-auto-pause-dialog.tsx new file mode 100644 index 0000000..3c9b66a --- /dev/null +++ b/apps/web/src/components/admin/auction-auto-pause-dialog.tsx @@ -0,0 +1,72 @@ +'use client'; + +import { Stack } from 'styled-system/jsx'; + +import { Button, Card, Heading, Text } from '@/components/ui'; + +export type AuctionAutoPauseAction = 'payment-token' | 'reserve-price'; + +interface AuctionAutoPauseDialogProps { + open: boolean; + action: AuctionAutoPauseAction; + onConfirm: () => void; + onCancel: () => void; + isLoading?: boolean; +} + +export function AuctionAutoPauseDialog({ + open, + action, + onConfirm, + onCancel, + isLoading = false +}: AuctionAutoPauseDialogProps) { + const actionLabel = action === 'payment-token' ? 'Update payment token' : 'Update reserve price'; + + if (!open) return null; + + return ( +
+ event.stopPropagation()} + p="5" + > + + + + Pause auctions to make changes + + + Auctions are currently active. To update the{' '} + {action === 'payment-token' ? 'payment token' : 'reserve price'}, the auctions must be paused first. + + The following actions will be executed together: +
    +
  1. + Pause auctions - Stop all auction activity +
  2. +
  3. + {actionLabel} - Apply your configuration change +
  4. +
+ + The configuration change will apply to the next auction after it resumes. + +
+
+ + +
+
+
+
+ ); +} diff --git a/apps/web/src/components/admin/authority-panel.tsx b/apps/web/src/components/admin/authority-panel.tsx index c3bd2f9..c9aaf17 100644 --- a/apps/web/src/components/admin/authority-panel.tsx +++ b/apps/web/src/components/admin/authority-panel.tsx @@ -4,6 +4,7 @@ import { Stack } from 'styled-system/jsx'; import { AuthorityActionForm } from '@/components/admin/admin-action-forms'; import { Badge, Button, Card, Heading, ShortId, Skeleton, Text } from '@/components/ui'; +import type { ProposalQueuedAction } from '@/lib/proposal-actions/types'; type AuthorityItem = { authority: string; @@ -27,7 +28,8 @@ export function AuthorityPanel({ loading = false, editable = true, formEnabled = true, - emptyLabel = 'No authorities indexed yet.' + emptyLabel = 'No authorities indexed yet.', + draftPreview }: { title: string; badge: string; @@ -44,6 +46,7 @@ export function AuthorityPanel({ editable?: boolean; formEnabled?: boolean; emptyLabel?: string; + draftPreview?: ProposalQueuedAction; }) { return ( @@ -62,6 +65,7 @@ export function AuthorityPanel({ onChange={(nextValue) => onValueChange?.(nextValue.authority)} disabled={Boolean(busy)} showEnabled={false} + draftPreview={draftPreview} /> ) : null} diff --git a/apps/web/src/components/admin/percentage-input.tsx b/apps/web/src/components/admin/percentage-input.tsx new file mode 100644 index 0000000..968d00a --- /dev/null +++ b/apps/web/src/components/admin/percentage-input.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { Stack } from 'styled-system/jsx'; + +import { FieldHelperText, FieldLabel, Input, Text } from '@/components/ui'; + +type PercentageInputProps = { + id: string; + label: string; + value: number | string | null | undefined; + onChange: (bps: number) => void; + helperText?: string; + disabled?: boolean; + min?: number; + max?: number; +}; + +const bpsToPercent = (bps: number): string => { + return (bps / 100).toFixed(2); +}; + +const percentToBps = (percent: number): number => { + return Math.round(percent * 100); +}; + +function parseValue(value: number | string | null | undefined): number { + const parsed = typeof value === 'string' ? Number(value) : Number(value ?? 0); + return Number.isFinite(parsed) ? Math.max(0, Math.floor(parsed)) : 0; +} + +export function PercentageInput({ + id, + label, + value, + onChange, + helperText, + disabled, + min = 0, + max = 100 +}: PercentageInputProps) { + const bps = parseValue(value); + const percentageValue = bpsToPercent(bps); + + function handleChange(event: React.ChangeEvent) { + const percentage = Number(event.target.value); + if (Number.isNaN(percentage)) return; + + if (percentage < min || percentage > max) { + return; + } + + onChange(percentToBps(percentage)); + } + + return ( + +
+ {label} + {helperText ? {helperText} : null} +
+ + + + + {percentageValue}% ({bps} basis points) + + +
+ ); +} diff --git a/apps/web/src/components/dao-shell.tsx b/apps/web/src/components/dao-shell.tsx index 9a93317..a9f1501 100644 --- a/apps/web/src/components/dao-shell.tsx +++ b/apps/web/src/components/dao-shell.tsx @@ -28,15 +28,24 @@ import { useDaoSessionStore } from '@/stores/dao-session-store'; type NavItem = { href: Route; label: string; icon: LucideIcon; exact?: boolean }; -function getNavItems(daoId: string): NavItem[] { - return [ +function getNavItems(daoId: string, auctionEnabled?: boolean | null): NavItem[] { + const items: NavItem[] = [ { href: `/dao/${daoId}` as Route, label: 'Dashboard', icon: LayoutDashboard, exact: true }, { href: `/dao/${daoId}/proposals` as Route, label: 'Proposals', icon: Vote }, - { href: `/dao/${daoId}/auctions` as Route, label: 'Auctions', icon: Gavel }, { href: `/dao/${daoId}/treasury` as Route, label: 'Treasury', icon: Landmark }, { href: `/dao/${daoId}/members` as Route, label: 'Members', icon: Users }, { href: `/dao/${daoId}/marketplace` as Route, label: 'Marketplace', icon: Store } ]; + + // Show Auctions tab based on current auction enabled state. + // - false: permanently disabled at finalization, hide tab + // - true: enabled (either at finalization or re-enabled later), show tab + // - null: DAO still pending finalization, show tab (might be enabled) + if (auctionEnabled !== false) { + items.splice(3, 0, { href: `/dao/${daoId}/auctions` as Route, label: 'Auctions', icon: Gavel }); + } + + return items; } function NavLink({ @@ -79,7 +88,7 @@ export function DaoShell({ children }: { children: ReactNode }) { const { data: memberLookup } = useGoldskyMember(currentNetwork.tokenContractId, session.address); const walletDisabled = Boolean(session.address && session.walletNetworkIssue); - const baseNavItems = getNavItems(daoId); + const baseNavItems = getNavItems(daoId, currentNetwork.auctionEnabled); const adminNavItem: NavItem = { href: `/dao/${daoId}/admin` as Route, label: 'Admin', diff --git a/apps/web/src/components/wallet-controls.tsx b/apps/web/src/components/wallet-controls.tsx index 22331ee..4e0bf70 100644 --- a/apps/web/src/components/wallet-controls.tsx +++ b/apps/web/src/components/wallet-controls.tsx @@ -192,6 +192,9 @@ export function WalletControls({ network }: { network?: WalletNetwork }) { signature: normalizeWalletSignature(signedMessage) }); } catch (error) { + console.warn('[Auth] SEP-53 failed, attempting SEP-10 fallback', { + error: error instanceof Error ? error.message : String(error) + }); if (!isSep53UnsupportedError(error)) throw error; authMethod = 'sep10'; diff --git a/apps/web/src/lib/proposal-actions/actions/batch-mint-governance-token/component.tsx b/apps/web/src/lib/proposal-actions/actions/batch-mint-governance-token/component.tsx index d73b009..4f73d54 100644 --- a/apps/web/src/lib/proposal-actions/actions/batch-mint-governance-token/component.tsx +++ b/apps/web/src/lib/proposal-actions/actions/batch-mint-governance-token/component.tsx @@ -4,19 +4,22 @@ import { Stack } from 'styled-system/jsx'; +import { AdminDraftActionPreview } from '@/components/admin/admin-draft-action-preview'; import { FieldHelperText, FieldLabel, Input } from '@/components/ui'; -import type { ActionFormProps } from '../../types'; +import type { ActionFormProps, ProposalQueuedAction } from '../../types'; import type { BatchMintGovernanceTokenData } from './types'; export function BatchMintGovernanceTokenForm({ value, onChange, disabled, - validationErrors -}: ActionFormProps) { + validationErrors, + draftPreview +}: ActionFormProps & { draftPreview?: ProposalQueuedAction }) { return ( + {draftPreview && } Recipient = { type: 'set-auction-reserve-price', diff --git a/apps/web/src/lib/proposal-actions/components/action-form-wrapper.tsx b/apps/web/src/lib/proposal-actions/components/action-form-wrapper.tsx index b1e4fb4..2ac0f40 100644 --- a/apps/web/src/lib/proposal-actions/components/action-form-wrapper.tsx +++ b/apps/web/src/lib/proposal-actions/components/action-form-wrapper.tsx @@ -136,6 +136,11 @@ export function ActionFormWrapper({ daoId }: { daoId: string }) { } > + {/* Note: draftPreview is intentionally NOT passed here. + Admin forms show draft previews to alert users when a setting change is already queued + (preventing duplicate submissions). In proposal creation, the full action queue is + visible separately below the form, giving users complete visibility. Draft previews + would be redundant here and create visual clutter. */} updateDraft(address, daoId, value)} diff --git a/apps/web/src/lib/use-admin-draft-status.ts b/apps/web/src/lib/use-admin-draft-status.ts new file mode 100644 index 0000000..26af6ad --- /dev/null +++ b/apps/web/src/lib/use-admin-draft-status.ts @@ -0,0 +1,43 @@ +import { getProposalActionResourceKey } from '@/lib/proposal-action-identity'; +import type { ProposalActionType, ProposalQueuedAction } from '@/lib/proposal-actions/types'; +import { useDaoSessionStore } from '@/stores/dao-session-store'; +import { normalizeWalletAddress, useProposalComposerStore } from '@/stores/proposal-composer-store'; + +/** + * Hook to check if specific action types exist in the proposal draft + * Useful for forms to show draft previews and detect conflicts + */ +export function useAdminDraftStatus(daoId: string, actionTypes: ProposalActionType | ProposalActionType[]) { + const address = useDaoSessionStore((state) => state.address); + const draft = useProposalComposerStore((state) => { + if (!address) return null; + const walletKey = normalizeWalletAddress(address); + return state.draftsByWallet[walletKey]?.[daoId] ?? null; + }); + + const typesToCheck = Array.isArray(actionTypes) ? actionTypes : [actionTypes]; + const actionsInDraft = draft?.queuedActions.filter((action) => typesToCheck.includes(action.type)) ?? []; + const resourceKeys = actionsInDraft.map((action) => getProposalActionResourceKey(action)); + + function checkConflict(testAction: ProposalQueuedAction): { + hasConflict: boolean; + existingAction?: ProposalQueuedAction; + } { + const testResourceKey = getProposalActionResourceKey(testAction); + const existing = actionsInDraft.find((action) => getProposalActionResourceKey(action) === testResourceKey); + + if (!existing) { + return { hasConflict: false }; + } + + const isDuplicate = JSON.stringify(existing) === JSON.stringify(testAction); + return { hasConflict: !isDuplicate, existingAction: existing }; + } + + return { + actionsInDraft, + hasActions: actionsInDraft.length > 0, + resourceKeys, + checkConflict + }; +}