diff --git a/AGENTS.md b/AGENTS.md index 3e5569a1..284c8dc6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,10 +76,7 @@ GoodWidget/ - Story interaction checks: `pnpm test:storybook`. - Playwright QA/state-flow checks: `pnpm test:demo`. - Root Playwright runtime artifacts (trace/video/attachments): `/test-results/` (gitignored). -- Nested widget Playwright test-runs (`tests/widgets//test-results/`) are gitignored - transient run output. The canonical visual evidence for a widget lives in - `examples/storybook/src/stories//screenshots/` (curated, deterministic, - generated by the screenshot regen script) — commit only that curated set. +- Nested widget Playwright test-runs are output (`tests/widgets//test-results/`) - Detailed workflow, fixture behavior, and QA reporting template live in [`docs/demo-environment.md`](docs/demo-environment.md) and [`docs/qa-guide.md`](docs/qa-guide.md). diff --git a/examples/storybook/.storybook/main.ts b/examples/storybook/.storybook/main.ts index 7118a297..a355900d 100644 --- a/examples/storybook/.storybook/main.ts +++ b/examples/storybook/.storybook/main.ts @@ -39,6 +39,15 @@ const config: StorybookConfig = { } config.optimizeDeps = { ...config.optimizeDeps, + // Storybook loads this preview annotation through a virtual module. When it is + // pre-bundled alongside the branch's larger widget dependency graph, Vite can + // discover a new shared chunk after the first browser request and invalidate the + // URL that Storybook already emitted. It is a small ESM module, so serving it + // directly avoids that dev-only optimizer race while preserving interactions. + exclude: [ + ...(config.optimizeDeps?.exclude ?? []), + '@storybook/addon-interactions/preview', + ], esbuildOptions: { ...config.optimizeDeps?.esbuildOptions, resolveExtensions: [ diff --git a/examples/storybook/src/fixtures/governanceInteractiveMock.ts b/examples/storybook/src/fixtures/governanceInteractiveMock.ts new file mode 100644 index 00000000..0084fb8b --- /dev/null +++ b/examples/storybook/src/fixtures/governanceInteractiveMock.ts @@ -0,0 +1,252 @@ +import { decodeAbiParameters, decodeFunctionData, parseAbi, type Address, type Hex } from 'viem' +import type { EIP1193Provider } from '@goodwidget/core' +import { + encodeMockGovernanceRead, + MOCK_ALIGNMENT, + MOCK_CITIZEN, + MOCK_G_TOKEN, + MOCK_GOOD_ID, + MOCK_HOUSES, +} from './governanceRuntimeMock' + +// Minimal write-side ABI fragments, mirroring packages/governance-widget/src/sdks/contracts.ts. +// Duplicated locally (same convention as the read ABI in governanceRuntimeMock.ts) so this +// browser fixture has no dependency on package internals. +const G_TOKEN_WRITE_ABI = parseAbi([ + 'function transferAndCall(address to, uint256 value, bytes data) returns (bool)', +]) +const HOUSES_WRITE_ABI = parseAbi([ + 'function castVote(address[] recipients, uint256[] allocations)', + 'function unstake()', +]) +const REGISTRATION_DATA_TYPES = [ + { type: 'uint8' }, + { type: 'string' }, + { type: 'string' }, + { type: 'string' }, + { type: 'string' }, + { type: 'string' }, +] as const + +const MOCK_GOVERNANCE_RPC_PATH = '/mock-governance-rpc' +const MOCK_SUPERFLUID_URL_FRAGMENT = 'celo-mainnet/protocol-v1' +const MOCK_ACCOUNT: Address = '0x1234123412341234123412341234123412341234' +const MOCK_NOW_SECONDS = 1_784_419_200 + +type PendingTransactionEffect = + | { kind: 'registration'; house: 0 | 1 } + | { kind: 'vote' } + | { kind: 'unstake' } + +interface InteractiveGovernanceSession { + memberStatus: 0 | 1 | 2 | 3 | 4 + memberHouse: 0 | 1 + hasVoted: boolean +} + +function buildMockReceipt(status: 'success' | 'reverted', hash: Hex) { + return { + blockHash: `0x${'b'.repeat(64)}`, + blockNumber: '0x10', + contractAddress: null, + cumulativeGasUsed: '0x5208', + effectiveGasPrice: '0x1', + from: MOCK_ACCOUNT, + gasUsed: '0x5208', + logs: [], + logsBloom: `0x${'0'.repeat(512)}`, + status: status === 'reverted' ? '0x0' : '0x1', + to: MOCK_HOUSES, + transactionHash: hash, + transactionIndex: '0x0', + type: '0x2', + } +} + +function buildMockBlock() { + return { + baseFeePerGas: '0x0', + difficulty: '0x0', + extraData: '0x', + gasLimit: '0x1c9c380', + gasUsed: '0x0', + hash: `0x${'b'.repeat(64)}`, + logsBloom: `0x${'0'.repeat(512)}`, + miner: MOCK_HOUSES, + mixHash: `0x${'c'.repeat(64)}`, + nonce: '0x0000000000000000', + number: '0x10', + parentHash: `0x${'d'.repeat(64)}`, + receiptsRoot: `0x${'e'.repeat(64)}`, + sha3Uncles: `0x${'f'.repeat(64)}`, + size: '0x0', + stateRoot: `0x${'1'.repeat(64)}`, + timestamp: `0x${MOCK_NOW_SECONDS.toString(16)}`, + totalDifficulty: '0x0', + transactions: [], + transactionsRoot: `0x${'2'.repeat(64)}`, + uncles: [], + } +} + +function buildMockFundingStreams() { + return { + data: { + streams: [ + { + sender: { id: MOCK_CITIZEN.toLowerCase() }, + currentFlowRate: '0', + streamedUntilUpdatedAt: '300000000000000000000', + updatedAtTimestamp: String(MOCK_NOW_SECONDS), + }, + { + sender: { id: MOCK_ALIGNMENT.toLowerCase() }, + currentFlowRate: '1', + streamedUntilUpdatedAt: '150000000000000000000', + updatedAtTimestamp: String(MOCK_NOW_SECONDS), + }, + ], + }, + } +} + +function requestUrl(input: RequestInfo | URL): string { + if (typeof input === 'string') return input + if (input instanceof URL) return input.href + return input.url +} + +export interface InteractiveGovernanceEnvironment { + provider: EIP1193Provider + celoRpcUrl: string + addresses: { housesAddress: Address; goodIdAddress: Address; gTokenAddress: Address } + teardown: () => void +} + +/** + * Wires a self-contained, browser-native mocked Celo RPC and Superfluid + * subgraph behind a `window.fetch` override, paired with a matching mock + * EIP-1193 wallet. This lets a human open the story directly in Storybook + * and drive the real `useGovernanceAdapter` runtime end-to-end (onboarding -> + * vote -> unstake) without a live contract or Playwright's `page.route` + * network interception, which only runs under automation. + */ +export function createInteractiveGovernanceEnvironment(): InteractiveGovernanceEnvironment { + const session: InteractiveGovernanceSession = { memberStatus: 0, memberHouse: 0, hasVoted: false } + const pendingEffectsByHash = new Map() + const listeners: Record void>> = {} + const originalFetch = window.fetch.bind(window) + let transactionCounter = 0 + + const nextTransactionHash = (): Hex => { + transactionCounter += 1 + return `0x${transactionCounter.toString(16).padStart(64, '0')}` as Hex + } + + const applyReceiptEffect = (effect: PendingTransactionEffect) => { + if (effect.kind === 'registration') { + session.memberStatus = 2 + session.memberHouse = effect.house + } else if (effect.kind === 'vote') { + session.hasVoted = true + } else if (effect.kind === 'unstake') { + session.memberStatus = 4 + } + } + + const provider = { + async request({ method, params }: { method: string; params?: unknown }) { + switch (method) { + case 'eth_requestAccounts': + case 'eth_accounts': + return [MOCK_ACCOUNT] + case 'eth_chainId': + return '0xa4ec' + case 'wallet_switchEthereumChain': + return null + case 'eth_estimateGas': + return '0x5208' + case 'eth_sendTransaction': { + const tx = (params as Array>)?.[0] ?? {} + const to = String(tx.to ?? '').toLowerCase() + const data = tx.data as Hex + const hash = nextTransactionHash() + + if (to === MOCK_HOUSES.toLowerCase()) { + const decoded = decodeFunctionData({ abi: HOUSES_WRITE_ABI, data }) + if (decoded.functionName === 'castVote') pendingEffectsByHash.set(hash, { kind: 'vote' }) + if (decoded.functionName === 'unstake') pendingEffectsByHash.set(hash, { kind: 'unstake' }) + } else if (to === MOCK_G_TOKEN.toLowerCase()) { + const decoded = decodeFunctionData({ abi: G_TOKEN_WRITE_ABI, data }) + if (decoded.functionName === 'transferAndCall') { + const registrationData = decoded.args[2] + const [house] = decodeAbiParameters(REGISTRATION_DATA_TYPES, registrationData) + pendingEffectsByHash.set(hash, { kind: 'registration', house: Number(house) === 1 ? 1 : 0 }) + } + } + return hash + } + default: + throw new Error(`Interactive governance mock: unsupported wallet method "${method}"`) + } + }, + on(event: string, listener: (...args: unknown[]) => void) { + listeners[event] = [...(listeners[event] ?? []), listener] + }, + removeListener(event: string, listener: (...args: unknown[]) => void) { + listeners[event] = (listeners[event] ?? []).filter((entry) => entry !== listener) + }, + } as EIP1193Provider + + window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = requestUrl(input) + + if (url.includes(MOCK_GOVERNANCE_RPC_PATH)) { + const payload = JSON.parse(String(init?.body ?? '{}')) as { + id: number + method: string + params?: unknown[] + } + const respond = (result: unknown) => + new Response(JSON.stringify({ jsonrpc: '2.0', id: payload.id, result }), { + headers: { 'content-type': 'application/json' }, + }) + + if (payload.method === 'eth_getTransactionReceipt') { + const hash = payload.params?.[0] as Hex + const effect = pendingEffectsByHash.get(hash) + if (effect) applyReceiptEffect(effect) + return respond(buildMockReceipt('success', hash)) + } + if (payload.method === 'eth_getBlockByNumber') return respond(buildMockBlock()) + if (payload.method === 'eth_blockNumber') return respond('0x10') + if (payload.method !== 'eth_call') return respond('0x') + + const call = (payload.params?.[0] as { to?: Address; data?: Hex } | undefined) ?? {} + if (!call.to || !call.data) return respond('0x') + const result = encodeMockGovernanceRead(call.to, call.data, { + memberStatusByAccount: { [MOCK_ACCOUNT.toLowerCase()]: session.memberStatus }, + memberHouseByAccount: { [MOCK_ACCOUNT.toLowerCase()]: session.memberHouse }, + hasVotedByVoter: { [MOCK_ACCOUNT.toLowerCase()]: session.hasVoted }, + }) + return respond(result) + } + + if (url.includes(MOCK_SUPERFLUID_URL_FRAGMENT)) { + return new Response(JSON.stringify(buildMockFundingStreams()), { + headers: { 'content-type': 'application/json' }, + }) + } + + return originalFetch(input, init) + }) as typeof window.fetch + + return { + provider, + celoRpcUrl: MOCK_GOVERNANCE_RPC_PATH, + addresses: { housesAddress: MOCK_HOUSES, goodIdAddress: MOCK_GOOD_ID, gTokenAddress: MOCK_G_TOKEN }, + teardown: () => { + window.fetch = originalFetch + }, + } +} diff --git a/examples/storybook/src/fixtures/governanceRuntimeMock.ts b/examples/storybook/src/fixtures/governanceRuntimeMock.ts new file mode 100644 index 00000000..049d58b2 --- /dev/null +++ b/examples/storybook/src/fixtures/governanceRuntimeMock.ts @@ -0,0 +1,181 @@ +import { + decodeFunctionData, + encodeFunctionResult, + parseAbi, + type Address, + type Hex, +} from 'viem' + +const HOUSES_READ_ABI = parseAbi([ + 'function minimumStake(uint8 house) view returns (uint256)', + 'function getMember(address account) view returns ((uint8 house, uint8 status, uint256 stakedAmount, uint64 joinedAt, uint64 updatedAt, uint64 unstakedAt, uint256 memberIndex, string name, string socialLinks, string projectWebpage, string missionStatement, string distributionStrategy))', + 'function getActiveMembers(uint8 house) view returns (address[])', + 'function cycleStartTime() view returns (uint64)', + 'function termDuration() view returns (uint64)', + 'function votingTermLength() view returns (uint64)', + 'function isVotingPeriod() view returns (bool)', + 'function getCurrentVoteId() view returns (uint256)', + 'function getVoteConfig(uint256 voteId) view returns ((uint64 startTime, uint64 endTime, uint64 executedAt, bool executed))', + 'function getVoteRecipients(uint256 voteId) view returns (address[])', + 'function getHasVoted(uint256 voteId, address voter) view returns (bool)', + 'function getFinalizedUnits(uint256 voteId, address recipient) view returns (uint128)', + 'function flowSplitterConfig() view returns (address splitter, uint256 poolId, address poolAddress)', +]) + +const GOOD_ID_READ_ABI = parseAbi([ + 'function getWhitelistedRoot(address account) view returns (address)', +]) + +export const MOCK_HOUSES = '0x4444444444444444444444444444444444444444' as Address +export const MOCK_GOOD_ID = '0x5555555555555555555555555555555555555555' as Address +export const MOCK_CITIZEN = '0x6666666666666666666666666666666666666666' as Address +export const MOCK_ALIGNMENT = '0x7777777777777777777777777777777777777777' as Address +export const MOCK_POOL = '0x8888888888888888888888888888888888888888' as Address +export const MOCK_G_TOKEN = '0x9999999999999999999999999999999999999999' as Address + +export interface MockGovernanceReadOptions { + memberStatus?: 0 | 1 | 2 | 3 | 4 + memberStatusByAccount?: Record + memberHouseByAccount?: Record + // Keyed by voter address, lowercased. Lets an interactive session reflect a + // just-submitted vote without needing a real per-voteId ledger. + hasVotedByVoter?: Record +} + +export function encodeMockGovernanceRead( + to: Address, + data: Hex, + options: MockGovernanceReadOptions = {}, +): Hex { + if (to.toLowerCase() === MOCK_GOOD_ID.toLowerCase()) { + const decoded = decodeFunctionData({ abi: GOOD_ID_READ_ABI, data }) + if (decoded.functionName !== 'getWhitelistedRoot') { + throw new Error(`Unexpected GoodID read: ${decoded.functionName}`) + } + return encodeFunctionResult({ + abi: GOOD_ID_READ_ABI, + functionName: 'getWhitelistedRoot', + result: MOCK_CITIZEN, + }) + } + + if (to.toLowerCase() !== MOCK_HOUSES.toLowerCase()) { + throw new Error(`Unexpected contract address: ${to}`) + } + + const decoded = decodeFunctionData({ abi: HOUSES_READ_ABI, data }) + switch (decoded.functionName) { + case 'getMember': { + const memberAccount = String(decoded.args[0]).toLowerCase() + const memberStatus = + options.memberStatusByAccount?.[memberAccount] ?? + options.memberStatus ?? + 2 + const hasMembership = memberStatus !== 0 && memberStatus !== 4 + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'getMember', + result: { + house: options.memberHouseByAccount?.[memberAccount] ?? 0, + status: memberStatus, + stakedAmount: hasMembership ? 1_000n * 10n ** 18n : 0n, + joinedAt: hasMembership ? 1_761_955_200n : 0n, + updatedAt: hasMembership ? 1_764_547_200n : 0n, + unstakedAt: memberStatus === 4 ? 1_784_044_800n : 0n, + memberIndex: 0n, + name: hasMembership ? 'Mocked Citizen' : '', + socialLinks: hasMembership ? 'https://example.com/citizen' : '', + projectWebpage: '', + missionStatement: '', + distributionStrategy: '', + }, + }) + } + case 'minimumStake': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'minimumStake', + result: 1_000n * 10n ** 18n, + }) + case 'getActiveMembers': { + const house = Number(decoded.args[0]) + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'getActiveMembers', + result: house === 0 ? [MOCK_CITIZEN] : [MOCK_ALIGNMENT], + }) + } + case 'cycleStartTime': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'cycleStartTime', + result: 1_764_547_200n, + }) + case 'termDuration': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'termDuration', + result: 19_440_000n, + }) + case 'votingTermLength': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'votingTermLength', + result: 1_209_600n, + }) + case 'isVotingPeriod': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'isVotingPeriod', + result: true, + }) + case 'getCurrentVoteId': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'getCurrentVoteId', + result: 1n, + }) + case 'getVoteConfig': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'getVoteConfig', + result: { + startTime: 1_783_987_200n, + endTime: 1_785_196_800n, + executedAt: 0n, + executed: false, + }, + }) + case 'getVoteRecipients': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'getVoteRecipients', + result: [MOCK_ALIGNMENT], + }) + case 'getHasVoted': { + const voter = String(decoded.args[1]).toLowerCase() + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'getHasVoted', + result: options.hasVotedByVoter?.[voter] ?? false, + }) + } + case 'getFinalizedUnits': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'getFinalizedUnits', + result: 0n, + }) + case 'flowSplitterConfig': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'flowSplitterConfig', + result: [MOCK_HOUSES, 1n, MOCK_POOL], + }) + default: + // Every HOUSES_READ_ABI function is handled above, so this branch is unreachable at + // the type level (decoded narrows to `never`) but kept as a runtime guard against a + // future ABI addition that isn't wired into this mock yet. + throw new Error(`Unexpected houses read call data: ${data}`) + } +} diff --git a/examples/storybook/src/stories/governance-widget/GovernanceDashboard.stories.tsx b/examples/storybook/src/stories/governance-widget/GovernanceDashboard.stories.tsx deleted file mode 100644 index 37b41f1e..00000000 --- a/examples/storybook/src/stories/governance-widget/GovernanceDashboard.stories.tsx +++ /dev/null @@ -1,359 +0,0 @@ -import React, { useState } from 'react' -import type { Meta, StoryObj } from '@storybook/react' -import { Text, XStack, YStack } from '@goodwidget/ui' -import { - AlignmentVotingProposalCard, - BalanceCard, - FundingDistributionChart, - GovernanceWidgetProvider, - ImpactCard, - OptimisticVotingProposalCard, -} from '@goodwidget/governance-widget' -import type { - FundingProjectAllocation, - RankedVotingOption, - VoteSegment, - VoterPreview, -} from '@goodwidget/governance-widget' -import type { GoodWidgetThemeOverrides } from '@goodwidget/core' - -const meta: Meta = { - title: 'Widgets/GovernanceWidget', - tags: ['autodocs'], - parameters: { - layout: 'padded', - goodWidgetProvider: { useShell: false, useProvider: false }, - }, -} - -export default meta -type Story = StoryObj - -const alignmentOptions: RankedVotingOption[] = [ - { id: 'food-chain', label: 'Local Food Chain', percentage: 42 }, - { id: 'web3-literacy', label: 'Web3 Literacy', percentage: 31 }, - { id: 'civic-onboarding', label: 'Civic Onboarding', percentage: 27 }, - { id: 'regenerative-markets', label: 'Regenerative Markets', percentage: 18 }, -] - -const voteSegments: VoteSegment[] = [ - { id: 'for', label: 'For', percentage: 65, tone: 'for' }, - { id: 'against', label: 'Against', percentage: 10, tone: 'against' }, - { id: 'abstain', label: 'Abstain', percentage: 3, tone: 'abstain' }, -] - -const lowQuorumSegments: VoteSegment[] = [ - { id: 'for', label: 'For', percentage: 24, tone: 'for' }, - { id: 'against', label: 'Against', percentage: 18, tone: 'against' }, - { id: 'abstain', label: 'Abstain', percentage: 8, tone: 'abstain' }, -] - -const mayaAvatar = - 'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 width=%2264%22 height=%2264%22 viewBox=%220 0 64 64%22%3E%3Crect width=%2264%22 height=%2264%22 rx=%2232%22 fill=%22%232563eb%22/%3E%3Ctext x=%2232%22 y=%2239%22 text-anchor=%22middle%22 font-family=%22Arial%22 font-size=%2224%22 font-weight=%22700%22 fill=%22white%22%3EM%3C/text%3E%3C/svg%3E' - -const voters: VoterPreview[] = [ - { id: 'maya', label: 'Maya', avatarUrl: mayaAvatar }, - { id: 'kenji', label: 'Kenji' }, - { id: 'sol', label: 'Sol' }, - { id: 'ama', label: 'Ama' }, -] - -const fundingProjects: FundingProjectAllocation[] = [ - { - id: 'education', - name: 'Education Hubs', - amount: { value: 157500, token: 'G$' }, - percentage: 35, - }, - { - id: 'merchant', - name: 'Merchant Onboard', - amount: { value: 112500, token: 'G$' }, - percentage: 25, - }, - { id: 'grants', name: 'Dev Grants', amount: { value: 90000, token: 'G$' }, percentage: 20 }, - { id: 'creator', name: 'Creator Fund', amount: { value: 90000, token: 'G$' }, percentage: 20 }, -] - -function GovernanceStoryFrame({ - children, - defaultTheme = 'light', - themeOverrides, -}: { - children: React.ReactNode - width?: number - defaultTheme?: 'light' | 'dark' - themeOverrides?: GoodWidgetThemeOverrides -}) { - const [lastAction, setLastAction] = useState('No interaction yet') - - // Mocked handlers make interaction affordances visible without wiring runtime data. - const enhancedChildren = React.Children.map(children, (child) => { - if (!React.isValidElement(child)) { - return child - } - - return React.cloneElement(child, { - onPress: (id: string) => setLastAction(`Opened ${id}`), - onCtaPress: () => setLastAction('CTA pressed'), - onProjectPress: (id: string) => setLastAction(`Opened project ${id}`), - } as Record) - }) - - return ( - - {enhancedChildren} - - {lastAction} - - - ) -} - -export const ImpactLight: Story = { - render: () => ( - - - - ), -} - -export const ImpactDarkLongDisabledMobile: Story = { - parameters: { viewport: { defaultViewport: 'mobile1' } }, - render: () => ( - - - - ), -} - -export const BalanceVariantsLight: Story = { - render: () => ( - - - - - - - ), -} - -export const BalanceDarkCompact: Story = { - render: () => ( - - - - ), -} - -export const AlignmentDefaultLight: Story = { - render: () => ( - - - - ), -} - -export const AlignmentDarkLongOptions: Story = { - render: () => ( - - - - ), -} - -export const OptimisticHighQuorumLight: Story = { - render: () => ( - - - - ), -} - -export const OptimisticDarkLowQuorumMixed: Story = { - render: () => ( - - - - ), -} - -export const FundingDistributionLight: Story = { - render: () => ( - - - - ), -} - -export const ImpactLightComponentOverride: Story = { - render: () => ( - - - - ), -} - -export const FundingDistributionDarkPopulated: Story = { - parameters: { viewport: { defaultViewport: 'mobile1' } }, - render: () => ( - - - - ), -} - -export const FundingDistributionDarkEmptyMobile: Story = { - parameters: { viewport: { defaultViewport: 'mobile1' } }, - render: () => ( - - - - ), -} diff --git a/examples/storybook/src/stories/governance-widget/GovernanceOnboarding.stories.tsx b/examples/storybook/src/stories/governance-widget/GovernanceOnboarding.stories.tsx index eb236198..8d4e7875 100644 --- a/examples/storybook/src/stories/governance-widget/GovernanceOnboarding.stories.tsx +++ b/examples/storybook/src/stories/governance-widget/GovernanceOnboarding.stories.tsx @@ -62,16 +62,18 @@ function GovernanceStoryFrame({ walletLabel: string children: ReactNode dataTestId: string - width?: any + width?: number }) { return ( - - - {walletLabel} - - - {children} + + + + {walletLabel} + + + {children} + ) } @@ -197,6 +199,7 @@ function CustodialInteractiveFlowStory() { storyProps={{ identityStatus: 'verified', initialStepId: 'welcome', + initialHouse: 'citizenship', walletAddress: '0x4E5B2D7a45C2e31a8F0d09b4bE1fA11aD3aC9F08', dataTestId: 'GovernanceOnboardingWidget-interactive-flow', transactionSteps: stepsState, @@ -274,8 +277,7 @@ export const CustodialAlignmentProfileError: Story = { }, initialFieldErrors: { projectWebpage: 'Project webpage is required', - missionStatement: 'Mission statement is required', - distributionStrategy: 'Distribution strategy is required', + missionStatement: 'Discourse link for mission statement and distribution strategy is required', }, dataTestId: 'GovernanceOnboardingWidget-alignment-profile-error', }} @@ -413,8 +415,7 @@ export const CustodialMobileDarkProfile: Story = { initialProfileDraft: { name: 'Solar Commons' }, initialFieldErrors: { projectWebpage: 'Project webpage is required', - missionStatement: 'Mission statement is required', - distributionStrategy: 'Distribution strategy is required', + missionStatement: 'Discourse link for mission statement and distribution strategy is required', }, }} /> diff --git a/examples/storybook/src/stories/governance-widget/GovernanceWidget.mdx b/examples/storybook/src/stories/governance-widget/GovernanceWidget.mdx new file mode 100644 index 00000000..f9ff004d --- /dev/null +++ b/examples/storybook/src/stories/governance-widget/GovernanceWidget.mdx @@ -0,0 +1,108 @@ +import { Canvas, Meta, Source } from '@storybook/blocks'; +import * as ShowcaseStories from './GovernanceWidgetShowcase.stories'; +import * as ThemeOverridesStories from './GovernanceWidgetThemeOverrides.stories'; +import { DocsCallout, DocsCard, DocsGrid, DocsPage, DocsSection } from '../docs/DocsLayout'; + + + + + + + + + + + + + + + + + + + + + ) +}`} + /> + + + + + Connects a mock wallet and mock RPC/subgraph directly to the real adapter — the only QA + story that exercises the runtime rather than a static state. + + + + + + Disconnected, onboarding, active membership, voting, unstaking, and error states all live in + `QA / GovernanceWidget / Runtime Fixtures`. + + + + + + + Real wallet, real GoodDaoHouses contract, no mocked reads or writes. + + + Static dashboard fixtures for screenshots and automation, plus one live-mocked-data story + for driving the real runtime by hand. + + + + + + + Use the showcase story for product-facing wallet checks against the real contract. Use the + QA fixtures for repeatable screenshots and state coverage, and the live mocked-data flow when + you need to manually exercise the real runtime without a live contract. + + + diff --git a/examples/storybook/src/stories/governance-widget/GovernanceWidgetQA.stories.tsx b/examples/storybook/src/stories/governance-widget/GovernanceWidgetQA.stories.tsx new file mode 100644 index 00000000..c21dd967 --- /dev/null +++ b/examples/storybook/src/stories/governance-widget/GovernanceWidgetQA.stories.tsx @@ -0,0 +1,387 @@ +import React, { useEffect, useRef } from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { Card, Text, YStack } from '@goodwidget/ui' +import { GovernanceWidget, type GovernanceWidgetAdapterState } from '@goodwidget/governance-widget' +import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193' +import { + getInjectedEip1193Provider, + isInjectedProviderUsable, +} from '../../fixtures/injectedEip1193' +import { + createInteractiveGovernanceEnvironment, + type InteractiveGovernanceEnvironment, +} from '../../fixtures/governanceInteractiveMock' +import { + alignmentRecipients, + createAdapterFactory, + createDashboard, + createState, +} from '../helpers/governanceWidgetStories' + +const meta: Meta = { + title: 'QA/GovernanceWidget/Runtime Fixtures', + component: GovernanceWidget, + tags: ['autodocs', 'qa'], + parameters: { + layout: 'padded', + goodWidgetProvider: { useShell: false, useProvider: false }, + }, +} + +export default meta +type Story = StoryObj + +function RuntimeStory({ + state, + defaultTheme = 'light', + useInjectedProvider = false, +}: { + state: GovernanceWidgetAdapterState + defaultTheme?: 'light' | 'dark' + useInjectedProvider?: boolean +}) { + const injectedProvider = getInjectedEip1193Provider() + + if (useInjectedProvider && !isInjectedProviderUsable(injectedProvider)) { + return ( + + + No injected wallet found + Install or enable an injected EIP-1193 wallet, then refresh Storybook. + + + ) + } + + const provider = useInjectedProvider ? injectedProvider : createCustodialEip1193Provider() + + return ( + + ) +} + +// Uses the real useGovernanceAdapter runtime (no adapterFactory override) against a +// browser-native mocked Celo RPC + Superfluid subgraph, so a human can drive the full +// onboarding -> vote -> unstake flow directly in Storybook, not just under Playwright. +function LiveMockedDataFlowStory() { + const environmentRef = useRef(null) + if (!environmentRef.current) environmentRef.current = createInteractiveGovernanceEnvironment() + + useEffect(() => { + const environment = environmentRef.current + return () => environment?.teardown() + }, []) + + const { provider, celoRpcUrl, addresses } = environmentRef.current + + return ( + + ) +} + +export const DisconnectedDashboard: Story = { + render: () => , +} + +export const LoadingConnected: Story = { + render: () => , +} + +export const OnboardingHouseSelection: Story = { + render: () => ( + + ), +} + +export const PendingAlignment: Story = { + render: () => , +} + +export const ActiveCitizenship: Story = { + render: () => , +} + +export const UpcomingVote: Story = { + render: () => ( + + ), +} + +export const ActiveAlignmentInjected: Story = { + render: () => ( + + ), +} + +export const VoteDetailOpen: Story = { + render: () => ( + + ), +} + +export const AlreadyVoted: Story = { + render: () => ( + + ), +} + +export const VoteClosedExecuted: Story = { + render: () => ( + + ), +} + +export const EmptyRecipients: Story = { + render: () => ( + + ), +} + +export const PoolUnavailableMocked: Story = { + render: () => ( + + ), +} + +export const UnsupportedChain: Story = { + render: () => , +} + +export const ActiveMembershipUnstakeReady: Story = { + render: () => ( + + ), +} + +export const UnstakeWalletConfirmation: Story = { + render: () => ( + + ), +} + +export const UnstakeSubmitted: Story = { + render: () => ( + + ), +} + +export const UnstakeRejected: Story = { + render: () => ( + + ), +} + +export const UnstakeReverted: Story = { + render: () => ( + + ), +} + +export const UnstakedReturnsToOnboarding: Story = { + render: () => ( + + ), +} + +export const RevokedMembership: Story = { + render: () => , +} + +export const FriendlyContractError: Story = { + render: () => ( + + ), +} + +// Real useGovernanceAdapter runtime (no adapterFactory override), but network mocking is +// left entirely to the caller: Playwright's runtime.spec.ts drives this story via its own +// page.route interception of `/mock-governance-rpc` and injects window.ethereum itself, so +// it can pause/resume reads and receipts mid-test. Kept distinct from LiveMockedDataFlow +// below, which is self-contained and meant for a human to open directly in Storybook. +export const RealAdapterMockedRuntime: Story = { + render: () => { + const injectedProvider = getInjectedEip1193Provider() + const provider = isInjectedProviderUsable(injectedProvider) + ? injectedProvider + : createCustodialEip1193Provider() + + return ( + + ) + }, +} + +// The live testable flow with mocked data: separated from GovernanceWidgetShowcase (which +// always uses a real wallet against the real contract), and separated from the static +// fixtures above (which never touch useGovernanceAdapter). Self-contained mocked RPC + +// wallet, so a human can drive it directly in Storybook without Playwright. +export const LiveMockedDataFlow: Story = { + render: () => , +} diff --git a/examples/storybook/src/stories/governance-widget/GovernanceWidgetShowcase.stories.tsx b/examples/storybook/src/stories/governance-widget/GovernanceWidgetShowcase.stories.tsx new file mode 100644 index 00000000..3720e109 --- /dev/null +++ b/examples/storybook/src/stories/governance-widget/GovernanceWidgetShowcase.stories.tsx @@ -0,0 +1,218 @@ +import { useCallback, useMemo, useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { Card, Text, YStack } from '@goodwidget/ui' +import { + GovernanceWidget, + type GovernanceWidgetAdapterActions, + type GovernanceWidgetAdapterState, +} from '@goodwidget/governance-widget' +import { + getInjectedEip1193Provider, + isInjectedProviderUsable, +} from '../../fixtures/injectedEip1193' +import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193' +import { + alignmentRecipients, + createDashboard, + createState, +} from '../helpers/governanceWidgetStories' + +// The GoodDaoHouses contract is not yet on the production Celo deployment — this is the +// `development-celo` address recorded in GoodProtocol PR #300 (GoodProtocol PR #299 has the +// final contract build this widget targets). FlowSplitter isn't wired to this deployment yet, +// so the funding-distribution chart is expected to render its empty state here. +const DEV_CELO_HOUSES_ADDRESS = '0x4Bc3Cdc036f21b68E034C0f1d90775fc3D725735' as const + +interface GovernanceWidgetStoryArgs { + defaultTheme: 'light' | 'dark' +} + +const meta: Meta = { + title: 'Widgets/GovernanceWidget/Showcase', + component: GovernanceWidget, + tags: ['integrator', 'manual', 'showcase'], + parameters: { layout: 'padded' }, + argTypes: { + defaultTheme: { + control: 'radio', + options: ['dark', 'light'], + description: 'Base theme applied via the widget’s own defaultTheme prop.', + }, + }, + args: { + defaultTheme: 'light', + }, +} + +export default meta +type Story = StoryObj + +function InjectedWalletStory({ defaultTheme }: GovernanceWidgetStoryArgs) { + const injectedProvider = getInjectedEip1193Provider() + + if (!isInjectedProviderUsable(injectedProvider)) { + return ( + + + No injected wallet found + + Install or enable an injected EIP-1193 wallet on Celo, then refresh Storybook. + + + + ) + } + + return ( + + ) +} + +function CustodialWalletStory({ defaultTheme }: GovernanceWidgetStoryArgs) { + try { + const provider = createCustodialEip1193Provider() + + return ( + + ) + } catch (error: unknown) { + return ( + + + Custodial fixture not configured + + {error instanceof Error ? error.message : 'Set a local private key in custodialEip1193.ts'} + + + + ) + } +} + +const activeDemoVote: GovernanceWidgetAdapterState['dashboard']['alignmentVoting'] = { + ...createDashboard().alignmentVoting, + voteId: 'alignment-active-demo', + title: 'House of Alignment Community Grants', + summaryLabel: 'Voting open · 2 days remaining', + options: [ + { id: alignmentRecipients[0], label: 'Local Food Chain', percentage: 42 }, + { id: alignmentRecipients[1], label: 'Web3 Literacy', percentage: 31 }, + { id: alignmentRecipients[2], label: 'Civic Onboarding', percentage: 27 }, + ], + recipients: [...alignmentRecipients], + allocationsBps: { + [alignmentRecipients[0]]: 4200, + [alignmentRecipients[1]]: 3000, + [alignmentRecipients[2]]: 2000, + }, + allocationTotalBps: 9200, + canVote: true, + hasVoted: false, + isVotingOpen: true, + executed: false, + finalizedUnits: {}, + disabledReason: undefined, +} + +const previousDemoVote: GovernanceWidgetAdapterState['dashboard']['alignmentVoting'] = { + ...activeDemoVote, + voteId: 'alignment-previous-demo', + title: 'Previous Round: Regional Access Grants', + summaryLabel: 'Final units executed', + options: [ + { id: alignmentRecipients[0], label: 'Local Food Chain', percentage: 50 }, + { id: alignmentRecipients[1], label: 'Web3 Literacy', percentage: 30 }, + { id: alignmentRecipients[2], label: 'Civic Onboarding', percentage: 20 }, + ], + allocationsBps: {}, + allocationTotalBps: 0, + canVote: false, + hasVoted: true, + isVotingOpen: false, + executed: true, + finalizedUnits: { + [alignmentRecipients[0]]: '500000', + [alignmentRecipients[1]]: '300000', + [alignmentRecipients[2]]: '200000', + }, + disabledReason: 'This vote has already been executed.', +} + +function DemoGovernanceWidget({ defaultTheme }: GovernanceWidgetStoryArgs) { + const initialState = useMemo( + () => createState('active_alignment', { + dashboard: createDashboard({ + alignmentVoting: activeDemoVote, + alignmentVotingHistory: [previousDemoVote], + }), + }), + [], + ) + const [state, setState] = useState(initialState) + + const setVoteAllocation = useCallback((recipientId: string, basisPoints: number) => { + setState((previous) => { + const voting = previous.dashboard.alignmentVoting + if (!(recipientId in voting.allocationsBps)) return previous + const allocationsBps = { + ...voting.allocationsBps, + [recipientId]: Math.max(0, Math.min(10_000, Math.trunc(basisPoints))), + } + return { + ...previous, + dashboard: { + ...previous.dashboard, + alignmentVoting: { + ...voting, + allocationsBps, + allocationTotalBps: Object.values(allocationsBps).reduce((total, amount) => total + amount, 0), + }, + }, + } + }) + }, []) + + const actions = useMemo(() => ({ + connect: async () => {}, + switchToCelo: async () => {}, + refresh: async () => {}, + retry: async () => {}, + selectHouse: () => {}, + register: async () => {}, + unstake: async () => {}, + openVote: () => setState((previous) => ({ ...previous, status: 'vote_detail' })), + closeVote: () => setState((previous) => ({ ...previous, status: 'active_alignment' })), + setVoteAllocation, + submitVote: async () => {}, + startIdentityVerification: async () => {}, + }), [setVoteAllocation]) + + const adapterFactory = useCallback(() => ({ state, actions }), [actions, state]) + + return +} + +// Real wallet, real dev-celo GoodDaoHouses contract, no mocked reads or writes — this is the +// live integrator-facing surface, deliberately kept separate from the QA fixtures/mocked flow. +export const InjectedWallet: Story = { + render: ({ defaultTheme }) => , +} + +export const CustodialWallet: Story = { + tags: ['!dev'], + render: ({ defaultTheme }) => , +} + +export const Demo: Story = { + render: ({ defaultTheme }) => , +} diff --git a/examples/storybook/src/stories/governance-widget/GovernanceWidgetThemeOverrides.stories.tsx b/examples/storybook/src/stories/governance-widget/GovernanceWidgetThemeOverrides.stories.tsx new file mode 100644 index 00000000..4ab69bab --- /dev/null +++ b/examples/storybook/src/stories/governance-widget/GovernanceWidgetThemeOverrides.stories.tsx @@ -0,0 +1,154 @@ +/** + * GovernanceWidget — Theme Overrides — demonstrates the widget's public theming + * surface as live color-picker controls. The code snippet is generated from the + * live arg values, so it can never drift from what's rendered. + * + * GovernanceWidget's own named theme components (packages/governance-widget/src/shared.tsx) + * are `GovernanceWrapper` (the card shell every section — impact, alignment voting, + * optimistic voting, funding distribution — renders inside of) and `ImpactCard` / + * `ImpactCardAction` (the impact summary card and its call-to-action button). All other + * governance surfaces reuse shared @goodwidget/ui components (`BalanceCard`, `Button`) + * that already have theme keys wired for other widgets — those are documented as + * reference-only below since they aren't governance-specific. + * + * Controls are wired for `dark_GovernanceWrapper` and `dark_ImpactCard` / + * `dark_ImpactCardAction` — the handful of high-impact targets that visibly shift the + * default brand, not exhaustive coverage of every value. + */ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import type { GoodWidgetThemeOverrides } from '@goodwidget/core' +import { ThemedDashboardStory } from '../helpers/governanceWidgetStories' +import { DocsCallout, DocsList } from '../docs/DocsLayout' + +const REFERENCE_ONLY_TARGETS: Array<{ name: string; fields: string[] }> = [ + { name: 'BalanceCard', fields: ['background', 'borderColor', 'shadowColor'] }, + { name: 'Button', fields: ['background', 'color', 'borderColor'] }, +] + +function CodeBlock({ children }: { children: string }) { + return ( +
+      {children}
+    
+ ) +} + +interface OverridesArgs { + defaultTheme: 'light' | 'dark' + wrapperBorderColor: string + wrapperShadowColor: string + impactCardBackground: string + impactCardActionBackground: string +} + +function buildThemeOverrides(args: OverridesArgs): GoodWidgetThemeOverrides { + const componentThemes = { + GovernanceWrapper: { + borderColor: args.wrapperBorderColor, + shadowColor: args.wrapperShadowColor, + }, + ImpactCard: { + background: args.impactCardBackground, + }, + ImpactCardAction: { + white: args.impactCardActionBackground, + }, + } + + return { + themes: { + dark_GovernanceWrapper: componentThemes.GovernanceWrapper, + light_GovernanceWrapper: componentThemes.GovernanceWrapper, + dark_ImpactCard: componentThemes.ImpactCard, + light_ImpactCard: componentThemes.ImpactCard, + dark_ImpactCardAction: componentThemes.ImpactCardAction, + light_ImpactCardAction: componentThemes.ImpactCardAction, + }, + } +} + +const meta: Meta = { + title: 'Widgets/GovernanceWidget/Theme overrides', + tags: ['integrator', 'showcase'], + parameters: { layout: 'padded' }, + argTypes: { + defaultTheme: { + control: 'radio', + options: ['light', 'dark'], + description: 'Base theme applied via the widget’s own defaultTheme prop.', + }, + wrapperBorderColor: { control: 'color', description: 'themes.dark_GovernanceWrapper.borderColor' }, + wrapperShadowColor: { control: 'color', description: 'themes.dark_GovernanceWrapper.shadowColor' }, + impactCardBackground: { control: 'color', description: 'themes.dark_ImpactCard.backgroundColor' }, + impactCardActionBackground: { + control: 'color', + description: 'themes.dark_ImpactCardAction.backgroundColor', + }, + }, + args: { + defaultTheme: 'light', + wrapperBorderColor: '#7C3AED', + wrapperShadowColor: '#7C3AED', + impactCardBackground: '#1E1B4B', + impactCardActionBackground: '#7C3AED', + }, +} +export default meta +type Story = StoryObj + +export const Playground: Story = { + render: (args) => { + const themeOverrides = buildThemeOverrides(args) + return ( +
+ + {``} + + + + +
  • + dark_GovernanceWrapper / light_GovernanceWrapper: backgroundColor, + borderColor, color, shadowColor — wired to the controls above (borderColor and + shadowColor only) +
  • +
  • + dark_ImpactCard / light_ImpactCard: backgroundColor, borderColor, + color — wired to the controls above (backgroundColor only) +
  • +
  • + dark_ImpactCardAction / light_ImpactCardAction: backgroundColor, + color — wired to the controls above (backgroundColor only) +
  • + {REFERENCE_ONLY_TARGETS.map((target) => ( +
  • + + dark_{target.name} / light_{target.name} + + : {target.fields.join(', ')} — shared with other widgets, not governance-specific +
  • + ))} +
    +
    + + +
    + ) + }, +} diff --git a/examples/storybook/src/stories/helpers/governanceWidgetStories.tsx b/examples/storybook/src/stories/helpers/governanceWidgetStories.tsx new file mode 100644 index 00000000..9506ae54 --- /dev/null +++ b/examples/storybook/src/stories/helpers/governanceWidgetStories.tsx @@ -0,0 +1,185 @@ +import type { GoodWidgetThemeOverrides } from '@goodwidget/core' +import { + GovernanceWidget, + type GovernanceWidgetAdapterFactory, + type GovernanceWidgetAdapterState, + type GovernanceWidgetStatus, +} from '@goodwidget/governance-widget' + +const connectedAddress = '0x4E5B2D7a45C2e31a8F0d09b4bE1fA11aD3aC9F08' as const +export const alignmentRecipients = [ + '0x1111111111111111111111111111111111111111', + '0x2222222222222222222222222222222222222222', + '0x3333333333333333333333333333333333333333', +] as const + +export function createDashboard( + overrides: Partial = {}, +): GovernanceWidgetAdapterState['dashboard'] { + return { + impact: { + title: 'Distributed', + metrics: [ + { label: 'UBI Pool', amount: { value: 12400000, token: 'G$' } }, + { + label: 'Impact Pool', + amount: { value: 5234891, token: 'G$', isStreaming: true, streamLabel: 'Live stream active' }, + }, + ], + description: + 'Empowering 640k+ people worldwide through transparent, decentralized funding for public goods.', + ctaLabel: 'View Impact Report Q3', + }, + activeMembers: { + icon: 'check' as const, + title: 'Active Members', + amount: 12402, + amountType: 'raw' as const, + metadataType: 'time-window' as const, + metadata: { label: 'Active members only', tone: 'muted' as const, icon: 'info' as const }, + }, + alignmentVoting: { + voteId: 'alignment-current', + title: 'Q3 House Of Alignment Funding Allocation', + summaryLabel: 'Current top 3 voted', + options: [ + { id: alignmentRecipients[0], label: 'Local Food Chain', percentage: 42 }, + { id: alignmentRecipients[1], label: 'Web3 Literacy', percentage: 31 }, + { id: alignmentRecipients[2], label: 'Civic Onboarding', percentage: 27 }, + ], + recipients: [...alignmentRecipients], + allocationsBps: { + [alignmentRecipients[0]]: 4200, + [alignmentRecipients[1]]: 3100, + [alignmentRecipients[2]]: 2700, + }, + allocationTotalBps: 10000, + canVote: false, + hasVoted: false, + isVotingOpen: true, + executed: false, + finalizedUnits: {}, + disabledReason: 'Only active House of Alignment members can vote.', + }, + fundingDistribution: { + title: 'Funding distribution', + centerLabel: 'Mocked pool total', + totalAmount: { value: 450000, token: 'G$', isStreaming: true, streamLabel: 'Mock pool data' }, + projects: [ + { id: 'education', name: 'Education Hubs', amount: { value: 157500, token: 'G$' }, percentage: 35 }, + { id: 'merchant', name: 'Merchant Onboard', amount: { value: 112500, token: 'G$' }, percentage: 25 }, + { id: 'grants', name: 'Dev Grants', amount: { value: 90000, token: 'G$' }, percentage: 20 }, + { id: 'creator', name: 'Creator Fund', amount: { value: 90000, token: 'G$' }, percentage: 20 }, + ], + isStreaming: true, + emptyStateLabel: 'No active funding distribution yet.', + }, + ...overrides, + } +} + +export function createState( + status: GovernanceWidgetStatus, + overrides: Partial = {}, +): GovernanceWidgetAdapterState { + const isConnected = status !== 'disconnected' + const member: GovernanceWidgetAdapterState['member'] = + status === 'active_citizenship' || status === 'active_alignment' || status === 'revoked' + ? { + house: status === 'active_alignment' ? 'alignment' : 'citizenship', + status: status === 'revoked' ? 'revoked' : 'active', + stakedAmount: 250000000000000000000n, + joinedAt: Date.UTC(2026, 0, 10), + updatedAt: Date.UTC(2026, 2, 1), + unstakedAt: null, + memberIndex: 0n, + name: status === 'active_alignment' ? 'Solar Commons' : 'Maya Citizen', + socialLinks: 'https://twitter.com/gooddollar', + projectWebpage: 'https://solar.example', + missionStatement: 'Expand regenerative local access.', + distributionStrategy: 'Allocate quarterly grants through community review.', + } + : null + + return { + status, + address: isConnected ? connectedAddress : null, + chainId: status === 'unsupported_chain' ? 1 : 42220, + identityStatus: status === 'onboarding_required' ? 'unverified' : 'verified', + identityVerificationUrl: null, + member, + dashboard: createDashboard(), + selectedHouse: 'citizenship', + onboardingStepId: undefined, + profileDraft: {}, + stakeAmountLabel: '250 G$', + minimumStakeAmounts: { citizenship: 250000000000000000000n, alignment: 500000000000000000000n }, + transactionSteps: [ + { id: 'prepare', title: 'Prepare wallet balance', status: 'completed' }, + { id: 'approve', title: 'Approve governance stake', status: 'active' }, + { id: 'stake', title: 'Lock the membership stake', status: 'pending' }, + { id: 'finalize', title: 'Finalize governance access', status: 'pending' }, + ], + registrationHash: null, + transaction: { kind: null, status: 'idle', hash: null, error: null }, + unstakeAvailability: { + canUnstake: false, + unlockAt: Date.UTC(2026, 8, 1, 12), + disabledReason: 'Membership remains locked until the current governance term has passed.', + }, + lifecycleNotice: null, + error: null, + ...overrides, + } +} + +export function createAdapterFactory(state: GovernanceWidgetAdapterState): GovernanceWidgetAdapterFactory { + return () => ({ + state, + actions: { + connect: async () => {}, + switchToCelo: async () => {}, + refresh: async () => {}, + retry: async () => {}, + selectHouse: () => {}, + register: async () => {}, + unstake: async () => {}, + openVote: () => {}, + closeVote: () => {}, + setVoteAllocation: () => {}, + submitVote: async () => {}, + startIdentityVerification: async () => {}, + }, + }) +} + +// A single fully-populated dashboard state (active alignment member, open vote, live funding +// distribution) used by the theme-overrides Playground so every themeable governance surface +// (GovernanceWrapper, ImpactCard, ImpactCardAction, the shared BalanceCard/Button) renders at +// once behind a mocked adapterFactory — no wallet or network required. +export function ThemedDashboardStory({ + themeOverrides, + defaultTheme = 'dark', +}: { + themeOverrides?: GoodWidgetThemeOverrides + defaultTheme?: 'light' | 'dark' +}) { + const state = createState('active_alignment', { + dashboard: createDashboard({ + alignmentVoting: { + ...createDashboard().alignmentVoting, + canVote: true, + disabledReason: undefined, + }, + }), + }) + + return ( + + ) +} diff --git a/packages/governance-widget/package.json b/packages/governance-widget/package.json index aaf4b2b5..63809cb2 100644 --- a/packages/governance-widget/package.json +++ b/packages/governance-widget/package.json @@ -47,10 +47,12 @@ } }, "dependencies": { + "@goodsdks/citizen-sdk": "1.2.5", "@goodwidget/core": "workspace:*", "@goodwidget/ui": "workspace:*", "react-native-svg": "15.15.5", - "tamagui": "1.121.0" + "tamagui": "1.121.0", + "viem": "^2.0.0" }, "devDependencies": { "@types/react": "^18.3.0", diff --git a/packages/governance-widget/src/AlignmentVotingProposalCard.tsx b/packages/governance-widget/src/AlignmentVotingProposalCard.tsx index eb1bfbdb..288f1be9 100644 --- a/packages/governance-widget/src/AlignmentVotingProposalCard.tsx +++ b/packages/governance-widget/src/AlignmentVotingProposalCard.tsx @@ -35,7 +35,6 @@ export function AlignmentVotingProposalCard({ return ( onPress(id) : undefined} diff --git a/packages/governance-widget/src/BalanceCard.tsx b/packages/governance-widget/src/BalanceCard.tsx index bb0ea7ba..615c03f1 100644 --- a/packages/governance-widget/src/BalanceCard.tsx +++ b/packages/governance-widget/src/BalanceCard.tsx @@ -26,7 +26,6 @@ export function BalanceCard({ return ( diff --git a/packages/governance-widget/src/FundingDistributionChart.tsx b/packages/governance-widget/src/FundingDistributionChart.tsx index 3957911b..bc1f13e3 100644 --- a/packages/governance-widget/src/FundingDistributionChart.tsx +++ b/packages/governance-widget/src/FundingDistributionChart.tsx @@ -133,6 +133,7 @@ function FundingDistributionChartContent({ totalAmount, projects, isStreaming = false, + stateLabel, onProjectPress, }: FundingDistributionChartProps) { const theme = useTheme() @@ -145,6 +146,11 @@ function FundingDistributionChartContent({ {title} + {stateLabel ? ( + + {stateLabel} + + ) : null} + ) diff --git a/packages/governance-widget/src/GovernanceOnboardingWidget.tsx b/packages/governance-widget/src/GovernanceOnboardingWidget.tsx index 088b4b45..4439c7a8 100644 --- a/packages/governance-widget/src/GovernanceOnboardingWidget.tsx +++ b/packages/governance-widget/src/GovernanceOnboardingWidget.tsx @@ -2,30 +2,29 @@ import { useMemo } from 'react' import { PageWizardProvider } from '@goodwidget/ui' import { GovernanceOnboardingFlow } from './onboarding/GovernanceOnboardingFlow' import { DEFAULT_FINAL_ACTIONS, DEFAULT_TRANSACTION_STEPS, ONBOARDING_STEPS } from './onboarding/constants' +import { HOUSE_COPY } from './onboarding/copy' import type { GovernanceOnboardingStepId, GovernanceOnboardingWidgetProps, GovernanceWizardData, } from './types' -/** - * GovernanceOnboardingWidget keeps the five onboarding pages UI-only for now. - * The component owns light/dark-safe visuals, simple local navigation, and a - * presentational state contract that stories and later runtime integrations can drive. - */ export function GovernanceOnboardingWidget({ currentStepId, initialStepId = 'welcome', identityStatus = 'verified', walletAddress, initialHouse, - disabledHouseOptions = [], initialProfileDraft, initialFieldErrors = {}, - stakeAmountLabel = '250 G$', + stakeAmountLabel, + stakeAmountLabels, transactionSteps = DEFAULT_TRANSACTION_STEPS, finalActions = DEFAULT_FINAL_ACTIONS, dataTestId, + onHouseChange, + onIdentityVerificationPress, + onProfileSubmit, onStepChange, onFinalActionPress, }: GovernanceOnboardingWidgetProps) { @@ -36,6 +35,12 @@ export function GovernanceOnboardingWidget({ }), [initialHouse, initialProfileDraft], ) + const resolvedStakeAmountLabels = stakeAmountLabels ?? (stakeAmountLabel + ? { citizenship: stakeAmountLabel, alignment: stakeAmountLabel } + : { + citizenship: HOUSE_COPY.citizenship.defaultStakeAmount, + alignment: HOUSE_COPY.alignment.defaultStakeAmount, + }) return ( diff --git a/packages/governance-widget/src/GovernanceWidget.tsx b/packages/governance-widget/src/GovernanceWidget.tsx new file mode 100644 index 00000000..77318584 --- /dev/null +++ b/packages/governance-widget/src/GovernanceWidget.tsx @@ -0,0 +1,764 @@ +import { createElement, useEffect, useMemo, useRef, useState } from 'react' +import { useWallet, WalletControls } from '@goodwidget/core' +import { + Button, + ButtonText, + Card, + Heading, + Icon, + resolveThemeColor, + Spinner, + Text, + XStack, + YStack, +} from '@goodwidget/ui' +import { useTheme } from 'tamagui' +import { AlignmentVotingProposalCard } from './AlignmentVotingProposalCard' +import { BalanceCard } from './BalanceCard' +import { FundingDistributionChart } from './FundingDistributionChart' +import { GovernanceOnboardingWidget } from './GovernanceOnboardingWidget' +import { GovernanceWidgetProvider } from './GovernanceWidgetProvider' +import { ImpactCard } from './ImpactCard' +import { useGovernanceAdapter } from './adapter' +import { + getGovernanceVotingDisabledReason, + type GovernanceWidgetAdapterActions, + type GovernanceWidgetAdapterFactoryInput, + type GovernanceWidgetAdapterResult, + type GovernanceWidgetAdapterState, + type GovernanceWidgetProps, +} from './widgetRuntimeContract' +import { isActiveStatus } from './adapter' +import { formatStakeAmount } from './sdks/contracts' + +const GOVERNANCE_WIDGET_MAX_WIDTH = 480 + +function formatMemberDate(timestamp: number | null): string { + if (!timestamp) return 'Not available' + return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format( + new Date(timestamp), + ) +} + +function formatMemberDateTime(timestamp: number | null): string { + if (!timestamp) return 'Not available' + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(new Date(timestamp)) +} + +function GovernanceHeader({ + state, + actions, +}: { + state: GovernanceWidgetAdapterState + actions: GovernanceWidgetAdapterActions +}) { + const { address: walletContextAddress } = useWallet() + + return ( + + + + + + + GoodDAO + + {state.address && walletContextAddress ? ( + + ) : state.address ? ( + + Connected wallet + {`${state.address.slice(0, 6)}…${state.address.slice(-4)}`} + + ) : ( + + )} + + + ) +} + +function RuntimeNotice({ + state, + actions, +}: { + state: GovernanceWidgetAdapterState + actions: GovernanceWidgetAdapterActions +}) { + if (state.status === 'loading') { + return ( + + + + Loading wallet, identity, membership, and governance data… + + + ) + } + + if (state.status === 'unsupported_chain') { + return ( + + + + Switch to Celo Mainnet + + + GoodDAO Houses are deployed on Celo Mainnet. Switch networks to continue with membership actions. + + + + + ) + } + + if (state.status === 'friendly_error') { + return ( + + + + Governance data unavailable + + {state.error ?? 'Please try again.'} + + + + ) + } + + return null +} + +function GovernanceDashboard({ + state, + actions, +}: { + state: GovernanceWidgetAdapterState + actions: GovernanceWidgetAdapterActions +}) { + const carouselRef = useRef(null) + const [activeVotingIndex, setActiveVotingIndex] = useState(0) + const votingSections = [ + state.dashboard.alignmentVoting, + ...(state.dashboard.alignmentVotingHistory ?? []), + ] + + const goToVotingSection = (index: number) => { + const nextIndex = Math.max(0, Math.min(index, votingSections.length - 1)) + setActiveVotingIndex(nextIndex) + carouselRef.current?.scrollTo({ + left: nextIndex * carouselRef.current.clientWidth, + behavior: 'smooth', + }) + } + + return ( + + + + + + {votingSections.map((voting, index) => ( + + actions.openVote() : undefined} + /> + + ))} + + + {votingSections.length > 1 ? ( + + + Voting round {activeVotingIndex + 1} of {votingSections.length} + + + + + + + ) : null} + {state.dashboard.alignmentVoting.options.length === 0 ? ( + + + {state.dashboard.alignmentVoting.disabledReason ?? + 'No House of Alignment members have been assigned yet. Voting will open shortly.'} + + + ) : null} + {state.dashboard.alignmentVoting.hasVoted ? ( + + + You already voted in this cycle. Ballot updates are not available for this contract version. + + + ) : null} + + + ) +} + +function PendingAlignmentState({ state }: { state: GovernanceWidgetAdapterState }) { + return ( + + + Alignment membership pending + + Your House of Alignment application is recorded on-chain and is waiting for + committee approval. No further transaction is required while it is pending. + + + Wallet: {state.address ?? 'Not connected'} + + + + ) +} + +function MembershipExitState({ + state, + actions, +}: { + state: GovernanceWidgetAdapterState + actions: GovernanceWidgetAdapterActions +}) { + const transaction = state.transaction.kind === 'unstake' ? state.transaction : null + const isPending = + transaction?.status === 'wallet_confirmation' || + transaction?.status === 'submitted' || + transaction?.status === 'confirmed' + const canSubmit = state.unstakeAvailability.canUnstake && !isPending + + return ( + + + Membership stake + + Active governance stakes remain locked for one full term. Once the lock expires, + unstaking returns your G$ and removes your active membership. + + + Available from + + {formatMemberDateTime(state.unstakeAvailability.unlockAt)} + + + {!state.unstakeAvailability.canUnstake ? ( + + {state.unstakeAvailability.disabledReason} + + ) : null} + {transaction?.status === 'wallet_confirmation' ? ( + Confirm the unstake transaction in your wallet. + ) : null} + {transaction?.status === 'submitted' ? ( + + Transaction submitted. Waiting for a successful Celo receipt… + + ) : null} + {transaction?.status === 'rejected' || + transaction?.status === 'reverted' || + transaction?.status === 'failed' ? ( + + {transaction.error ?? 'The unstake transaction did not complete.'} + + ) : null} + + + + ) +} + +function RevokedState({ state }: { state: GovernanceWidgetAdapterState }) { + return ( + + + Membership revoked + + This governance membership was revoked and cannot be reactivated from the widget. + Contact the GoodDAO governance team if you believe this status is incorrect. + + + Wallet: {state.address ?? 'Not connected'} + + + + ) +} + +function GovernanceSignupBanner({ onResume }: { onResume: () => void }) { + return ( + + + Sign up and stake to participate in GoodDAO + + + + ) +} + +interface WidgetBounds { + left: number + width: number +} + +function MemberFooter({ + state, + bounds, +}: { + state: GovernanceWidgetAdapterState + bounds?: WidgetBounds +}) { + const memberStatus = state.member?.status ?? ( + state.status === 'disconnected' ? 'not connected' : + state.status === 'onboarding_required' ? 'onboarding' : + state.status === 'pending_alignment' ? 'pending approval' : + state.status === 'revoked' ? 'revoked' : 'not available' + ) + const house = state.member + ? state.member.house === 'alignment' ? 'House of Alignment' : 'House of Citizenship' + : 'No house selected' + + return ( + + + + House: {house} + + + Joined: {state.member ? formatMemberDate(state.member.joinedAt) : 'Not yet'} + + + Status: {memberStatus} + + + + ) +} + +function GovernanceVoteDetail({ + state, + actions, +}: { + state: GovernanceWidgetAdapterState + actions: GovernanceWidgetAdapterActions +}) { + const theme = useTheme() + const sliderAccentColor = resolveThemeColor(theme, '$primary') + const sliderTrackColor = resolveThemeColor(theme, '$backgroundHover') + const vote = state.dashboard.alignmentVoting + const disabledReason = getGovernanceVotingDisabledReason(vote) + const voteTransactionPending = + state.transaction.kind === 'vote' && + ( + state.transaction.status === 'wallet_confirmation' || + state.transaction.status === 'submitted' || + state.transaction.status === 'confirmed' + ) + const canSubmit = + vote.canVote && + vote.allocationTotalBps === 10000 && + !vote.hasVoted && + vote.isVotingOpen && + !voteTransactionPending + const isReadOnly = vote.hasVoted || vote.executed || voteTransactionPending + + return ( + + + + {vote.title} + + + + Allocate basis points across the recipients captured when this vote opened. + Your allocation must total exactly 10,000 basis points. + + + {vote.options.map((option) => { + const currentValue = vote.allocationsBps[option.id] ?? 0 + const availablePoints = Math.max(0, 10_000 - vote.allocationTotalBps) + const sliderMax = Math.max(currentValue, currentValue + availablePoints) + + return isReadOnly ? ( + + {option.label}: {vote.executed ? `${vote.finalizedUnits[option.id] ?? '0'} finalized units` : `${currentValue} bps`} + + ) : ( + + + {option.label} + {currentValue} bps + + {createElement('input', { + type: 'range', + min: 0, + max: sliderMax, + step: 100, + value: currentValue, + 'aria-label': `${option.label} allocation`, + onChange: (event: { currentTarget?: { value?: string } }) => { + actions.setVoteAllocation(option.id, Number.parseInt(event.currentTarget?.value ?? '0', 10)) + }, + style: { + width: '100%', + accentColor: sliderAccentColor, + backgroundColor: sliderTrackColor, + color: sliderAccentColor, + cursor: 'pointer', + }, + })} + + 0 bps + Up to {sliderMax} bps + + + ) + })} + + + Allocation total: {vote.allocationTotalBps} / 10,000 bps + + {!isReadOnly ? ( + + Available points: {Math.max(0, 10_000 - vote.allocationTotalBps)} bps + + ) : null} + {vote.hasVoted ? ( + + Already voted — this contract does not support ballot replacement. + + ) : null} + {!canSubmit && !voteTransactionPending ? ( + {disabledReason ?? 'Voting is unavailable.'} + ) : null} + {state.transaction.kind === 'vote' && state.transaction.status === 'wallet_confirmation' ? ( + Confirm the vote in your wallet. + ) : null} + {state.transaction.kind === 'vote' && state.transaction.status === 'submitted' ? ( + Vote submitted. Waiting for confirmation… + ) : null} + {state.transaction.kind === 'vote' && state.transaction.status === 'confirmed' ? ( + Vote confirmed on Celo. + ) : null} + {state.transaction.kind === 'vote' && state.transaction.error ? ( + {state.transaction.error} + ) : null} + + + + ) +} + +function GovernanceWidgetView({ + adapter, + testId, +}: { + adapter: GovernanceWidgetAdapterResult + testId?: string +}) { + const { state, actions } = adapter + const widgetRef = useRef(null) + const [widgetBounds, setWidgetBounds] = useState() + useEffect(() => { + const element = widgetRef.current + if (!element || typeof ResizeObserver === 'undefined') return + + const updateBounds = () => { + const { left, width } = element.getBoundingClientRect() + setWidgetBounds({ left, width }) + } + + updateBounds() + const observer = new ResizeObserver(updateBounds) + observer.observe(element) + return () => observer.disconnect() + }, []) + + // Skip is a view-only choice, not membership state: it never touches the + // contract, so a reload or a wallet reconnect (address change) drops back + // to onboarding rather than silently remembering the skip. + const [isOnboardingSkipped, setIsOnboardingSkipped] = useState(false) + useEffect(() => { + setIsOnboardingSkipped(false) + }, [state.address]) + + const shouldShowDashboard = + state.status === 'disconnected' || + state.status === 'loading' || + state.status === 'unsupported_chain' || + state.status === 'friendly_error' || + isActiveStatus(state.status) || + (state.status === 'onboarding_required' && isOnboardingSkipped) + + return ( + + + + {state.error && state.status !== 'friendly_error' && state.transaction.status === 'idle' ? ( + + + Governance action unavailable + {state.error} + + + ) : null} + {state.status === 'vote_detail' ? : null} + {state.status === 'onboarding_required' && isOnboardingSkipped ? ( + setIsOnboardingSkipped(false)} /> + ) : null} + {state.status === 'onboarding_required' && !isOnboardingSkipped ? ( + + {state.lifecycleNotice ? ( + + {state.lifecycleNotice} + + ) : null} + { + void actions.startIdentityVerification() + }} + onProfileSubmit={(profileDraft) => { + void actions.register(profileDraft) + }} + /> + + + ) : null} + {state.status === 'pending_alignment' ? : null} + {state.status === 'revoked' ? : null} + {shouldShowDashboard ? : null} + {isActiveStatus(state.status) ? : null} + + + ) +} + +function DefaultGovernanceWidgetContent({ + adapterInput, + testId, +}: { + adapterInput: GovernanceWidgetAdapterFactoryInput + testId?: string +}) { + const adapter = useGovernanceAdapter(adapterInput) + return +} + +function InjectedGovernanceWidgetContent({ + adapterFactory, + adapterInput, + testId, +}: { + adapterFactory: NonNullable + adapterInput: GovernanceWidgetAdapterFactoryInput + testId?: string +}) { + const adapter = adapterFactory(adapterInput) + return +} + +export function GovernanceWidget({ + provider, + themeOverrides, + config, + defaultTheme = 'light', + adapterFactory, + testId, + environment, + celoRpcUrl, + addresses, +}: GovernanceWidgetProps) { + const adapterInput = useMemo( + () => ({ environment, celoRpcUrl, addresses }), + [addresses, celoRpcUrl, environment], + ) + + return ( + + {adapterFactory ? ( + + ) : ( + + )} + + ) +} diff --git a/packages/governance-widget/src/adapter.ts b/packages/governance-widget/src/adapter.ts new file mode 100644 index 00000000..c8494c2f --- /dev/null +++ b/packages/governance-widget/src/adapter.ts @@ -0,0 +1,289 @@ +import { useCallback, useMemo } from 'react' +import { useWallet } from '@goodwidget/core' +import { getAddress } from 'viem' +import type { GovernanceDashboardState } from './widgetRuntimeContract' +import type { + GovernanceWidgetAdapterActions, + GovernanceWidgetAdapterFactoryInput, + GovernanceWidgetAdapterResult, + GovernanceWidgetAdapterState, + GovernanceWidgetStatus, + GovernanceTransactionState, + GovernanceVotingState, +} from './widgetRuntimeContract' +import { + CELO_CHAIN_ID, + createGovernancePublicClient, + requestCeloMainnetSwitch, + resolveGovernanceAddresses, +} from './sdks/contracts' +import type { GovernanceStakeRequirements } from './sdks/contractReads' +import { + createTransactionSteps, + friendlyGovernanceError, + isActiveStatus, + useGovernanceMembership, +} from './hooks/useGovernanceMembership' +import { + createEmptyVotingState, + useGovernanceVoting, +} from './hooks/useGovernanceVoting' +import { + createFundingLoadingState, + useGovernanceFunding, +} from './hooks/useGovernanceFunding' + +const IMPACT_METRICS: GovernanceDashboardState['impact'] = { + title: 'Distributed', + metrics: [ + { label: 'UBI Pool', amount: { value: '—', token: 'G$' } }, + { label: 'Impact Pool', amount: { value: '—', token: 'G$' } }, + ], + description: + 'Empowering people worldwide through transparent, decentralized funding for public goods.', + ctaLabel: 'View Impact Report Q3', +} + +const EMPTY_STAKES: GovernanceStakeRequirements = { + citizenship: 0n, + alignment: 0n, +} + +function createDashboardState(params: { + activeMemberCount?: number + voting?: GovernanceVotingState + funding?: GovernanceDashboardState['fundingDistribution'] +} = {}): GovernanceDashboardState { + return { + impact: IMPACT_METRICS, + activeMembers: { + icon: 'check', + title: 'Active Members', + amount: params.activeMemberCount ?? 0, + amountType: 'raw', + metadataType: 'time-window', + metadata: { label: 'Active members only', tone: 'muted', icon: 'info' }, + }, + alignmentVoting: params.voting ?? createEmptyVotingState(), + fundingDistribution: params.funding ?? createFundingLoadingState(), + } +} + +function createInitialState( + status: GovernanceWidgetStatus = 'disconnected', +): GovernanceWidgetAdapterState { + return { + status, + address: null, + chainId: null, + identityStatus: 'unverified', + identityVerificationUrl: null, + member: null, + dashboard: createDashboardState(), + selectedHouse: 'citizenship', + onboardingStepId: undefined, + profileDraft: {}, + stakeAmountLabel: '0 G$', + minimumStakeAmounts: EMPTY_STAKES, + transactionSteps: createTransactionSteps('idle'), + registrationHash: null, + transaction: { kind: null, status: 'idle', hash: null, error: null }, + unstakeAvailability: { + canUnstake: false, + unlockAt: null, + disabledReason: 'Only active members can unstake.', + }, + lifecycleNotice: null, + error: null, + } +} + +function isPendingTransaction(transaction: GovernanceTransactionState): boolean { + return transaction.status === 'wallet_confirmation' || transaction.status === 'submitted' +} + +export function selectGovernanceTransaction( + membershipTransaction: GovernanceTransactionState, + votingTransaction: GovernanceTransactionState, + isVoteDetailOpen = false, +): GovernanceTransactionState { + if (isPendingTransaction(votingTransaction)) return votingTransaction + if (isPendingTransaction(membershipTransaction)) return membershipTransaction + if (isVoteDetailOpen && votingTransaction.status !== 'idle') return votingTransaction + return membershipTransaction.status !== 'idle' ? membershipTransaction : votingTransaction +} + +export function useGovernanceAdapter({ + environment = 'production', + celoRpcUrl, + addresses: addressOverrides, +}: GovernanceWidgetAdapterFactoryInput = {}): GovernanceWidgetAdapterResult { + const { address, chainId, provider, connect } = useWallet() + const publicClient = useMemo( + () => createGovernancePublicClient(celoRpcUrl), + [celoRpcUrl], + ) + const addresses = useMemo( + () => resolveGovernanceAddresses(addressOverrides), + [addressOverrides], + ) + const account = useMemo( + () => address ? getAddress(address.toLowerCase()) : null, + [address], + ) + const resolvedChainId = chainId ?? null + const runtimeEnabled = Boolean(addresses.houses) + + const membership = useGovernanceMembership({ + account, + chainId: resolvedChainId, + provider, + publicClient, + addresses, + environment, + }) + const voting = useGovernanceVoting({ + enabled: runtimeEnabled && Boolean(membership.schedule), + account, + provider, + publicClient, + addresses, + member: membership.member, + identityRoot: membership.identityRoot, + activeAlignment: membership.activeAlignment, + schedule: membership.schedule, + minimumStakes: membership.minimumStakes, + }) + const funding = useGovernanceFunding({ + enabled: runtimeEnabled, + publicClient, + housesAddress: addresses.houses, + tokenAddress: addresses.gToken, + }) + + const refresh = useCallback(async () => { + await Promise.all([ + membership.refresh(), + voting.refresh(), + funding.refresh(), + ]) + }, [funding, membership, voting]) + + const switchToCelo = useCallback(async () => { + await requestCeloMainnetSwitch(provider) + }, [provider]) + + let status: GovernanceWidgetStatus + let runtimeError: string | null = null + if (!account) { + status = 'disconnected' + } else if (resolvedChainId !== CELO_CHAIN_ID) { + status = 'unsupported_chain' + } else if (!addresses.houses) { + status = 'friendly_error' + runtimeError = 'Governance contract address is not configured yet.' + } else if (membership.isLoading && !membership.membership) { + status = 'loading' + } else if (membership.loadError) { + status = 'friendly_error' + runtimeError = membership.loadError + } else { + status = isActiveStatus(membership.status) && voting.isDetailOpen + ? 'vote_detail' + : membership.status + } + + const transaction = selectGovernanceTransaction( + membership.transaction, + voting.transaction, + voting.isDetailOpen, + ) + + const state = useMemo(() => ({ + status, + address: account, + chainId: resolvedChainId, + identityStatus: membership.identityStatus, + identityVerificationUrl: membership.identityVerificationUrl, + member: membership.member, + dashboard: createDashboardState({ + activeMemberCount: + membership.activeCitizens.length + membership.activeAlignment.length, + voting: voting.voting, + funding: funding.funding, + }), + selectedHouse: membership.selectedHouse, + onboardingStepId: membership.onboardingStepId, + profileDraft: membership.profileDraft, + stakeAmountLabel: membership.stakeAmountLabel, + minimumStakeAmounts: membership.minimumStakes, + transactionSteps: membership.transactionSteps, + registrationHash: membership.transaction.kind === 'registration' + ? membership.transaction.hash + : null, + transaction, + unstakeAvailability: membership.unstakeAvailability, + lifecycleNotice: membership.lifecycleNotice, + error: runtimeError ?? transaction.error ?? membership.error ?? voting.error, + }), [ + account, + funding.funding, + membership.activeAlignment.length, + membership.activeCitizens.length, + membership.identityStatus, + membership.identityVerificationUrl, + membership.lifecycleNotice, + membership.error, + membership.member, + membership.minimumStakes, + membership.onboardingStepId, + membership.profileDraft, + membership.selectedHouse, + membership.stakeAmountLabel, + membership.transaction, + membership.transactionSteps, + membership.unstakeAvailability, + resolvedChainId, + runtimeError, + status, + transaction, + voting.error, + voting.voting, + ]) + + const actions = useMemo(() => ({ + connect, + switchToCelo, + refresh, + retry: refresh, + selectHouse: membership.selectHouse, + register: membership.register, + unstake: membership.unstake, + openVote: voting.openVote, + closeVote: voting.closeVote, + setVoteAllocation: voting.setVoteAllocation, + submitVote: voting.submitVote, + startIdentityVerification: membership.startIdentityVerification, + }), [ + connect, + membership.register, + membership.selectHouse, + membership.startIdentityVerification, + membership.unstake, + refresh, + switchToCelo, + voting.closeVote, + voting.openVote, + voting.setVoteAllocation, + voting.submitVote, + ]) + + return { state, actions } +} + +export { + createDashboardState, + createInitialState, + friendlyGovernanceError, + isActiveStatus, +} diff --git a/packages/governance-widget/src/hooks/useGovernanceFunding.ts b/packages/governance-widget/src/hooks/useGovernanceFunding.ts new file mode 100644 index 00000000..758f679c --- /dev/null +++ b/packages/governance-widget/src/hooks/useGovernanceFunding.ts @@ -0,0 +1,127 @@ +import { useCallback, useEffect, useState } from 'react' +import type { Address, PublicClient } from 'viem' +import type { GovernanceDashboardState } from '../widgetRuntimeContract' +import { ZERO_ADDRESS } from '../sdks/contracts' +import { readFlowSplitterConfig } from '../sdks/contractReads' +import { fetchFundingReceivedSoFar } from '../sdks/funding' + +type FundingState = GovernanceDashboardState['fundingDistribution'] + +export function createFundingLoadingState(): FundingState { + return { + title: 'Funding received so far', + centerLabel: 'Loading funding', + totalAmount: { + value: '0', + token: 'G$', + isStreaming: false, + streamLabel: 'Loading Superfluid streams', + }, + projects: [], + isStreaming: false, + stateLabel: 'Refreshing cumulative funding…', + emptyStateLabel: 'Loading funding streams…', + } +} + +export function createFundingUnavailableState(): FundingState { + return { + title: 'Funding received so far', + centerLabel: 'Funding unavailable', + totalAmount: { + value: '0', + token: 'G$', + isStreaming: false, + streamLabel: 'Superfluid stream data unavailable', + }, + projects: [], + isStreaming: false, + stateLabel: 'Funding data is temporarily unavailable.', + emptyStateLabel: 'Membership and voting remain available while funding data refreshes.', + } +} + +export function useGovernanceFunding(params: { + enabled: boolean + publicClient: PublicClient + housesAddress?: Address + tokenAddress: Address +}) { + const { enabled, publicClient, housesAddress, tokenAddress } = params + const [funding, setFunding] = useState(() => createFundingLoadingState()) + const [error, setError] = useState(null) + + const refresh = useCallback(async () => { + if (!enabled || !housesAddress) return + try { + const flowConfig = await readFlowSplitterConfig({ publicClient, housesAddress }) + if (flowConfig.poolAddress.toLowerCase() === ZERO_ADDRESS) { + setFunding({ + title: 'Funding received so far', + centerLabel: 'No receiver configured', + totalAmount: { + value: '0', + token: 'G$', + isStreaming: false, + streamLabel: 'No FlowSplitter pool receiver yet', + }, + projects: [], + isStreaming: false, + emptyStateLabel: 'No funding receiver has been configured yet.', + }) + setError(null) + return + } + + const total = await fetchFundingReceivedSoFar({ + receiver: flowConfig.poolAddress, + token: tokenAddress, + }) + const hasActiveStreams = total.activeStreamCount > 0 + const hasHistoricalStreams = total.streamCount > 0 + setFunding({ + title: 'Funding received so far', + centerLabel: hasActiveStreams + ? 'Active Superfluid total' + : hasHistoricalStreams + ? 'Cumulative received' + : 'No streams yet', + totalAmount: { + value: total.formattedAmount, + token: 'G$', + isStreaming: hasActiveStreams, + streamLabel: hasActiveStreams + ? `${total.activeStreamCount} active stream${total.activeStreamCount === 1 ? '' : 's'}` + : hasHistoricalStreams + ? `${total.streamCount} stopped historical stream${total.streamCount === 1 ? '' : 's'}` + : 'No inbound streams found', + }, + projects: [], + isStreaming: hasActiveStreams, + stateLabel: hasHistoricalStreams && !hasActiveStreams + ? 'Historical streams are stopped; their received totals remain included.' + : undefined, + emptyStateLabel: hasHistoricalStreams + ? 'Distribution breakdown is unavailable until outgoing stream data exists.' + : 'No funding streams have been received yet.', + }) + setError(null) + } catch (err: unknown) { + setFunding(createFundingUnavailableState()) + setError(err instanceof Error ? err.message : 'Funding refresh failed') + } + }, [enabled, housesAddress, publicClient, tokenAddress]) + + useEffect(() => { + if (!enabled) { + setFunding(createFundingLoadingState()) + setError(null) + return + } + void refresh() + const interval = globalThis.setInterval(() => void refresh(), 30_000) + return () => globalThis.clearInterval(interval) + }, [enabled, refresh]) + + return { funding, error, refresh } +} diff --git a/packages/governance-widget/src/hooks/useGovernanceMembership.ts b/packages/governance-widget/src/hooks/useGovernanceMembership.ts new file mode 100644 index 00000000..badc3889 --- /dev/null +++ b/packages/governance-widget/src/hooks/useGovernanceMembership.ts @@ -0,0 +1,559 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Linking } from 'react-native' +import type { EIP1193Provider } from '@goodwidget/core' +import type { StepperStepItem } from '@goodwidget/ui' +import type { Address, Hex, PublicClient } from 'viem' +import type { GovernanceHouse, GovernanceOnboardingStepId, GovernanceProfileDraft } from '../types' +import type { + GovernanceTransactionState, + GovernanceTransactionStatus, + GovernanceUnstakeAvailability, + GovernanceWidgetStatus, +} from '../widgetRuntimeContract' +import { + CELO_CHAIN_ID, + createGovernanceWalletClient, + formatStakeAmount, + safeMillisecondsFromSeconds, + type GovernanceContractAddresses, + type GovernanceMemberRecord, +} from '../sdks/contracts' +import { + readGovernanceAccountState, + readGovernancePublicState, + readGovernanceSchedule, + type GovernanceMembershipReads, + type GovernancePublicReads, + type GovernanceSchedule, + type GovernanceStakeRequirements, +} from '../sdks/contractReads' +import { + registerWithTransferAndCall, + unstakeGovernanceMembership, + type GovernanceTransactionStage, +} from '../sdks/transactions' +import { + createGovernanceIdentitySdk, + createGovernanceIdentityVerificationLink, + type GovernanceIdentityEnvironment, +} from '../sdks/identity' +import { useGovernanceTransactionGuard } from './useGovernanceTransactionGuard' + +const EMPTY_STAKES: GovernanceStakeRequirements = { + citizenship: 0n, + alignment: 0n, +} + +const EMPTY_ADDRESSES: Address[] = [] + +const IDLE_TRANSACTION: GovernanceTransactionState = { + kind: null, + status: 'idle', + hash: null, + error: null, +} + +export function createTransactionSteps( + stage: GovernanceTransactionStatus, + failedMessage?: string, +): StepperStepItem[] { + const rejected = stage === 'rejected' + const reverted = stage === 'reverted' || stage === 'failed' + + return [ + { + id: 'prepare', + title: 'Prepare wallet balance', + description: 'Keep the required G$ amount available before the membership transaction starts.', + status: stage === 'idle' ? 'active' : 'completed', + }, + { + id: 'approve', + title: 'Confirm in wallet', + description: rejected ? failedMessage : 'Approve the membership transaction from your wallet.', + status: rejected ? 'failed' : stage === 'wallet_confirmation' ? 'active' : stage === 'idle' ? 'pending' : 'completed', + }, + { + id: 'stake', + title: 'Transaction submitted', + description: reverted ? failedMessage : 'Wait for the Celo transaction receipt before continuing.', + status: reverted ? 'failed' : stage === 'submitted' ? 'active' : stage === 'confirmed' ? 'completed' : 'pending', + }, + { + id: 'finalize', + title: 'Confirmed on-chain', + status: stage === 'confirmed' ? 'completed' : 'pending', + }, + ] +} + +export function friendlyGovernanceError(err: unknown): string { + if (!(err instanceof Error)) return 'Something went wrong. Please try again.' + + const message = err.message + if (message.includes('User rejected') || message.includes('4001')) { + return 'Transaction rejected in the wallet.' + } + if (message.includes('Already voted')) return 'You already voted in this allocation cycle.' + if (message.includes('Alloc != 10000')) return 'Allocation totals must equal exactly 10,000 basis points.' + if (message.includes('insufficient funds')) { + return 'Your wallet does not have enough funds for this governance action.' + } + if (message.includes('Term not passed')) { + return 'Your membership is still locked for the current governance term.' + } + if (message.includes('revert') || message.includes('reverted')) { + return 'The governance contract rejected this action. Review your details and try again.' + } + if (message.includes('fetch') || message.includes('network') || message.includes('HTTP')) { + return 'Unable to reach Celo Mainnet. Check your connection and try again.' + } + return 'Unable to complete the governance action. Please try again.' +} + +export function transactionStatusFromError(err: unknown): Extract { + if (err instanceof Error && (err.message.includes('User rejected') || err.message.includes('4001'))) { + return 'rejected' + } + if (err instanceof Error && (err.message.includes('revert') || err.message.includes('reverted'))) { + return 'reverted' + } + return 'failed' +} + +export function statusFromMember(member: GovernanceMemberRecord | null): GovernanceWidgetStatus { + if (!member || member.status === 'none' || member.status === 'unstaked') return 'onboarding_required' + // GoodDaoHouses activates Citizen registrations immediately; Pending is Alignment-only. + if (member.status === 'pending' && member.house === 'alignment') return 'pending_alignment' + if (member.status === 'active' && member.house === 'alignment') return 'active_alignment' + if (member.status === 'active') return 'active_citizenship' + if (member.status === 'revoked') return 'revoked' + return 'onboarding_required' +} + +export function isActiveStatus(status: GovernanceWidgetStatus): boolean { + return status === 'active_alignment' || status === 'active_citizenship' +} + +export function getUnstakeAvailability( + member: GovernanceMemberRecord | null, + termDurationSeconds: bigint, + currentBlockTime: number | null, +): GovernanceUnstakeAvailability { + if (!member || member.status !== 'active') { + return { canUnstake: false, unlockAt: null, disabledReason: 'Only active members can unstake.' } + } + if (!member.updatedAt || termDurationSeconds <= 0n) { + return { + canUnstake: false, + unlockAt: null, + disabledReason: 'The membership lock period is unavailable. Refresh before trying again.', + } + } + if (currentBlockTime === null) { + return { + canUnstake: false, + unlockAt: null, + disabledReason: 'The current Celo block time is unavailable. Refresh before trying again.', + } + } + + const termDurationMs = safeMillisecondsFromSeconds(termDurationSeconds) + if (termDurationMs === null) { + return { + canUnstake: false, + unlockAt: null, + disabledReason: 'The membership lock period is unavailable. Refresh before trying again.', + } + } + + const unlockAt = member.updatedAt + termDurationMs + if (!Number.isSafeInteger(unlockAt)) { + return { + canUnstake: false, + unlockAt: null, + disabledReason: 'The membership lock period is unavailable. Refresh before trying again.', + } + } + return currentBlockTime >= unlockAt + ? { canUnstake: true, unlockAt } + : { + canUnstake: false, + unlockAt, + disabledReason: 'Membership remains locked until the current governance term has passed.', + } +} + +interface MembershipHookState { + loadedAccount: Address | null + membership: GovernanceMembershipReads | null + publicState: GovernancePublicReads | null + schedule: GovernanceSchedule | null + selectedHouse: GovernanceHouse + onboardingStepId?: GovernanceOnboardingStepId + profileDraft: GovernanceProfileDraft + transactionSteps: StepperStepItem[] + transaction: GovernanceTransactionState + identityVerificationUrl: string | null + lifecycleNotice: string | null + isLoading: boolean + loadError: string | null + error: string | null +} + +export function resolveRegistrationStake( + membership: GovernanceMembershipReads | null, + selectedHouse: GovernanceHouse, +): { stakeAmountWei: bigint; error: null } | { stakeAmountWei: null; error: string } { + if (!membership) { + return { + stakeAmountWei: null, + error: 'Membership data is still loading. Please try again in a moment.', + } + } + return { stakeAmountWei: membership.minimumStakes[selectedHouse], error: null } +} + +function createInitialMembershipState(): MembershipHookState { + return { + loadedAccount: null, + membership: null, + publicState: null, + schedule: null, + selectedHouse: 'citizenship', + onboardingStepId: undefined, + profileDraft: {}, + transactionSteps: createTransactionSteps('idle'), + transaction: IDLE_TRANSACTION, + identityVerificationUrl: null, + lifecycleNotice: null, + isLoading: false, + loadError: null, + error: null, + } +} + +function transactionFromStage( + kind: GovernanceTransactionState['kind'], + stage: GovernanceTransactionStage, + hash?: Hex, +): GovernanceTransactionState { + return { kind, status: stage, hash: hash ?? null, error: null } +} + +export function useGovernanceMembership(params: { + account: Address | null + chainId: number | null + provider: EIP1193Provider | null + publicClient: PublicClient + addresses: GovernanceContractAddresses + environment: GovernanceIdentityEnvironment +}) { + const { account, chainId, provider, publicClient, addresses, environment } = params + const [state, setState] = useState(() => createInitialMembershipState()) + const refreshRequestId = useRef(0) + const transactionGuard = useGovernanceTransactionGuard([ + account?.toLowerCase() ?? 'no-account', + chainId ?? 'no-chain', + addresses.houses?.toLowerCase() ?? 'no-contract', + ].join(':')) + const enabled = Boolean(addresses.houses) + const hasCurrentAccountState = Boolean( + account && state.loadedAccount?.toLowerCase() === account.toLowerCase(), + ) + const membership = hasCurrentAccountState ? state.membership : null + const schedule = state.publicState ? state.schedule : null + + const refresh = useCallback(async () => { + if (!addresses.houses) return + const requestId = ++refreshRequestId.current + setState((previous) => ({ ...previous, isLoading: true, loadError: null })) + + try { + const [publicState, schedule, accountState] = await Promise.all([ + readGovernancePublicState({ + publicClient, + housesAddress: addresses.houses, + }), + readGovernanceSchedule({ publicClient, housesAddress: addresses.houses }), + account && chainId === CELO_CHAIN_ID + ? readGovernanceAccountState({ + publicClient, + housesAddress: addresses.houses, + goodIdAddress: addresses.goodId, + account, + }) + : Promise.resolve(null), + ]) + setState((previous) => requestId === refreshRequestId.current + ? { + ...previous, + loadedAccount: account, + publicState, + membership: accountState + ? { ...publicState, ...accountState } + : null, + schedule, + selectedHouse: + !accountState || + accountState.member.status === 'none' || + accountState.member.status === 'unstaked' + ? previous.selectedHouse + : accountState.member.house, + isLoading: false, + loadError: null, + } + : previous) + } catch (err: unknown) { + setState((previous) => requestId === refreshRequestId.current + ? { + ...previous, + loadedAccount: account, + isLoading: false, + loadError: friendlyGovernanceError(err), + } + : previous) + } + }, [account, addresses.goodId, addresses.houses, chainId, publicClient]) + + useEffect(() => { + refreshRequestId.current += 1 + if (!enabled) { + setState(createInitialMembershipState()) + return + } + setState(createInitialMembershipState()) + void refresh() + }, [enabled, refresh]) + + useEffect(() => { + if (!enabled) return undefined + const interval = globalThis.setInterval(() => void refresh(), 30_000) + return () => globalThis.clearInterval(interval) + }, [enabled, refresh]) + + const selectHouse = useCallback((house: GovernanceHouse) => { + setState((previous) => ({ ...previous, selectedHouse: house })) + }, []) + + const register = useCallback(async (profileDraft: GovernanceProfileDraft) => { + if (!account || !addresses.houses) return + const selectedHouse = state.selectedHouse + const registrationStake = resolveRegistrationStake(membership, selectedHouse) + if (registrationStake.stakeAmountWei === null) { + setState((previous) => ({ + ...previous, + error: registrationStake.error, + })) + return + } + const transactionToken = transactionGuard.begin() + if (!transactionToken) return + + const walletClient = createGovernanceWalletClient({ provider, account }) + if (!walletClient) { + const error = 'The connected wallet provider is unavailable.' + if (transactionGuard.isCurrent(transactionToken)) { + setState((previous) => ({ + ...previous, + onboardingStepId: 'stake', + profileDraft, + transactionSteps: createTransactionSteps('failed', error), + transaction: { kind: 'registration', status: 'failed', hash: null, error }, + error, + })) + } + transactionGuard.finish(transactionToken) + return + } + + const stakeAmountWei = registrationStake.stakeAmountWei + setState((previous) => ({ + ...previous, + onboardingStepId: 'stake', + profileDraft, + transactionSteps: createTransactionSteps('wallet_confirmation'), + transaction: transactionFromStage('registration', 'wallet_confirmation'), + lifecycleNotice: null, + error: null, + })) + + try { + const hash = await registerWithTransferAndCall({ + publicClient, + walletClient, + account, + addresses: { ...addresses, houses: addresses.houses }, + selectedHouse, + profileDraft, + stakeAmountWei, + onStage: (stage, stageHash) => { + if (!transactionGuard.isCurrent(transactionToken)) return + setState((previous) => ({ + ...previous, + transaction: transactionFromStage('registration', stage, stageHash), + transactionSteps: createTransactionSteps(stage), + })) + }, + }) + if (!transactionGuard.isCurrent(transactionToken)) return + setState((previous) => ({ + ...previous, + transaction: { kind: 'registration', status: 'confirmed', hash, error: null }, + transactionSteps: createTransactionSteps('confirmed'), + onboardingStepId: 'success', + })) + await refresh() + } catch (err: unknown) { + if (!transactionGuard.isCurrent(transactionToken)) return + const status = transactionStatusFromError(err) + const error = friendlyGovernanceError(err) + setState((previous) => ({ + ...previous, + transaction: { kind: 'registration', status, hash: previous.transaction.hash, error }, + transactionSteps: createTransactionSteps(status, error), + error, + })) + } finally { + transactionGuard.finish(transactionToken) + } + }, [account, addresses, membership, provider, publicClient, refresh, state.selectedHouse, transactionGuard]) + + const unstake = useCallback(async () => { + if (!account || !addresses.houses) return + const availability = getUnstakeAvailability( + membership?.member ?? null, + schedule?.termDurationSeconds ?? 0n, + schedule?.currentBlockTime ?? null, + ) + if (!availability.canUnstake) return + const transactionToken = transactionGuard.begin() + if (!transactionToken) return + + const walletClient = createGovernanceWalletClient({ provider, account }) + if (!walletClient) { + const error = 'The connected wallet provider is unavailable.' + if (transactionGuard.isCurrent(transactionToken)) { + setState((previous) => ({ + ...previous, + transaction: { kind: 'unstake', status: 'failed', hash: null, error }, + error, + })) + } + transactionGuard.finish(transactionToken) + return + } + + setState((previous) => ({ + ...previous, + transaction: transactionFromStage('unstake', 'wallet_confirmation'), + lifecycleNotice: null, + error: null, + })) + + try { + const hash = await unstakeGovernanceMembership({ + publicClient, + walletClient, + account, + housesAddress: addresses.houses, + onStage: (stage, stageHash) => { + if (!transactionGuard.isCurrent(transactionToken)) return + setState((previous) => ({ + ...previous, + transaction: transactionFromStage('unstake', stage, stageHash), + })) + }, + }) + if (!transactionGuard.isCurrent(transactionToken)) return + setState((previous) => ({ + ...previous, + transaction: { kind: 'unstake', status: 'confirmed', hash, error: null }, + lifecycleNotice: 'Membership unstaked successfully. You can now join a governance house again.', + })) + await refresh() + } catch (err: unknown) { + if (!transactionGuard.isCurrent(transactionToken)) return + const status = transactionStatusFromError(err) + const error = friendlyGovernanceError(err) + setState((previous) => ({ + ...previous, + transaction: { kind: 'unstake', status, hash: previous.transaction.hash, error }, + error, + })) + } finally { + transactionGuard.finish(transactionToken) + } + }, [ + account, + addresses.houses, + membership?.member, + provider, + publicClient, + refresh, + schedule?.currentBlockTime, + schedule?.termDurationSeconds, + transactionGuard, + ]) + + const startIdentityVerification = useCallback(async () => { + if (!account) return + const walletClient = createGovernanceWalletClient({ provider, account }) + if (!walletClient) { + setState((previous) => ({ ...previous, error: 'The connected wallet provider is unavailable.' })) + return + } + + try { + setState((previous) => ({ ...previous, error: null })) + const identitySdk = createGovernanceIdentitySdk({ publicClient, walletClient, environment }) + const returnUrl = (await Linking.getInitialURL()) ?? undefined + const identityVerificationUrl = await createGovernanceIdentityVerificationLink({ + identitySdk, + returnUrl, + chainId: chainId ?? undefined, + }) + setState((previous) => ({ ...previous, identityVerificationUrl })) + await Linking.openURL(identityVerificationUrl) + } catch (err: unknown) { + setState((previous) => ({ ...previous, error: friendlyGovernanceError(err) })) + } + }, [account, chainId, environment, provider, publicClient]) + + const minimumStakes = membership?.minimumStakes ?? state.publicState?.minimumStakes ?? EMPTY_STAKES + const member = membership?.member ?? null + const status = statusFromMember(member) + const unstakeAvailability = useMemo( + () => getUnstakeAvailability( + member, + schedule?.termDurationSeconds ?? 0n, + schedule?.currentBlockTime ?? null, + ), + [member, schedule?.currentBlockTime, schedule?.termDurationSeconds], + ) + + return { + ...state, + membership, + schedule, + isLoading: enabled && (!state.publicState || (account !== null && !hasCurrentAccountState)) + ? true + : state.isLoading, + status, + member, + minimumStakes, + stakeAmountLabel: formatStakeAmount(minimumStakes[state.selectedHouse]), + identityRoot: membership?.identityRoot ?? null, + identityStatus: membership?.identityRoot && membership.identityRoot !== '0x0000000000000000000000000000000000000000' + ? 'verified' as const + : 'unverified' as const, + activeCitizens: membership?.activeCitizens ?? state.publicState?.activeCitizens ?? EMPTY_ADDRESSES, + activeAlignment: membership?.activeAlignment ?? state.publicState?.activeAlignment ?? EMPTY_ADDRESSES, + unstakeAvailability, + refresh, + selectHouse, + register, + unstake, + startIdentityVerification, + } +} diff --git a/packages/governance-widget/src/hooks/useGovernanceTransactionGuard.ts b/packages/governance-widget/src/hooks/useGovernanceTransactionGuard.ts new file mode 100644 index 00000000..3d8e8156 --- /dev/null +++ b/packages/governance-widget/src/hooks/useGovernanceTransactionGuard.ts @@ -0,0 +1,44 @@ +import { useCallback, useEffect, useMemo, useRef } from 'react' + +export interface GovernanceTransactionToken { + id: number + scope: string +} + +/** + * Keeps one wallet transaction active per account/chain/contract scope. + * A scope change invalidates callbacks from the previous wallet before they + * can publish receipt state into the newly connected account. + */ +export function useGovernanceTransactionGuard(scope: string) { + const scopeRef = useRef(scope) + const nextIdRef = useRef(0) + const activeIdRef = useRef(null) + scopeRef.current = scope + + useEffect(() => { + nextIdRef.current += 1 + activeIdRef.current = null + }, [scope]) + + const begin = useCallback((): GovernanceTransactionToken | null => { + if (activeIdRef.current !== null) return null + + const id = ++nextIdRef.current + activeIdRef.current = id + return { id, scope: scopeRef.current } + }, []) + + const isCurrent = useCallback((token: GovernanceTransactionToken): boolean => ( + token.id === activeIdRef.current && token.scope === scopeRef.current + ), []) + + const finish = useCallback((token: GovernanceTransactionToken): void => { + if (isCurrent(token)) activeIdRef.current = null + }, [isCurrent]) + + return useMemo( + () => ({ begin, isCurrent, finish }), + [begin, finish, isCurrent], + ) +} diff --git a/packages/governance-widget/src/hooks/useGovernanceVoting.ts b/packages/governance-widget/src/hooks/useGovernanceVoting.ts new file mode 100644 index 00000000..a43ad2f8 --- /dev/null +++ b/packages/governance-widget/src/hooks/useGovernanceVoting.ts @@ -0,0 +1,386 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { EIP1193Provider } from '@goodwidget/core' +import { getAddress, isAddress, type Address, type PublicClient } from 'viem' +import type { RankedVotingOption } from '../types' +import { + getGovernanceVotingDisabledReason, + type GovernanceTransactionState, + type GovernanceVotingState, +} from '../widgetRuntimeContract' +import { + ZERO_ADDRESS, + createGovernanceWalletClient, + safeMillisecondsFromSeconds, + type GovernanceContractAddresses, + type GovernanceMemberRecord, +} from '../sdks/contracts' +import { + readGovernanceVote, + type GovernanceSchedule, + type GovernanceStakeRequirements, +} from '../sdks/contractReads' +import { castGovernanceVote } from '../sdks/transactions' +import { friendlyGovernanceError, transactionStatusFromError } from './useGovernanceMembership' +import { useGovernanceTransactionGuard } from './useGovernanceTransactionGuard' + +const IDLE_VOTE_TRANSACTION: GovernanceTransactionState = { + kind: null, + status: 'idle', + hash: null, + error: null, +} + +function shortAddress(address: Address): string { + return `${address.slice(0, 6)}…${address.slice(-4)}` +} + +function nextVotingWindowLabel(schedule: GovernanceSchedule): string { + if (!schedule.cycleStartTime || schedule.termDurationSeconds === 0n || schedule.currentBlockTime === null) { + return 'Contract schedule unavailable' + } + const termMs = safeMillisecondsFromSeconds(schedule.termDurationSeconds) + if (termMs === null) return 'Contract schedule unavailable' + const nowMs = schedule.currentBlockTime + const nextStart = nowMs < schedule.cycleStartTime + ? schedule.cycleStartTime + : schedule.cycleStartTime + (Math.floor((nowMs - schedule.cycleStartTime) / termMs) + 1) * termMs + if (!Number.isSafeInteger(nextStart)) return 'Contract schedule unavailable' + return `Next window starts ${new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(new Date(nextStart))}` +} + +export function resolveGovernanceVoterKey( + member: GovernanceMemberRecord | null, + identityRoot: Address | null, + account: Address, +): Address { + return member?.house === 'citizenship' && identityRoot && identityRoot.toLowerCase() !== ZERO_ADDRESS + ? identityRoot + : account +} + +export function createVotingState(params: { + account?: Address | null + member: GovernanceMemberRecord | null + identityRoot: Address | null + voteId: bigint + isVotingOpen: boolean + voteStartTime: number | null + voteConfig: { startTime: number | null; endTime: number | null; executedAt: number | null; executed: boolean } + recipients: Address[] + hasVoted: boolean + finalizedUnits: Record + schedule: GovernanceSchedule + minimumStake: bigint +}): GovernanceVotingState { + const recipients = params.recipients.map((recipient) => getAddress(recipient)) + const totalUnits = Object.values(params.finalizedUnits).reduce((total, amount) => total + amount, 0n) + const options: RankedVotingOption[] = recipients.map((recipient) => { + const units = params.finalizedUnits[recipient.toLowerCase()] ?? params.finalizedUnits[recipient] ?? 0n + return { + id: recipient, + label: shortAddress(recipient), + percentage: totalUnits > 0n ? Number((units * 100n) / totalUnits) : 0, + } + }) + const allocationsBps = Object.fromEntries(options.map((option) => [option.id, 0])) + const isActiveMember = params.member?.status === 'active' + const hasRequiredStake = Boolean( + params.member && params.member.stakedAmount >= params.minimumStake, + ) + const hasCitizenIdentity = params.member?.house !== 'citizenship' || Boolean( + params.identityRoot && params.identityRoot.toLowerCase() !== ZERO_ADDRESS, + ) + const joinedBeforeVote = Boolean( + params.member?.joinedAt && + params.voteStartTime && + params.member.joinedAt <= params.voteStartTime, + ) + const canVote = Boolean( + isActiveMember && + hasRequiredStake && + hasCitizenIdentity && + joinedBeforeVote && + params.isVotingOpen && + !params.hasVoted && + recipients.length > 0, + ) + + let title = 'Alignment vote' + let summaryLabel = 'Allocate 10,000 bps to eligible House of Alignment recipients' + let disabledReason: string | undefined + + if (!params.isVotingOpen) { + title = 'Upcoming Alignment vote' + summaryLabel = nextVotingWindowLabel(params.schedule) + disabledReason = 'Voting is currently closed.' + } else if (recipients.length === 0) { + disabledReason = 'No House of Alignment members were eligible when this vote opened.' + } else if (params.hasVoted) { + disabledReason = 'You already voted in this allocation cycle.' + } else if (params.account === null) { + disabledReason = 'Connect a wallet to participate in voting.' + } else if (!isActiveMember) { + disabledReason = 'Only active members can vote.' + } else if (!hasRequiredStake) { + disabledReason = 'Your membership stake is below the current minimum required for this house.' + } else if (!joinedBeforeVote) { + disabledReason = 'Members who joined after this vote opened cannot participate in this cycle.' + } else if (!hasCitizenIdentity) { + disabledReason = 'Verify your GoodID before voting as a Citizen.' + } + + if (params.voteConfig.executed) { + title = 'Executed Alignment vote' + summaryLabel = 'Final units executed' + disabledReason = 'This vote has already been executed.' + } + + return { + voteId: params.voteId.toString(), + title, + summaryLabel, + options, + recipients, + allocationsBps, + allocationTotalBps: 0, + canVote, + hasVoted: params.hasVoted, + isVotingOpen: params.isVotingOpen, + executed: params.voteConfig.executed, + finalizedUnits: Object.fromEntries( + Object.entries(params.finalizedUnits).map(([recipient, units]) => [recipient, units.toString()]), + ), + disabledReason, + } +} + +export function validateGovernanceBallot( + recipients: string[], + allocationsBps: Record, +): { recipients: Address[]; allocations: bigint[] } { + if (recipients.length === 0) throw new Error('No recipients') + if (!recipients.every((recipient) => isAddress(recipient))) throw new Error('Invalid recipient') + + const normalized = recipients.map((recipient) => getAddress(recipient)) + if (new Set(normalized.map((recipient) => recipient.toLowerCase())).size !== normalized.length) { + throw new Error('Duplicate recipient') + } + const allocations = normalized.map((recipient, index) => { + const originalRecipient = recipients[index] + const amount = allocationsBps[recipient] + ?? allocationsBps[originalRecipient] + ?? allocationsBps[recipient.toLowerCase()] + ?? 0 + if (!Number.isSafeInteger(amount)) throw new Error('Invalid allocation') + return BigInt(amount) + }) + const total = allocations.reduce((sum, allocation) => sum + allocation, 0n) + if (allocations.some((allocation) => allocation < 0n || allocation > 10_000n)) { + throw new Error('Invalid allocation') + } + if (total !== 10_000n) throw new Error('Alloc != 10000') + return { recipients: normalized, allocations } +} + +export function createEmptyVotingState(): GovernanceVotingState { + return { + voteId: '0', + title: 'Upcoming Alignment vote', + summaryLabel: 'Contract schedule unavailable', + options: [], + recipients: [], + allocationsBps: {}, + allocationTotalBps: 0, + canVote: false, + hasVoted: false, + isVotingOpen: false, + executed: false, + finalizedUnits: {}, + disabledReason: 'Connect a wallet to participate in voting.', + } +} + +export function useGovernanceVoting(params: { + enabled: boolean + account: Address | null + provider: EIP1193Provider | null + publicClient: PublicClient + addresses: GovernanceContractAddresses + member: GovernanceMemberRecord | null + identityRoot: Address | null + activeAlignment: Address[] + schedule: GovernanceSchedule | null + minimumStakes: GovernanceStakeRequirements +}) { + const { + enabled, + account, + provider, + publicClient, + addresses, + member, + identityRoot, + activeAlignment, + schedule, + minimumStakes, + } = params + const [voting, setVoting] = useState(() => createEmptyVotingState()) + const [transaction, setTransaction] = useState(IDLE_VOTE_TRANSACTION) + const [isDetailOpen, setIsDetailOpen] = useState(false) + const [error, setError] = useState(null) + const refreshRequestId = useRef(0) + const transactionGuard = useGovernanceTransactionGuard([ + account?.toLowerCase() ?? 'no-account', + addresses.houses?.toLowerCase() ?? 'no-contract', + enabled ? 'enabled' : 'disabled', + ].join(':')) + + const refresh = useCallback(async () => { + if (!enabled || !addresses.houses || !schedule) return + const requestId = ++refreshRequestId.current + try { + const voterKey = account ? resolveGovernanceVoterKey(member, identityRoot, account) : undefined + const vote = await readGovernanceVote({ + publicClient, + housesAddress: addresses.houses, + voterKey, + activeAlignment, + schedule, + }) + if (requestId === refreshRequestId.current) { + setVoting(createVotingState({ + account, + member, + identityRoot, + voteId: vote.voteId, + isVotingOpen: vote.isVotingPeriod, + voteStartTime: vote.voteStartTime, + voteConfig: vote.voteConfig, + recipients: vote.recipients, + hasVoted: vote.hasVoted, + finalizedUnits: vote.finalizedUnits, + schedule, + minimumStake: member ? minimumStakes[member.house] : 0n, + })) + setError(null) + } + } catch (err: unknown) { + if (requestId === refreshRequestId.current) setError(friendlyGovernanceError(err)) + } + }, [ + account, + activeAlignment, + addresses.houses, + enabled, + identityRoot, + member, + minimumStakes, + publicClient, + schedule, + ]) + + useEffect(() => { + refreshRequestId.current += 1 + setVoting(createEmptyVotingState()) + setTransaction(IDLE_VOTE_TRANSACTION) + setIsDetailOpen(false) + setError(null) + }, [account, addresses.houses, enabled]) + + useEffect(() => { + // Membership's 30-second refresh updates these dependencies, + // keeping voting on the same cadence. + if (enabled) void refresh() + }, [enabled, refresh]) + + const setVoteAllocation = useCallback((recipientId: string, basisPoints: number) => { + setVoting((previous) => { + if (!(recipientId in previous.allocationsBps)) return previous + const normalizedBasisPoints = Number.isFinite(basisPoints) + ? Math.trunc(basisPoints) + : 0 + const allocationsBps = { + ...previous.allocationsBps, + [recipientId]: Math.max(0, Math.min(10_000, normalizedBasisPoints)), + } + const allocationTotalBps = Object.values(allocationsBps).reduce((total, amount) => total + amount, 0) + return { + ...previous, + allocationsBps, + allocationTotalBps, + } + }) + }, []) + + const submitVote = useCallback(async () => { + if (!account || !addresses.houses) return + const disabledReason = getGovernanceVotingDisabledReason(voting) + if (disabledReason || !voting.canVote) { + const unavailableError = disabledReason ?? 'Voting is currently unavailable.' + setError(unavailableError) + return + } + const transactionToken = transactionGuard.begin() + if (!transactionToken) return + + const walletClient = createGovernanceWalletClient({ provider, account }) + if (!walletClient) { + const providerError = 'The connected wallet provider is unavailable.' + if (transactionGuard.isCurrent(transactionToken)) { + setTransaction({ kind: 'vote', status: 'failed', hash: null, error: providerError }) + setError(providerError) + } + transactionGuard.finish(transactionToken) + return + } + + try { + const ballot = validateGovernanceBallot(voting.recipients, voting.allocationsBps) + setTransaction({ kind: 'vote', status: 'wallet_confirmation', hash: null, error: null }) + const hash = await castGovernanceVote({ + publicClient, + walletClient, + account, + housesAddress: addresses.houses, + recipients: ballot.recipients, + allocationsBps: ballot.allocations, + onStage: (stage, stageHash) => { + if (!transactionGuard.isCurrent(transactionToken)) return + setTransaction({ + kind: 'vote', + status: stage, + hash: stageHash ?? null, + error: null, + }) + }, + }) + if (!transactionGuard.isCurrent(transactionToken)) return + setTransaction({ kind: 'vote', status: 'confirmed', hash, error: null }) + await refresh() + } catch (err: unknown) { + if (!transactionGuard.isCurrent(transactionToken)) return + const status = transactionStatusFromError(err) + const friendlyError = friendlyGovernanceError(err) + setTransaction((previous) => ({ ...previous, kind: 'vote', status, error: friendlyError })) + setError(friendlyError) + } finally { + transactionGuard.finish(transactionToken) + } + }, [account, addresses.houses, provider, publicClient, refresh, transactionGuard, voting]) + + return useMemo(() => ({ + voting, + transaction, + isDetailOpen, + error, + refresh, + openVote: () => setIsDetailOpen(true), + closeVote: () => setIsDetailOpen(false), + setVoteAllocation, + submitVote, + }), [error, isDetailOpen, refresh, setVoteAllocation, submitVote, transaction, voting]) +} diff --git a/packages/governance-widget/src/index.ts b/packages/governance-widget/src/index.ts index 141a7b1e..49d029b1 100644 --- a/packages/governance-widget/src/index.ts +++ b/packages/governance-widget/src/index.ts @@ -5,6 +5,7 @@ export { OptimisticVotingProposalCard } from './OptimisticVotingProposalCard' export { FundingDistributionChart } from './FundingDistributionChart' export { GovernanceWidgetProvider } from './GovernanceWidgetProvider' export { GovernanceOnboardingWidget } from './GovernanceOnboardingWidget' +export { GovernanceWidget } from './GovernanceWidget' export type { GovernanceWidgetProviderProps } from './types' export type { GovernanceAmount, @@ -35,3 +36,31 @@ export type { } from './types' export { DEFAULT_TRANSACTION_STEPS } from './onboarding/constants' +export { useGovernanceAdapter } from './adapter' +export type { + GovernanceDashboardState, + GovernanceTransactionKind, + GovernanceTransactionState, + GovernanceTransactionStatus, + GovernanceUnstakeAvailability, + GovernanceVotingState, + GovernanceWidgetAdapterActions, + GovernanceWidgetAdapterFactory, + GovernanceWidgetAdapterFactoryInput, + GovernanceWidgetAdapterResult, + GovernanceWidgetAdapterState, + GovernanceWidgetProps, + GovernanceWidgetStatus, +} from './widgetRuntimeContract' +export { + CELO_CHAIN_ID, + CELO_GOODID_ADDRESS, + DEFAULT_CELO_RPC_URL, + G_TOKEN_CELO_ADDRESS, + encodeGovernanceRegistrationData, + mapFlowSplitterConfig, + mapMemberRecord, + mapVoteConfig, + resolveGovernanceAddresses, +} from './sdks/contracts' +export { calculateStreamAmountWei, fetchFundingReceivedSoFar } from './sdks/funding' diff --git a/packages/governance-widget/src/onboarding/GovernanceOnboardingFlow.tsx b/packages/governance-widget/src/onboarding/GovernanceOnboardingFlow.tsx index 1bd80102..8cb59941 100644 --- a/packages/governance-widget/src/onboarding/GovernanceOnboardingFlow.tsx +++ b/packages/governance-widget/src/onboarding/GovernanceOnboardingFlow.tsx @@ -3,7 +3,12 @@ import type { ReactNode } from 'react' import { Button, ButtonText, PageWizardShell, XStack, usePageWizard } from '@goodwidget/ui' import { HOUSE_COPY } from './copy' import { DEFAULT_TRANSACTION_STEPS, DEFAULT_FINAL_ACTIONS } from './constants' -import { validateProfileDraft, isProfileDraftComplete, validateField } from './validation' +import { + isProfileDraftComplete, + isProfileFieldRequired, + validateField, + validateProfileDraft, +} from './validation' import { WelcomeStepContent } from './steps/WelcomeStepContent' import { HouseStepContent } from './steps/HouseStepContent' import { ProfileStepContent } from './steps/ProfileStepContent' @@ -23,29 +28,35 @@ import type { StepperStepItem } from '@goodwidget/ui' interface GovernanceOnboardingFlowProps { identityStatus: GovernanceIdentityStatus walletAddress?: string - disabledHouseOptions: GovernanceHouse[] initialFieldErrors: GovernanceProfileFieldErrors - stakeAmountLabel: string + stakeAmountLabels: Record transactionSteps: StepperStepItem[] finalActions: GovernanceOnboardingAction[] + onHouseChange?: (house: GovernanceHouse) => void + onIdentityVerificationPress?: () => void + onProfileSubmit?: (profileDraft: GovernanceWizardData['profileDraft'], house: GovernanceHouse) => void onFinalActionPress?: (actionId: string) => void dataTestId?: string } +function areTransactionStepsComplete(steps: StepperStepItem[]): boolean { + return steps.length > 0 && steps.every((step) => step.status === 'completed') +} + export function GovernanceOnboardingFlow({ identityStatus, walletAddress, - disabledHouseOptions, initialFieldErrors, - stakeAmountLabel, + stakeAmountLabels, transactionSteps = DEFAULT_TRANSACTION_STEPS, finalActions = DEFAULT_FINAL_ACTIONS, + onHouseChange, + onIdentityVerificationPress, + onProfileSubmit, onFinalActionPress, dataTestId, }: GovernanceOnboardingFlowProps) { - const { currentStep, steps, data, setData, next } = usePageWizard() - // The success step is a terminal view and should not appear in the progress - // indicator — Stitch design shows exactly 4 steps: Verify, Path, Profile, Transact. + const { currentStep, steps, data, setData, next, back, isFirst } = usePageWizard() const stepperDisplaySteps = steps.filter((s) => s.id !== 'success') const [fieldErrors, setFieldErrors] = useState(initialFieldErrors) @@ -53,6 +64,7 @@ export function GovernanceOnboardingFlow({ const selectedHouse = wizardData.selectedHouse const profileDraft = wizardData.profileDraft ?? {} const resolvedHouse: GovernanceHouse = selectedHouse ?? 'citizenship' + const selectedStakeAmountLabel = stakeAmountLabels[resolvedHouse] const isIdentityVerified = identityStatus === 'verified' const profileIsComplete = isProfileDraftComplete(resolvedHouse, profileDraft) @@ -68,7 +80,6 @@ export function GovernanceOnboardingFlow({ } }) - // Clear the error as the user types so they get immediate positive feedback setFieldErrors((previousErrors) => { const nextErrors = { ...previousErrors } delete nextErrors[fieldKey] @@ -76,10 +87,12 @@ export function GovernanceOnboardingFlow({ }) } - // Validate a single field when the user leaves it (blur) so they see - // inline feedback before hitting the submit button. const handleFieldBlur = (fieldKey: GovernanceProfileFieldKey, fieldValue: string) => { - const error = validateField(fieldKey, fieldValue) + const error = validateField( + fieldKey, + fieldValue, + isProfileFieldRequired(resolvedHouse, fieldKey), + ) setFieldErrors((prev) => { if (!error) { const next = { ...prev } @@ -95,12 +108,14 @@ export function GovernanceOnboardingFlow({ setFieldErrors(nextFieldErrors) if (Object.keys(nextFieldErrors).length === 0) { + onProfileSubmit?.(profileDraft, resolvedHouse) next() } } const handleHouseSelect = (nextHouse: GovernanceHouse) => { setData({ selectedHouse: nextHouse }) + onHouseChange?.(nextHouse) } let shellTitle = 'Governance onboarding' @@ -108,6 +123,7 @@ export function GovernanceOnboardingFlow({ let shellContent: ReactNode = null let shellFooter: ReactNode = null let hideStepper = false + const showBackButton = !isFirst && currentStep?.id !== 'stake' && currentStep?.id !== 'success' switch (currentStep?.id as GovernanceOnboardingStepId | undefined) { case 'welcome': @@ -121,28 +137,27 @@ export function GovernanceOnboardingFlow({ walletAddress={walletAddress} isIdentityVerified={isIdentityVerified} onProceedPress={next} + onVerifyPress={onIdentityVerificationPress} /> ) - // Footer is null — "Proceed to Membership" is inside OnboardingIdentityCard shellFooter = null break case 'house': shellTitle = 'Choose your house' shellDescription = - 'Where will your impact be felt? Choose the path that best fits your contribution.' + 'Select the governance body you wish to join.' shellContent = ( ) shellFooter = ( - - ) @@ -157,37 +172,34 @@ export function GovernanceOnboardingFlow({ selectedHouse={resolvedHouse} profileDraft={profileDraft} fieldErrors={fieldErrors} - stakeAmountLabel={stakeAmountLabel} + stakeAmountLabel={selectedStakeAmountLabel} onProfileFieldChange={updateProfileField} onProfileFieldBlur={handleFieldBlur} ctaDisabled={!profileIsComplete} - // CTA button lives inside the card — no shell footer button needed onContinuePress={handleProfileContinue} /> ) - // Footer is null — "Create Profile and Stake" is inside ProfileStepContent card shellFooter = null break case 'stake': { - // Disable the CTA until every on-chain transaction step has completed. - // L03TJ3 feedback: "I can continue to success while the progress is not finalized?" - const allStepsCompleted = - transactionSteps.length > 0 && - transactionSteps.every((step) => step.status === 'completed') - shellTitle = 'Creating profile & staking' + const allStepsCompleted = areTransactionStepsComplete(transactionSteps) + shellTitle = 'Securing your membership' shellDescription = - 'Please wait while your transaction is confirmed on-chain. You can review each step below.' + 'Transactions are being processed on-chain. Please do not close this window.' shellContent = ( - + ) - shellFooter = ( - - - ) + ) : null break } @@ -196,7 +208,7 @@ export function GovernanceOnboardingFlow({ shellContent = ( ) @@ -208,6 +220,21 @@ export function GovernanceOnboardingFlow({ + Back + + ) : undefined} footer={shellFooter} dataTestId={dataTestId} showStepper={!hideStepper} diff --git a/packages/governance-widget/src/onboarding/HouseSelectionCard.tsx b/packages/governance-widget/src/onboarding/HouseSelectionCard.tsx index 83e36c6c..20d9746d 100644 --- a/packages/governance-widget/src/onboarding/HouseSelectionCard.tsx +++ b/packages/governance-widget/src/onboarding/HouseSelectionCard.tsx @@ -1,18 +1,12 @@ import { Stack } from 'tamagui' -import { Badge, BadgeText, Heading, Icon, PillText, Text, XStack, createComponent } from '@goodwidget/ui' +import { Heading, Icon, PillText, XStack, createComponent } from '@goodwidget/ui' import { HOUSE_COPY } from './copy' import type { GovernanceHouse } from '../types' -/** Maps each house to its Figma-specified icon name. */ const HOUSE_ICON: Record = { citizenship: 'user', alignment: 'compass', } - - -/** - * Internal house-selection button. Uses createComponent to register for theme overrides. - */ const HouseOptionButton = createComponent(Stack, { name: 'GovernanceHouseOptionButton', tag: 'button', @@ -41,13 +35,6 @@ const HouseOptionButton = createComponent(Stack, { backgroundColor: '$backgroundHover', }, }, - disabled: { - true: { - opacity: 0.5, - cursor: 'not-allowed', - pointerEvents: 'none', - }, - }, } as const, }) @@ -100,7 +87,6 @@ const HousePill = createComponent(Stack, { interface HouseSelectionCardProps { house: GovernanceHouse isSelected: boolean - isDisabled: boolean stakeAmountLabel: string onPress: () => void } @@ -108,7 +94,6 @@ interface HouseSelectionCardProps { export function HouseSelectionCard({ house, isSelected, - isDisabled, stakeAmountLabel, onPress, }: HouseSelectionCardProps) { @@ -117,24 +102,19 @@ export function HouseSelectionCard({ return ( - {/* ── Header: icon + title + radio (matches Figma layout) ── */} - {houseCopy.title} + {houseCopy.title} - {/* ── Summary text ─────────────────────────────────────────── */} - {houseCopy.summary} - {houseCopy.label} @@ -142,14 +122,7 @@ export function HouseSelectionCard({ {`${stakeAmountLabel} stake`} - {isSelected ? ( - - Selected - - ) : null} - - {/* "Continue with this house" row removed — not in Figma design */} ) } diff --git a/packages/governance-widget/src/onboarding/OnboardingIdentityCard.tsx b/packages/governance-widget/src/onboarding/OnboardingIdentityCard.tsx index ebb2467e..45ef3893 100644 --- a/packages/governance-widget/src/onboarding/OnboardingIdentityCard.tsx +++ b/packages/governance-widget/src/onboarding/OnboardingIdentityCard.tsx @@ -6,6 +6,7 @@ interface OnboardingIdentityCardProps { identityStatus: GovernanceIdentityStatus walletAddress?: string onProceedPress?: () => void + onVerifyPress?: () => void } /** Left-border accent row used for the Identity Status field when verified. */ @@ -41,6 +42,7 @@ export function OnboardingIdentityCard({ identityStatus, walletAddress, onProceedPress, + onVerifyPress, }: OnboardingIdentityCardProps) { const isVerified = identityStatus === 'verified' @@ -122,19 +124,27 @@ export function OnboardingIdentityCard({ - {/* ── CTA button ─────────────────────────────────────────── */} - {/* Figma: single "Proceed to Membership" button, blue when verified, - disabled (grey outline) when unverified. No separate "Verify" button. */} - + {isVerified ? ( + + ) : ( + + )} ) diff --git a/packages/governance-widget/src/onboarding/constants.ts b/packages/governance-widget/src/onboarding/constants.ts index 02d73a60..6b8dfc1b 100644 --- a/packages/governance-widget/src/onboarding/constants.ts +++ b/packages/governance-widget/src/onboarding/constants.ts @@ -11,8 +11,8 @@ export const ONBOARDING_STEPS: PageWizardStep[] = [ ] export const REQUIRED_PROFILE_FIELDS: Record = { - citizenship: ['name', 'socialLinks'], - alignment: ['name', 'projectWebpage', 'missionStatement', 'distributionStrategy'], + citizenship: ['name'], + alignment: ['name', 'socialLinks', 'projectWebpage', 'missionStatement'], } export const DEFAULT_TRANSACTION_STEPS: StepperStepItem[] = [ diff --git a/packages/governance-widget/src/onboarding/copy.ts b/packages/governance-widget/src/onboarding/copy.ts index bdee8c22..64988ccf 100644 --- a/packages/governance-widget/src/onboarding/copy.ts +++ b/packages/governance-widget/src/onboarding/copy.ts @@ -13,14 +13,14 @@ export const HOUSE_COPY: Record = { title: 'House of Citizenship', summary: 'Represent verified community members and highlight your public governance identity.', helper: 'Collect the profile details that describe the member behind the wallet.', - label: 'Membership house', + label: 'Identity', defaultStakeAmount: '100 G$', }, alignment: { title: 'House of Alignment', summary: 'Coordinate aligned projects and explain how your mission creates value for the network.', helper: 'Collect project-facing metadata that can later map to the onchain registration shape.', - label: 'Project house', + label: 'Protocol security', defaultStakeAmount: '250 G$', }, } diff --git a/packages/governance-widget/src/onboarding/steps/HouseStepContent.tsx b/packages/governance-widget/src/onboarding/steps/HouseStepContent.tsx index 0571c074..583d2fad 100644 --- a/packages/governance-widget/src/onboarding/steps/HouseStepContent.tsx +++ b/packages/governance-widget/src/onboarding/steps/HouseStepContent.tsx @@ -4,35 +4,31 @@ import type { GovernanceHouse } from '../../types' interface HouseStepContentProps { selectedHouse?: GovernanceHouse - disabledHouseOptions: GovernanceHouse[] - stakeAmountLabel: string + stakeAmountLabels: Record onHouseSelect: (nextHouse: GovernanceHouse) => void } export function HouseStepContent({ selectedHouse, - disabledHouseOptions, - stakeAmountLabel, + stakeAmountLabels, onHouseSelect, }: HouseStepContentProps) { return ( - onHouseSelect('citizenship')} - /> onHouseSelect('alignment')} /> + onHouseSelect('citizenship')} + /> diff --git a/packages/governance-widget/src/onboarding/steps/ProfileStepContent.tsx b/packages/governance-widget/src/onboarding/steps/ProfileStepContent.tsx index fe74fca3..93d50aa6 100644 --- a/packages/governance-widget/src/onboarding/steps/ProfileStepContent.tsx +++ b/packages/governance-widget/src/onboarding/steps/ProfileStepContent.tsx @@ -65,7 +65,9 @@ function FormField({ /> {/* Show the requirement hint only when there is no error showing */} {helperText && !errorMessage ? ( - {helperText} + + {helperText} + ) : null} {errorMessage ? {errorMessage} : null} @@ -82,7 +84,6 @@ export function ProfileStepContent({ onProfileFieldBlur, onContinuePress, }: ProfileStepContentProps) { - return ( @@ -140,20 +141,28 @@ export function ProfileStepContent({ onBlur={(v) => onProfileFieldBlur('name', v)} /> - {selectedHouse === 'citizenship' ? ( - onProfileFieldChange('socialLinks', v)} - onBlur={(v) => onProfileFieldBlur('socialLinks', v)} - /> - ) : ( + onProfileFieldChange('socialLinks', v)} + onBlur={(v) => onProfileFieldBlur('socialLinks', v)} + /> + + {selectedHouse !== 'citizenship' && ( <> onProfileFieldChange('projectWebpage', v)} onBlur={(v) => onProfileFieldBlur('projectWebpage', v)} /> - onProfileFieldChange('missionStatement', v)} onBlur={(v) => onProfileFieldBlur('missionStatement', v)} /> - onProfileFieldChange('distributionStrategy', v)} - onBlur={(v) => onProfileFieldBlur('distributionStrategy', v)} - /> )} - {/* ── CTA button (Figma: inside card at bottom) ───────────────── */}