diff --git a/examples/storybook/src/fixtures/governanceRuntimeMock.ts b/examples/storybook/src/fixtures/governanceRuntimeMock.ts new file mode 100644 index 00000000..656703e8 --- /dev/null +++ b/examples/storybook/src/fixtures/governanceRuntimeMock.ts @@ -0,0 +1,172 @@ +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 interface MockGovernanceReadOptions { + memberStatus?: 0 | 1 | 2 | 3 | 4 + memberStatusByAccount?: Record + memberHouseByAccount?: 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': + return encodeFunctionResult({ + abi: HOUSES_READ_ABI, + functionName: 'getHasVoted', + result: 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: + throw new Error(`Unexpected houses read: ${decoded.functionName}`) + } +} diff --git a/examples/storybook/src/stories/governance-widget/GovernanceOnboarding.stories.tsx b/examples/storybook/src/stories/governance-widget/GovernanceOnboarding.stories.tsx index eb236198..a73695ff 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, diff --git a/examples/storybook/src/stories/governance-widget/GovernanceRuntime.stories.tsx b/examples/storybook/src/stories/governance-widget/GovernanceRuntime.stories.tsx index 56f77ac6..ff1d89fb 100644 --- a/examples/storybook/src/stories/governance-widget/GovernanceRuntime.stories.tsx +++ b/examples/storybook/src/stories/governance-widget/GovernanceRuntime.stories.tsx @@ -26,8 +26,15 @@ export default meta type Story = StoryObj const connectedAddress = '0x4E5B2D7a45C2e31a8F0d09b4bE1fA11aD3aC9F08' as const +const alignmentRecipients = [ + '0x1111111111111111111111111111111111111111', + '0x2222222222222222222222222222222222222222', + '0x3333333333333333333333333333333333333333', +] as const -function createDashboard(overrides: Partial = {}) { +function createDashboard( + overrides: Partial = {}, +): GovernanceWidgetAdapterState['dashboard'] { return { impact: { title: 'Distributed', @@ -55,12 +62,16 @@ function createDashboard(overrides: Partial = {}, ): GovernanceWidgetAdapterState { const isConnected = status !== 'disconnected' - const member = - status === 'active_citizenship' || status === 'active_alignment' || status === 'restake_required' + const member: GovernanceWidgetAdapterState['member'] = + status === 'active_citizenship' || status === 'active_alignment' || status === 'revoked' ? { house: status === 'active_alignment' ? 'alignment' : 'citizenship', - status: status === 'restake_required' ? 'unstaked' : 'active', + status: status === 'revoked' ? 'revoked' : 'active', stakedAmount: 250000000000000000000n, joinedAt: Date.UTC(2026, 0, 10), updatedAt: Date.UTC(2026, 2, 1), - unstakedAt: status === 'restake_required' ? Date.UTC(2026, 5, 1) : null, + unstakedAt: null, memberIndex: 0n, name: status === 'active_alignment' ? 'Solar Commons' : 'Maya Citizen', socialLinks: 'https://twitter.com/gooddollar', @@ -118,7 +129,6 @@ function createState( member, dashboard: createDashboard(), selectedHouse: 'citizenship', - disabledHouseOptions: status === 'onboarding_required' ? ['alignment'] : [], onboardingStepId: undefined, profileDraft: {}, stakeAmountLabel: '250 G$', @@ -130,6 +140,13 @@ function createState( { 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, } @@ -145,7 +162,7 @@ function createAdapterFactory(state: GovernanceWidgetAdapterState): GovernanceWi retry: async () => {}, selectHouse: () => {}, register: async () => {}, - restake: async () => {}, + unstake: async () => {}, openVote: () => {}, closeVote: () => {}, setVoteAllocation: () => {}, @@ -197,12 +214,11 @@ export const LoadingConnected: Story = { render: () => , } -export const OnboardingRequiredHoaUnavailable: Story = { +export const OnboardingHouseSelection: Story = { render: () => ( @@ -217,6 +233,25 @@ export const ActiveCitizenship: Story = { render: () => , } +export const UpcomingVote: Story = { + render: () => ( + + ), +} + export const ActiveAlignmentInjected: Story = { render: () => ( , } -export const RestakeRequired: 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 = { @@ -345,3 +468,24 @@ export const FriendlyContractError: Story = { /> ), } + +export const RealAdapterMockedRuntime: Story = { + render: () => { + const injectedProvider = getInjectedEip1193Provider() + const provider = isInjectedProviderUsable(injectedProvider) + ? injectedProvider + : createCustodialEip1193Provider() + + return ( + + ) + }, +} diff --git a/packages/governance-widget/src/GovernanceOnboardingWidget.tsx b/packages/governance-widget/src/GovernanceOnboardingWidget.tsx index e0272f63..4439c7a8 100644 --- a/packages/governance-widget/src/GovernanceOnboardingWidget.tsx +++ b/packages/governance-widget/src/GovernanceOnboardingWidget.tsx @@ -2,27 +2,23 @@ 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, @@ -39,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 ( + - - GoodDAO Governance - - Browse governance impact, funding distribution, and active Alignment voting. - - + + + + + GoodDAO + {state.address ? ( @@ -57,7 +84,7 @@ function GovernanceHeader({ )} - + ) } @@ -174,7 +201,8 @@ function PendingAlignmentState({ state }: { state: GovernanceWidgetAdapterState Alignment membership pending - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Your House of Alignment application is waiting for committee approval. + 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'} @@ -184,32 +212,84 @@ function PendingAlignmentState({ state }: { state: GovernanceWidgetAdapterState ) } -function RestakeState({ +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 ( - + - Restore governance membership + Membership stake - This member is currently {state.member?.status ?? 'inactive'}. You can restake to rejoin the selected house. + 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 MemberFooter({ state }: { state: GovernanceWidgetAdapterState }) { if (!state.member || !isActiveStatus(state.status)) return null @@ -238,8 +318,21 @@ function GovernanceVoteDetail({ actions: GovernanceWidgetAdapterActions }) { const vote = state.dashboard.alignmentVoting - const canSubmit = vote.canVote && vote.allocationTotalBps === 10000 && !vote.hasVoted && vote.isVotingOpen - const isReadOnly = vote.hasVoted || vote.executed + 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 ( @@ -251,7 +344,8 @@ function GovernanceVoteDetail({ - Placeholder voting detail. Enter allocation basis points; totals must equal 10,000 before voting. + Allocate basis points across the recipients captured when this vote opened. + Your allocation must total exactly 10,000 basis points. {vote.options.map((option) => @@ -278,7 +372,21 @@ function GovernanceVoteDetail({ Already voted — this contract does not support ballot replacement. ) : null} - {!canSubmit ? {vote.disabledReason ?? 'Voting is unavailable.'} : 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} ) @@ -166,37 +162,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 } @@ -205,7 +198,7 @@ export function GovernanceOnboardingFlow({ shellContent = ( ) 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 0430b601..45ef3893 100644 --- a/packages/governance-widget/src/onboarding/OnboardingIdentityCard.tsx +++ b/packages/governance-widget/src/onboarding/OnboardingIdentityCard.tsx @@ -124,7 +124,6 @@ export function OnboardingIdentityCard({ - {/* ── CTA button ─────────────────────────────────────────── */} {isVerified ? (