Skip to content

Commit 5234db4

Browse files
julien51claude
andauthored
feat: add governance delegation writes (unlock-protocol#16324)
* feat: add governance delegation writes * fix: pass tokenSymbol as prop to DelegateAccountPanel instead of reading from config Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: thread tokenSymbol prop through to DelegateWalletPanel Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: replace StateCard wallet-connect states with inline connect button Show a simple "Connect wallet" button (using @unlock-protocol/ui Button) when the user is not authenticated, matching the airdrops site UX pattern. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve ENS on mainnet and Basenames on Base ENS names are registered on Ethereum mainnet; resolving them on Base always fails. Use a mainnet JsonRpcProvider for ENS lookups and fall back to a Base JsonRpcProvider for Basename resolution. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: use shared staging Privy app ID from unlock-app as default Falls back to the same staging Privy app used by unlock-app so wallet connection works locally without setting NEXT_PUBLIC_PRIVY_APP_ID. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: match Privy UI config to unlock-app Use the same loginMethods, embeddedWallets, appearance, and _render settings as unlock-app. Remove the canConnect dead-code guard since privyAppId now always has a fallback value. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: use AddressInput for delegate target field Replace the plain Input with AddressInput from @unlock-protocol/ui, which includes a clear button, wallet icon, and built-in ENS/Basename resolution with debounce. Submit path validates the already-resolved address from the input's onChange callback. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: replace custom app shell header with HeaderNav + wallet connect Use HeaderNav from @unlock-protocol/ui with governance nav links. Add GovernanceHeader client component showing a Connect button when unauthenticated and an address/sign-out menu when connected. Add disconnect() to useGovernanceWallet via Privy's useLogout hook. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add Footer to governance app shell Use the Footer component from @unlock-protocol/ui with governance- relevant links (DAO, Forum, Snapshot, Docs, Roadmap, Blog). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add ConnectModal, TermsOfServiceModal, and fix header alignment - ConnectModal: wraps Privy LoginModal with useConnectModal context - TermsOfServiceModal: one-time ToS acceptance persisted in localStorage - ConnectModalProvider: simple open/close state wired to Privy login() - GovernanceHeader: use openConnectModal instead of calling login() directly - GovernanceHeader: wrap HeaderNav in max-w-7xl container to fix full-width alignment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: mark AppShell as client component Footer from @unlock-protocol/ui uses hooks without 'use client', which causes a Next.js build error when imported from a server component. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: constrain footer width to match page layout Wrap Footer in max-w-7xl container to match the header and main content alignment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove inline connect button from delegation panel The header Connect button is the single entry point for wallet connection. Remove the !authenticated branch with its Button, the !address waiting StateCard, and all related hook destructuring (connect, isReady, canConnect). Panel now renders null when no wallet is connected. Also remove StateCard and ReactNode since they are no longer needed. Update HeroCard copy to drop the "Connect a wallet" prompt. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: collapse delegation panel into page Flatten DelegateAccountPanel + DelegateWalletPanel into app/delegate/page.tsx directly. The component file added indirection with no benefit — one page, one component, all logic in one place. tokenSymbol is now fetched client-side via useEffect so the page can be a client component. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove unused canConnect from useGovernanceWallet canConnect was only used in the old DelegateAccountPanel guard which is now deleted. Also removes the governanceEnv import which was only needed for that field. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove dead code — getBrowserProvider, wallet, getGovernorInterface None of these are consumed outside their definition files. Inline the provider creation directly into ensureBaseNetwork. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: use ethers ZeroAddress instead of local constant Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: move delegation form to /delegates, remove /delegate route Merges the personal delegation form (wallet balance, voting power, delegate management) directly into the /delegates page above the leaderboard. Removes the now-redundant /delegate route. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: self-delegate button and refresh on logout AddressInput manages its own internal state seeded from value on mount and does not re-sync when the value prop changes externally. Use a reset key to force remount when the value is set from outside (self-delegate button or auto-fill from on-chain delegate). Also call router.refresh() on logout so the UI clears correctly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove redundant connected wallet address from delegation form Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove Personal Delegation label from delegates page header Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * ci: fix claude-code-review allowed_tools — use claude_args instead allowed_tools was removed from claude-code-action@v1; the equivalent is now claude_args: '--allowedTools ...'. Without this the review agent cannot post PR comments. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Revert "ci: fix claude-code-review allowed_tools — use claude_args instead" This reverts commit 4b1451b. * fix: hide delegation form when not authenticated Gate on authenticated (from usePrivy) in addition to address. Privy can return a cached wallet address before the session is confirmed, causing the form to show while the header still shows "Connect". Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: remove hero card from delegates page Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: ENS/Basename resolution and per-row Delegate button in leaderboard - Use <Address useName={...}> from @unlock-protocol/ui for all address display in leaderboard rows (resolves Basenames then ENS) - Add per-row Delegate button that pre-fills the delegation form via ?delegate= query param - Pre-format bigint values server-side before passing to client rows (bigints can't cross the server→client boundary in Next.js) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add comments to empty catch blocks to satisfy no-empty lint rule Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent adbd592 commit 5234db4

File tree

16 files changed

+696
-132
lines changed

16 files changed

+696
-132
lines changed

governance-app/app/delegate/page.tsx

Lines changed: 0 additions & 27 deletions
This file was deleted.

governance-app/app/delegates/page.tsx

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,52 @@
1+
// ABOUTME: Delegates page — personal delegation form (wallet-connected) and
2+
// the full delegate leaderboard ordered by voting power.
13
import { ProposalErrorState } from '~/components/proposals/ProposalErrorState'
2-
import { DelegateLeaderboardRow } from '~/components/delegates/DelegateLeaderboardRow'
3-
import { getDelegateOverview } from '~/lib/governance/delegates'
4+
import {
5+
DelegateLeaderboardRow,
6+
type DelegateRowData,
7+
} from '~/components/delegates/DelegateLeaderboardRow'
8+
import { DelegateFormSection } from '~/components/delegates/DelegateFormSection'
9+
import {
10+
getDelegateOverview,
11+
formatDelegatedShare,
12+
} from '~/lib/governance/delegates'
13+
import { formatTokenAmount } from '~/lib/governance/format'
414

515
export const dynamic = 'force-dynamic'
616

717
export default async function DelegatesPage() {
818
try {
919
const overview = await getDelegateOverview()
1020

21+
const rows: DelegateRowData[] = overview.delegates.map((d, index) => ({
22+
address: d.address,
23+
rank: index + 1,
24+
votingPower: formatTokenAmount(d.votingPower),
25+
tokenBalance: formatTokenAmount(d.tokenBalance),
26+
delegatorCount: d.delegatorCount,
27+
delegatedShare: formatDelegatedShare(d.votingPower, overview.totalSupply),
28+
}))
29+
1130
return (
1231
<section className="space-y-8">
32+
<DelegateFormSection />
33+
1334
<div className="rounded-[2rem] border border-brand-ui-primary/10 bg-white p-8 shadow-sm">
1435
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-brand-ui-primary/55">
15-
Delegation Read Path
36+
Delegate Leaderboard
1637
</p>
17-
<h2 className="mt-4 text-4xl font-semibold text-brand-ui-primary">
18-
Delegate leaderboard
38+
<h2 className="mt-4 text-3xl font-semibold text-brand-ui-primary">
39+
Top delegates by voting power
1940
</h2>
2041
<p className="mt-4 max-w-3xl text-base leading-7 text-brand-ui-primary/72">
2142
Current delegation relationships reconstructed from on-chain
22-
`DelegateChanged` events and hydrated with live voting power from
23-
the UP token contract.
43+
DelegateChanged events and hydrated with live voting power from the
44+
UP token contract.
2445
</p>
2546
</div>
2647
<div className="grid gap-4">
27-
{overview.delegates.map((delegate, index) => (
28-
<DelegateLeaderboardRow
29-
key={delegate.address}
30-
delegate={delegate}
31-
rank={index + 1}
32-
totalSupply={overview.totalSupply}
33-
/>
48+
{rows.map((row) => (
49+
<DelegateLeaderboardRow key={row.address} {...row} />
3450
))}
3551
</div>
3652
</section>

governance-app/app/providers.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,27 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
66
import { ToastProvider, UnlockUIProvider } from '@unlock-protocol/ui'
77
import { useState } from 'react'
88
import { governanceEnv } from '~/config/env'
9+
import { ConnectModalProvider } from '~/hooks/useConnectModal'
910

1011
type ProviderProps = {
1112
children: React.ReactNode
1213
}
1314

1415
function WalletProvider({ children }: ProviderProps) {
15-
if (!governanceEnv.privyAppId) {
16-
return <>{children}</>
17-
}
18-
1916
return (
2017
<PrivyProvider
2118
appId={governanceEnv.privyAppId}
2219
config={{
23-
loginMethods: ['wallet', 'email', 'google'],
20+
loginMethods: ['wallet', 'email', 'google', 'farcaster'],
2421
embeddedWallets: {
2522
createOnLogin: 'off',
2623
},
2724
appearance: {
28-
landingHeader: 'Unlock DAO Governance',
25+
landingHeader: '',
26+
},
27+
// @ts-expect-error internal api
28+
_render: {
29+
standalone: true,
2930
},
3031
}}
3132
>
@@ -51,9 +52,11 @@ export function Providers({ children }: ProviderProps) {
5152
return (
5253
<UnlockUIProvider Link={Link}>
5354
<WalletProvider>
54-
<QueryClientProvider client={queryClient}>
55-
<ToastProvider>{children}</ToastProvider>
56-
</QueryClientProvider>
55+
<ConnectModalProvider>
56+
<QueryClientProvider client={queryClient}>
57+
<ToastProvider>{children}</ToastProvider>
58+
</QueryClientProvider>
59+
</ConnectModalProvider>
5760
</WalletProvider>
5861
</UnlockUIProvider>
5962
)
Lines changed: 8 additions & 0 deletions
Loading
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// ABOUTME: Renders the Privy LoginModal inside an Unlock UI Modal wrapper.
2+
// Visibility is controlled by ConnectModalProvider via useConnectModal.
3+
'use client'
4+
5+
import { Modal } from '@unlock-protocol/ui'
6+
import { LoginModal } from '@privy-io/react-auth'
7+
import { useConnectModal } from '~/hooks/useConnectModal'
8+
9+
export function ConnectModal() {
10+
const { open, closeConnectModal } = useConnectModal()
11+
12+
return (
13+
<Modal isOpen={open} setIsOpen={closeConnectModal} size="small">
14+
<div className="w-full max-w-sm rounded-2xl bg-white">
15+
<LoginModal open={open} />
16+
</div>
17+
</Modal>
18+
)
19+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// ABOUTME: Shows a one-time terms of service acceptance modal on first visit.
2+
// Acceptance is persisted in localStorage so the modal only appears once.
3+
'use client'
4+
5+
import { Button, Modal } from '@unlock-protocol/ui'
6+
import { useTermsOfService } from '~/hooks/useTermsOfService'
7+
8+
export function TermsOfServiceModal() {
9+
const { termsAccepted, saveTermsAccepted, termsLoading } = useTermsOfService()
10+
const showTermsModal = !termsLoading && !termsAccepted
11+
12+
return (
13+
<Modal isOpen={showTermsModal} setIsOpen={saveTermsAccepted}>
14+
<div className="flex flex-col justify-center gap-4 bg-white">
15+
<span className="text-base">
16+
No account required ✨, but you need to agree to our{' '}
17+
<a
18+
className="text-brand-ui-primary outline-none"
19+
href="https://unlock-protocol.com/terms"
20+
target="_blank"
21+
rel="noopener noreferrer"
22+
>
23+
Terms of Service
24+
</a>{' '}
25+
and{' '}
26+
<a
27+
className="text-brand-ui-primary outline-none"
28+
href="https://unlock-protocol.com/privacy"
29+
target="_blank"
30+
rel="noopener noreferrer"
31+
>
32+
Privacy Policy
33+
</a>
34+
.
35+
</span>
36+
<Button onClick={saveTermsAccepted}>I agree</Button>
37+
</div>
38+
</Modal>
39+
)
40+
}

0 commit comments

Comments
 (0)