diff --git a/examples/storybook/.storybook/main.ts b/examples/storybook/.storybook/main.ts index 4a35a68..ca94d9a 100644 --- a/examples/storybook/.storybook/main.ts +++ b/examples/storybook/.storybook/main.ts @@ -2,11 +2,14 @@ * Storybook main configuration. * * - Framework: @storybook/react-vite - * - Addons: essentials (controls, docs, actions, viewport) + interactions (play functions) + * - Addons: essentials (controls, docs, actions) + interactions (play functions) * - viteFinal: mirrors the react-native-web + Tamagui settings from examples/react-web */ +import { fileURLToPath } from 'node:url' import type { StorybookConfig } from '@storybook/react-vite' +const reactNativeSvgShim = fileURLToPath(new URL('../src/shims/reactNativeSvg.tsx', import.meta.url)) + const config: StorybookConfig = { stories: ['../src/**/*.stories.@(ts|tsx)'], addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'], @@ -18,7 +21,7 @@ const config: StorybookConfig = { autodocs: 'tag', }, viteFinal: async (config) => { - // Mirror the Vite settings from examples/react-web so Tamagui + react-native-web resolve + // Mirror the Vite settings from examples/react-web so Tamagui + react-native-web resolve. config.define = { ...config.define, global: 'globalThis', @@ -30,6 +33,7 @@ const config: StorybookConfig = { alias: { ...(config.resolve?.alias as Record | undefined), 'react-native': 'react-native-web', + 'react-native-svg': reactNativeSvgShim, }, } config.optimizeDeps = { diff --git a/examples/storybook/package.json b/examples/storybook/package.json index 25373c9..dc1e8b8 100644 --- a/examples/storybook/package.json +++ b/examples/storybook/package.json @@ -13,6 +13,7 @@ "@goodwidget/ui": "workspace:*", "@goodwidget/claim-widget-theme-demo": "workspace:*", "@goodwidget/citizen-claim-widget": "workspace:*", + "@goodwidget/governance-widget": "workspace:*", "react": "^18.3.0", "react-dom": "^18.3.0", "react-native-web": "^0.19.13", diff --git a/examples/storybook/src/shims/reactNativeSvg.tsx b/examples/storybook/src/shims/reactNativeSvg.tsx new file mode 100644 index 0000000..09c0d37 --- /dev/null +++ b/examples/storybook/src/shims/reactNativeSvg.tsx @@ -0,0 +1,32 @@ +import React from 'react' + +type SvgElementProps = React.SVGProps & { + accessibilityRole?: string +} + +type SvgGroupProps = React.SVGProps & { + rotation?: string | number + origin?: string +} + +type SvgCircleProps = React.SVGProps & { + onPress?: () => void +} + +/** Storybook web shim for the react-native-svg primitives used by the donut chart. */ +export default function Svg({ accessibilityRole: _accessibilityRole, ...props }: SvgElementProps) { + return +} + +/** Mirrors react-native-svg's G transform props with standard SVG attributes. */ +export function G({ rotation, origin, transform, ...props }: SvgGroupProps) { + const rotationTransform = rotation ? `rotate(${rotation} ${origin ?? ''})`.trim() : undefined + const combinedTransform = [transform, rotationTransform].filter(Boolean).join(' ') + + return +} + +/** Maps react-native-svg onPress to the browser SVG onClick event for stories. */ +export function Circle({ onPress, ...props }: SvgCircleProps) { + return +} diff --git a/examples/storybook/src/stories/governance-widget/GovernanceDashboard.stories.tsx b/examples/storybook/src/stories/governance-widget/GovernanceDashboard.stories.tsx new file mode 100644 index 0000000..894697a --- /dev/null +++ b/examples/storybook/src/stories/governance-widget/GovernanceDashboard.stories.tsx @@ -0,0 +1,304 @@ +import React, { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { Text, XStack, YStack } from '@goodwidget/ui' +import { + AlignmentVotingProposalCard, + BalanceCard, + FundingDistributionChart, + ImpactCard, + OptimisticVotingProposalCard, +} from '@goodwidget/governance-widget' +import type { + FundingProjectAllocation, + RankedVotingOption, + VoteSegment, + VoterPreview, +} from '@goodwidget/governance-widget' + +const meta: Meta = { + title: 'Widgets/GovernanceDashboard', + tags: ['autodocs'], + parameters: { + layout: 'centered', + goodWidgetProvider: { useShell: false, defaultTheme: 'light' }, + }, +} + +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 voters: VoterPreview[] = [ + { id: 'maya', label: 'Maya' }, + { id: 'kenji', label: 'Kenji' }, + { id: 'sol', label: 'Sol' }, + { id: 'ama', label: 'Ama' }, +] + +const fundingProjects: FundingProjectAllocation[] = [ + { + id: 'food', + name: 'Local Food Chain', + amount: { value: 12400, token: 'G$', isStreaming: true }, + percentage: 42, + }, + { + id: 'literacy', + name: 'Web3 Literacy for Community Builders', + amount: { value: 9100, token: 'G$' }, + percentage: 31, + }, + { + id: 'civic', + name: 'Civic Onboarding', + amount: { value: 7900, token: 'G$' }, + percentage: 27, + }, +] + +function GovernanceStoryFrame({ + children, + width = 520, +}: { + children: React.ReactNode + width?: number +}) { + const [lastAction, setLastAction] = useState('No interaction yet') + + 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: { + goodWidgetProvider: { useShell: false, defaultTheme: 'dark' }, + viewport: { defaultViewport: 'mobile1' }, + }, + render: () => ( + + + + ), +} + +export const BalanceVariantsLight: Story = { + render: () => ( + + + + + ), +} + +export const BalanceDarkCompact: Story = { + parameters: { goodWidgetProvider: { useShell: false, defaultTheme: 'dark' } }, + render: () => ( + + + + ), +} + +export const AlignmentDefaultLight: Story = { + render: () => ( + + + + ), +} + +export const AlignmentDarkLongOptions: Story = { + parameters: { goodWidgetProvider: { useShell: false, defaultTheme: 'dark' } }, + render: () => ( + + + + ), +} + +export const OptimisticHighQuorumLight: Story = { + render: () => ( + + + + ), +} + +export const OptimisticDarkLowQuorumMixed: Story = { + parameters: { goodWidgetProvider: { useShell: false, defaultTheme: 'dark' } }, + render: () => ( + + + + ), +} + +export const FundingDistributionLight: Story = { + render: () => ( + + + + ), +} + +export const FundingDistributionDarkEmptyMobile: Story = { + parameters: { + goodWidgetProvider: { useShell: false, defaultTheme: 'dark' }, + 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 new file mode 100644 index 0000000..45b870a --- /dev/null +++ b/examples/storybook/src/stories/governance-widget/GovernanceOnboarding.stories.tsx @@ -0,0 +1,345 @@ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { Card, Text, YStack } from '@goodwidget/ui' +import { + GovernanceOnboardingWidget, + type GovernanceOnboardingWidgetProps, +} from '@goodwidget/governance-widget' +import { + getInjectedEip1193Provider, + isInjectedProviderUsable, +} from '../../fixtures/injectedEip1193' +import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193' + +const meta: Meta = { + title: 'Widgets/GovernanceOnboarding', + component: GovernanceOnboardingWidget, + tags: ['autodocs'], + parameters: { + layout: 'centered', + goodWidgetProvider: { useShell: false, defaultTheme: 'light' }, + }, +} + +export default meta +type Story = StoryObj + +const failedTransactionSteps = [ + { + id: 'prepare', + title: 'Prepare wallet balance', + status: 'completed', + }, + { + id: 'approve', + title: 'Approve governance stake', + status: 'completed', + }, + { + id: 'stake', + title: 'Lock the membership stake', + description: 'The previous transaction failed and needs a retry from the wallet.', + status: 'failed', + }, + { + id: 'finalize', + title: 'Finalize governance access', + status: 'pending', + }, +] as const + +function GovernanceStoryFrame({ + walletLabel, + children, + dataTestId, + width = 440, +}: { + walletLabel: string + children: React.ReactNode + dataTestId: string + width?: number +}) { + return ( + + + + {walletLabel} + + + {children} + + ) +} + +function InjectedGovernanceStory({ + walletLabel, + storyProps, + dataTestId, +}: { + walletLabel: string + storyProps: GovernanceOnboardingWidgetProps + dataTestId: string +}) { + const injectedProvider = getInjectedEip1193Provider() + const usableProvider = isInjectedProviderUsable(injectedProvider) + + if (!usableProvider) { + return ( + + + No injected wallet found + + Install or enable an injected EIP-1193 wallet in this browser, then refresh Storybook. + + + + ) + } + + return ( + + + + ) +} + +function CustodialGovernanceStory({ + walletLabel, + storyProps, + dataTestId, + width, +}: { + walletLabel: string + storyProps: GovernanceOnboardingWidgetProps + dataTestId: string + width?: number +}) { + try { + createCustodialEip1193Provider() + + return ( + + + + ) + } catch (error: unknown) { + return ( + + + Custodial fixture not configured + + {error instanceof Error ? error.message : 'Set a local private key in custodialEip1193.ts'} + + + + ) + } +} + +export const InjectedWelcomeUnverified: Story = { + render: () => ( + + ), +} + +export const CustodialInteractiveFlow: Story = { + render: () => ( + + ), +} + +export const CustodialWelcomeUnverified: Story = { + render: () => ( + + ), +} + +export const CustodialHouseSelection: Story = { + render: () => ( + + ), +} + +export const CustodialCitizenshipProfileReady: Story = { + render: () => ( + + ), +} + +export const CustodialAlignmentProfileError: Story = { + parameters: { + goodWidgetProvider: { useShell: false, defaultTheme: 'dark' }, + }, + render: () => ( + + ), +} + +export const CustodialStakeProgress: Story = { + parameters: { + goodWidgetProvider: { useShell: false, defaultTheme: 'dark' }, + }, + render: () => ( + + ), +} + +export const CustodialSuccess: Story = { + render: () => ( + + ), +} + +function CustodialGovernanceStoryAtWidth({ + walletLabel, + storyProps, + dataTestId, + width, +}: { + walletLabel: string + storyProps: GovernanceOnboardingWidgetProps + dataTestId: string + width: number +}) { + return ( + + ) +} + +export const CustodialMobileWelcome: Story = { + parameters: { + goodWidgetProvider: { useShell: false, defaultTheme: 'light' }, + viewport: { defaultViewport: 'mobile1' }, + }, + render: () => ( + + ), +} + +export const CustodialMobileDarkProfile: Story = { + parameters: { + goodWidgetProvider: { useShell: false, defaultTheme: 'dark' }, + viewport: { defaultViewport: 'mobile1' }, + }, + render: () => ( + + ), +} diff --git a/examples/storybook/src/stories/governance-widget/screenshots/README.md b/examples/storybook/src/stories/governance-widget/screenshots/README.md new file mode 100644 index 0000000..42eaaf4 --- /dev/null +++ b/examples/storybook/src/stories/governance-widget/screenshots/README.md @@ -0,0 +1,43 @@ +# Governance Onboarding — Reference Screenshots + +This directory holds the **curated reference set** for the governance onboarding widget. +The PNGs are produced by the Playwright suite (`pnpm test:demo tests/widgets/governance-widget`) +and copied here for human review. + +## How to regenerate + +```bash +# From repo root: +pnpm --filter @goodwidget/storybook build-storybook # optional, dev server also works +pnpm storybook & # start dev server on :6006 +pnpm test:demo tests/widgets/governance-widget # runs the suite, writes test-results/ +``` + +Then copy each `tests/widgets/governance-widget/test-results/gwo-NN-*.png` into this +directory under its state-based name below. + +## What's here + +| State | Story | Notes | +|---|---|---| +| `welcome-verified.png` | `custodialInteractiveFlow` (step 1) | Verified welcome; CTA enabled | +| `welcome-unverified.png` | `custodialWelcomeUnverified` | Unverified welcome; CTA disabled | +| `house-selection.png` | `custodialInteractiveFlow` (step 2) | House selection inside the interactive flow | +| `house-selection-standalone.png` | `custodialHouseSelection` | House selection as a standalone story | +| `profile-alignment.png` | `custodialInteractiveFlow` (step 3) | Alignment profile with all fields filled | +| `profile-citizenship-ready.png` | `custodialCitizenshipProfileReady` | Citizenship profile ready to continue | +| `profile-alignment-error.png` | `custodialAlignmentProfileError` | Alignment profile with field errors | +| `stake-progress-active.png` | `custodialInteractiveFlow` (step 4) | Stake stepper with active transaction | +| `stake-progress-failed.png` | `custodialStakeProgress` | Stake stepper with failed transaction | +| `success.png` | `custodialInteractiveFlow` (step 5) | Success state inside the interactive flow | +| `success-standalone.png` | `custodialSuccess` | Success state as a standalone story | + +## Naming convention + +- **`screenshots/`** — curated, state-named reference set, checked in, regenerated + on demand. This is what reviewers look at. +- **`tests/widgets/governance-widget/test-results/`** — Playwright run artifacts. + These churn on every test run and should not be considered reference quality. + +The two directories intentionally diverge to keep reference evidence stable while +allowing test artifacts to evolve with the test suite. diff --git a/examples/storybook/src/stories/governance-widget/screenshots/house-selection-standalone.png b/examples/storybook/src/stories/governance-widget/screenshots/house-selection-standalone.png new file mode 100644 index 0000000..8c92079 Binary files /dev/null and b/examples/storybook/src/stories/governance-widget/screenshots/house-selection-standalone.png differ diff --git a/examples/storybook/src/stories/governance-widget/screenshots/house-selection.png b/examples/storybook/src/stories/governance-widget/screenshots/house-selection.png new file mode 100644 index 0000000..3ec1172 Binary files /dev/null and b/examples/storybook/src/stories/governance-widget/screenshots/house-selection.png differ diff --git a/examples/storybook/src/stories/governance-widget/screenshots/profile-alignment-error.png b/examples/storybook/src/stories/governance-widget/screenshots/profile-alignment-error.png new file mode 100644 index 0000000..41aeead Binary files /dev/null and b/examples/storybook/src/stories/governance-widget/screenshots/profile-alignment-error.png differ diff --git a/examples/storybook/src/stories/governance-widget/screenshots/profile-alignment.png b/examples/storybook/src/stories/governance-widget/screenshots/profile-alignment.png new file mode 100644 index 0000000..a1b7394 Binary files /dev/null and b/examples/storybook/src/stories/governance-widget/screenshots/profile-alignment.png differ diff --git a/examples/storybook/src/stories/governance-widget/screenshots/profile-citizenship-ready.png b/examples/storybook/src/stories/governance-widget/screenshots/profile-citizenship-ready.png new file mode 100644 index 0000000..55d2971 Binary files /dev/null and b/examples/storybook/src/stories/governance-widget/screenshots/profile-citizenship-ready.png differ diff --git a/examples/storybook/src/stories/governance-widget/screenshots/stake-progress-active.png b/examples/storybook/src/stories/governance-widget/screenshots/stake-progress-active.png new file mode 100644 index 0000000..fa59af4 Binary files /dev/null and b/examples/storybook/src/stories/governance-widget/screenshots/stake-progress-active.png differ diff --git a/examples/storybook/src/stories/governance-widget/screenshots/stake-progress-failed.png b/examples/storybook/src/stories/governance-widget/screenshots/stake-progress-failed.png new file mode 100644 index 0000000..734919b Binary files /dev/null and b/examples/storybook/src/stories/governance-widget/screenshots/stake-progress-failed.png differ diff --git a/examples/storybook/src/stories/governance-widget/screenshots/success-standalone.png b/examples/storybook/src/stories/governance-widget/screenshots/success-standalone.png new file mode 100644 index 0000000..d94e3a9 Binary files /dev/null and b/examples/storybook/src/stories/governance-widget/screenshots/success-standalone.png differ diff --git a/examples/storybook/src/stories/governance-widget/screenshots/success.png b/examples/storybook/src/stories/governance-widget/screenshots/success.png new file mode 100644 index 0000000..d94e3a9 Binary files /dev/null and b/examples/storybook/src/stories/governance-widget/screenshots/success.png differ diff --git a/examples/storybook/src/stories/governance-widget/screenshots/welcome-unverified.png b/examples/storybook/src/stories/governance-widget/screenshots/welcome-unverified.png new file mode 100644 index 0000000..e9b145d Binary files /dev/null and b/examples/storybook/src/stories/governance-widget/screenshots/welcome-unverified.png differ diff --git a/examples/storybook/src/stories/governance-widget/screenshots/welcome-verified.png b/examples/storybook/src/stories/governance-widget/screenshots/welcome-verified.png new file mode 100644 index 0000000..039448b Binary files /dev/null and b/examples/storybook/src/stories/governance-widget/screenshots/welcome-verified.png differ diff --git a/packages/governance-widget/package.json b/packages/governance-widget/package.json new file mode 100644 index 0000000..007f847 --- /dev/null +++ b/packages/governance-widget/package.json @@ -0,0 +1,39 @@ +{ + "name": "@goodwidget/governance-widget", + "version": "0.1.0", + "description": "Presentational governance homepage components for GoodWidget", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src/", + "clean": "rm -rf dist .turbo" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "dependencies": { + "@goodwidget/ui": "workspace:*", + "react-native-svg": "15.15.5", + "tamagui": "1.121.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "tsup": "^8.4.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/governance-widget/src/GovernanceOnboardingWidget.tsx b/packages/governance-widget/src/GovernanceOnboardingWidget.tsx new file mode 100644 index 0000000..14a7420 --- /dev/null +++ b/packages/governance-widget/src/GovernanceOnboardingWidget.tsx @@ -0,0 +1,63 @@ +import React, { 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 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$', + transactionSteps = DEFAULT_TRANSACTION_STEPS, + finalActions = DEFAULT_FINAL_ACTIONS, + dataTestId, + onStepChange, + onVerifyIdentity, + onFinalActionPress, +}: GovernanceOnboardingWidgetProps) { + const initialWizardData = useMemo( + () => ({ + selectedHouse: initialHouse, + profileDraft: initialProfileDraft ?? {}, + }), + [initialHouse, initialProfileDraft], + ) + + return ( + onStepChange?.(stepId as GovernanceOnboardingStepId)} + > + + + ) +} diff --git a/packages/governance-widget/src/governanceComponents.tsx b/packages/governance-widget/src/governanceComponents.tsx new file mode 100644 index 0000000..ccce74e --- /dev/null +++ b/packages/governance-widget/src/governanceComponents.tsx @@ -0,0 +1,606 @@ +import React from 'react' +import Svg, { Circle, G } from 'react-native-svg' +import { Stack, useTheme } from 'tamagui' +import { + Badge, + BadgeText, + Button, + ButtonText, + Card, + Heading, + Icon, + Text, + TokenAmount, + XStack, + YStack, +} from '@goodwidget/ui' +import type { IconName } from '@goodwidget/ui' + +export interface GovernanceAmount { + value: string | number + token?: string + isStreaming?: boolean + streamLabel?: string +} + +export interface ImpactCardMetric { + label: string + amount: GovernanceAmount + description?: string +} + +export interface ImpactCardProps { + title: string + metrics: [ImpactCardMetric, ImpactCardMetric] + description: string + ctaLabel?: string + ctaDisabled?: boolean + onCtaPress?: () => void + testID?: string +} + +export interface BalanceCardMetadata { + label: string + tone?: 'default' | 'positive' | 'muted' + icon?: IconName +} + +export interface BalanceCardProps { + icon: IconName + title: string + amount: GovernanceAmount | string | number + amountType?: 'token' | 'raw' + metadata: BalanceCardMetadata + compact?: boolean + testID?: string +} + +export interface RankedVotingOption { + id: string + label: string + percentage: number +} + +export interface AlignmentVotingProposalCardProps { + id: string + categoryLabel: string + title: string + summaryLabel?: string + options: RankedVotingOption[] + maxVisibleOptions?: number + onPress?: (id: string) => void + testID?: string +} + +export interface VoteSegment { + id: string + label: string + percentage: number + tone?: 'for' | 'against' | 'abstain' | 'neutral' +} + +export interface VoterPreview { + id: string + label: string + avatarUrl?: string +} + +export interface OptimisticVotingProposalCardProps { + id: string + categoryLabel: string + title: string + quorumLabel?: string + quorumReachedPercent: number + voteSegments: VoteSegment[] + voters: VoterPreview[] + remainingVoterCountLabel?: string + onPress?: (id: string) => void + testID?: string +} + +export interface FundingProjectAllocation { + id: string + name: string + amount: GovernanceAmount + percentage: number +} + +export interface FundingDistributionChartProps { + totalAmount: GovernanceAmount + projects: FundingProjectAllocation[] + isStreaming?: boolean + onProjectPress?: (id: string) => void + testID?: string +} + +const SEGMENT_TONES: Record, string> = { + for: '$primary', + against: '$error', + abstain: '$placeholderColor', + neutral: '$success', +} + +const DONUT_COLOR_KEYS = ['primary', 'success', 'warning', 'info', 'error'] as const + +function clampPercentage(value: number): number { + if (!Number.isFinite(value)) { + return 0 + } + + return Math.max(0, Math.min(100, value)) +} + +function formatRawValue(value: string | number): string { + if (typeof value === 'string') { + return value + } + + return new Intl.NumberFormat('en-US', { maximumFractionDigits: 2 }).format(value) +} + +function isGovernanceAmount(value: GovernanceAmount | string | number): value is GovernanceAmount { + return typeof value === 'object' && value !== null && 'value' in value +} + +function resolveThemeColor(theme: ReturnType, key: string, fallback: string): string { + const themeValue = theme[key as keyof typeof theme] + + if (themeValue && typeof themeValue === 'object' && 'val' in themeValue) { + return String(themeValue.val) + } + + return fallback +} + +function renderGovernanceAmount(amount: GovernanceAmount, size: 'sm' | 'md' | 'lg' | 'xl' = 'lg') { + return ( + + {amount.token ? ( + + ) : ( + {formatRawValue(amount.value)} + )} + {amount.isStreaming ? ( + + {amount.streamLabel ?? 'Live stream'} + + ) : null} + + ) +} + +function MetricBox({ metric }: { metric: ImpactCardMetric }) { + return ( + + + {metric.label} + + {renderGovernanceAmount(metric.amount, 'lg')} + {metric.description ? ( + + {metric.description} + + ) : null} + + ) +} + +function ProposalHeader({ categoryLabel }: { categoryLabel: string }) { + return ( + + + {categoryLabel} + + + + ) +} + +function ProgressBar({ percentage, colorToken = '$primary' }: { percentage: number; colorToken?: string }) { + return ( + + + + ) +} + +function RankedOptionRow({ option }: { option: RankedVotingOption }) { + return ( + + + + {option.label} + + + {clampPercentage(option.percentage)}% + + + + + ) +} + +function StackedProgressBar({ segments }: { segments: VoteSegment[] }) { + return ( + + {segments.map((segment) => ( + + ))} + + ) +} + +function VoterAvatar({ voter, index }: { voter: VoterPreview; index: number }) { + const initial = voter.label.trim().slice(0, 1).toUpperCase() || '?' + + return ( + + {voter.avatarUrl ? ( + {voter.label} + ) : ( + + {initial} + + )} + + ) +} + +function VoterPreviewGroup({ voters, remainingLabel }: { voters: VoterPreview[]; remainingLabel?: string }) { + return ( + + + {voters.slice(0, 4).map((voter, index) => ( + + ))} + + {remainingLabel ? ( + + {remainingLabel} + + ) : null} + + ) +} + +function VoteLegend({ segments }: { segments: VoteSegment[] }) { + return ( + + {segments.map((segment) => ( + + + + {segment.label} {clampPercentage(segment.percentage)}% + + + ))} + + ) +} + +function fundingAmountLabel(amount: GovernanceAmount): string { + const base = amount.token ? `${formatRawValue(amount.value)} ${amount.token}` : formatRawValue(amount.value) + + if (amount.isStreaming) { + return `${base} streaming` + } + + return base +} + +function FundingLegend({ + projects, + colors, + onProjectPress, +}: { + projects: FundingProjectAllocation[] + colors: string[] + onProjectPress?: (id: string) => void +}) { + if (projects.length === 0) { + return ( + + No active funding distribution yet. + + ) + } + + return ( + + {projects.map((project, index) => ( + + ))} + + ) +} + +function FundingDonut({ + projects, + totalAmount, + colors, + onProjectPress, +}: { + projects: FundingProjectAllocation[] + totalAmount: GovernanceAmount + colors: string[] + onProjectPress?: (id: string) => void +}) { + const size = 196 + const strokeWidth = 22 + const radius = (size - strokeWidth) / 2 + const circumference = 2 * Math.PI * radius + let offset = 0 + + return ( + + + + + {projects.map((project, index) => { + const percentage = clampPercentage(project.percentage) + const dashLength = (percentage / 100) * circumference + const dashOffset = -offset + offset += dashLength + + return ( + onProjectPress(project.id) : undefined} + /> + ) + })} + + + + {renderGovernanceAmount(totalAmount, 'md')} + + Total active funding + + + + ) +} + +export function ImpactCard({ + title, + metrics, + description, + ctaLabel, + ctaDisabled = false, + onCtaPress, + testID, +}: ImpactCardProps) { + return ( + + {title} + + {metrics.map((metric) => ( + + ))} + + {description} + {ctaLabel ? ( + + ) : null} + + ) +} + +export function BalanceCard({ + icon, + title, + amount, + amountType = 'token', + metadata, + compact = false, + testID, +}: BalanceCardProps) { + const amountValue = isGovernanceAmount(amount) ? amount : { value: amount, token: amountType === 'token' ? 'G$' : undefined } + const metadataTone = metadata.tone === 'positive' ? 'default' : metadata.tone === 'muted' ? 'secondary' : 'soft' + + return ( + + + + + {title} + + + {renderGovernanceAmount(amountValue, compact ? 'md' : 'lg')} + + {metadata.icon ? : null} + + {metadata.label} + + + + ) +} + +export function AlignmentVotingProposalCard({ + id, + categoryLabel, + title, + summaryLabel = 'Current top voted', + options, + maxVisibleOptions = 3, + onPress, + testID, +}: AlignmentVotingProposalCardProps) { + const visibleOptions = options.slice(0, maxVisibleOptions) + const hiddenCount = Math.max(0, options.length - visibleOptions.length) + + return ( + onPress(id) : undefined} + role={onPress ? 'button' : undefined} + aria-label={`Open proposal ${title}`} + > + + {title} + + + + {summaryLabel} + + + + {visibleOptions.map((option) => ( + + ))} + {hiddenCount > 0 ? ( + + +{hiddenCount} more options + + ) : null} + + + ) +} + +export function OptimisticVotingProposalCard({ + id, + categoryLabel, + title, + quorumLabel = 'Current vote quorum', + quorumReachedPercent, + voteSegments, + voters, + remainingVoterCountLabel, + onPress, + testID, +}: OptimisticVotingProposalCardProps) { + return ( + onPress(id) : undefined} + role={onPress ? 'button' : undefined} + aria-label={`Open proposal ${title}`} + > + + {title} + + + + {quorumLabel} + + + {clampPercentage(quorumReachedPercent)}% reached + + + + + + + + + + + + + + + ) +} + +export function FundingDistributionChart({ + totalAmount, + projects, + isStreaming = false, + onProjectPress, + testID, +}: FundingDistributionChartProps) { + const theme = useTheme() + const fallbackColors = ['#2563eb', '#16a34a', '#d97706', '#0891b2', '#dc2626'] + const colors = DONUT_COLOR_KEYS.map((key, index) => resolveThemeColor(theme, key, fallbackColors[index])) + const total = { ...totalAmount, isStreaming: totalAmount.isStreaming ?? isStreaming } + + return ( + + + Funding distribution + Current allocation across active governance projects. + + + + + + + ) +} diff --git a/packages/governance-widget/src/index.ts b/packages/governance-widget/src/index.ts new file mode 100644 index 0000000..c09acd0 --- /dev/null +++ b/packages/governance-widget/src/index.ts @@ -0,0 +1,33 @@ +export { GovernanceOnboardingWidget } from './GovernanceOnboardingWidget' +export type { + GovernanceHouse, + GovernanceIdentityStatus, + GovernanceOnboardingAction, + GovernanceOnboardingStepId, + GovernanceOnboardingWidgetProps, + GovernanceProfileDraft, + GovernanceProfileFieldErrors, + GovernanceProfileFieldKey, + GovernanceWizardData, +} from './types' +export { + ImpactCard, + BalanceCard, + AlignmentVotingProposalCard, + OptimisticVotingProposalCard, + FundingDistributionChart, +} from './governanceComponents' +export type { + GovernanceAmount, + ImpactCardMetric, + ImpactCardProps, + BalanceCardProps, + BalanceCardMetadata, + RankedVotingOption, + AlignmentVotingProposalCardProps, + VoteSegment, + VoterPreview, + OptimisticVotingProposalCardProps, + FundingProjectAllocation, + FundingDistributionChartProps, +} from './governanceComponents' diff --git a/packages/governance-widget/src/onboarding/GovernanceOnboardingFlow.tsx b/packages/governance-widget/src/onboarding/GovernanceOnboardingFlow.tsx new file mode 100644 index 0000000..a33bc66 --- /dev/null +++ b/packages/governance-widget/src/onboarding/GovernanceOnboardingFlow.tsx @@ -0,0 +1,200 @@ +import React, { useState } 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 { isProfileDraftComplete, validateProfileDraft } from './validation' +import { WelcomeStepContent } from './steps/WelcomeStepContent' +import { HouseStepContent } from './steps/HouseStepContent' +import { ProfileStepContent } from './steps/ProfileStepContent' +import { StakeStepContent } from './steps/StakeStepContent' +import { SuccessStepContent } from './steps/SuccessStepContent' +import type { + GovernanceHouse, + GovernanceIdentityStatus, + GovernanceOnboardingAction, + GovernanceOnboardingStepId, + GovernanceProfileFieldErrors, + GovernanceProfileFieldKey, + GovernanceWizardData, +} from '../types' +import type { StepperStepItem } from '@goodwidget/ui' + +interface GovernanceOnboardingFlowProps { + identityStatus: GovernanceIdentityStatus + walletAddress?: string + disabledHouseOptions: GovernanceHouse[] + initialFieldErrors: GovernanceProfileFieldErrors + stakeAmountLabel: string + transactionSteps: StepperStepItem[] + finalActions: GovernanceOnboardingAction[] + onVerifyIdentity?: () => void + onFinalActionPress?: (actionId: string) => void + dataTestId?: string +} + +export function GovernanceOnboardingFlow({ + identityStatus, + walletAddress, + disabledHouseOptions, + initialFieldErrors, + stakeAmountLabel, + transactionSteps = DEFAULT_TRANSACTION_STEPS, + finalActions = DEFAULT_FINAL_ACTIONS, + onVerifyIdentity, + onFinalActionPress, + dataTestId, +}: GovernanceOnboardingFlowProps) { + const { currentStep, data, setData, next, back, isFirst } = usePageWizard() + const [fieldErrors, setFieldErrors] = useState(initialFieldErrors) + + const wizardData = data as GovernanceWizardData + const selectedHouse = wizardData.selectedHouse + const profileDraft = wizardData.profileDraft ?? {} + const resolvedHouse: GovernanceHouse = selectedHouse ?? 'citizenship' + const isIdentityVerified = identityStatus === 'verified' + + const updateProfileField = (fieldKey: GovernanceProfileFieldKey, nextValue: string) => { + setData((previousData) => { + const previousDraft = + (previousData as GovernanceWizardData).profileDraft ?? {} + return { + profileDraft: { + ...previousDraft, + [fieldKey]: nextValue, + }, + } + }) + + setFieldErrors((previousErrors) => { + const nextErrors = { ...previousErrors } + delete nextErrors[fieldKey] + return nextErrors + }) + } + + const handleProfileContinue = () => { + const nextFieldErrors = validateProfileDraft(resolvedHouse, profileDraft) + setFieldErrors(nextFieldErrors) + + if (Object.keys(nextFieldErrors).length === 0) { + next() + } + } + + const handleHouseSelect = (nextHouse: GovernanceHouse) => { + setData({ selectedHouse: nextHouse }) + } + + let shellTitle = 'Governance onboarding' + let shellDescription = '' + let shellContent: React.ReactNode = null + let shellFooter: React.ReactNode = null + let hideStepper = false + + switch (currentStep?.id as GovernanceOnboardingStepId | undefined) { + case 'welcome': + default: + shellTitle = 'Welcome' + shellDescription = + 'Before entering governance, we must verify your unique identity status on the GoodDollar Protocol.' + shellContent = ( + + ) + // 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.' + shellContent = ( + + ) + shellFooter = ( + + + + + ) + break + + case 'profile': + shellTitle = `Apply for ${HOUSE_COPY[resolvedHouse].title}` + shellDescription = + 'Finalize your application by providing your details and staking the required amount.' + shellContent = ( + + ) + // Footer is null — "Create Profile and Stake" is inside ProfileStepContent card + shellFooter = null + break + + case 'stake': + shellTitle = 'Creating profile & staking' + shellDescription = + 'Please wait while your transaction is confirmed on-chain. You can review each step below.' + shellContent = ( + + ) + shellFooter = ( + + + + + ) + break + + case 'success': + hideStepper = true + shellContent = ( + + ) + shellFooter = null + break + } + + return ( + + {shellContent} + + ) +} diff --git a/packages/governance-widget/src/onboarding/HouseSelectionCard.tsx b/packages/governance-widget/src/onboarding/HouseSelectionCard.tsx new file mode 100644 index 0000000..5b0414f --- /dev/null +++ b/packages/governance-widget/src/onboarding/HouseSelectionCard.tsx @@ -0,0 +1,151 @@ +import React from 'react' +import { Stack, styled } from 'tamagui' +import { Badge, BadgeText, Heading, Icon, PillText, Text, XStack, YStack } from '@goodwidget/ui' +import { HOUSE_COPY } from './copy' +import type { GovernanceHouse } from '../types' + +/** + * Internal house-selection button. Uses Tamagui's `styled()` directly (not + * `createComponent`) because this is governance-specific UI, not a reusable + * design-system primitive. Registering a `name` here would pollute the public + * `@goodwidget/ui` manifest namespace with widget-internal components. + */ +const HouseOptionButton = styled(Stack, { + tag: 'button', + alignItems: 'flex-start', + justifyContent: 'flex-start', + width: '100%', + borderRadius: '$4', + borderWidth: 1, + borderColor: '$borderColor', + backgroundColor: '$background', + padding: '$4', + gap: '$3', + cursor: 'pointer', + hoverStyle: { + borderColor: '$borderColorFocus', + backgroundColor: '$backgroundHover', + }, + pressStyle: { + borderColor: '$borderColorFocus', + backgroundColor: '$backgroundPress', + }, + variants: { + selected: { + true: { + borderColor: '$borderColorFocus', + backgroundColor: '$backgroundHover', + }, + }, + disabled: { + true: { + opacity: 0.5, + cursor: 'not-allowed', + pointerEvents: 'none', + }, + }, + } as const, +}) + +const RadioBullet = styled(Stack, { + width: 24, + height: 24, + borderRadius: '$full', + borderWidth: 2, + borderColor: '$borderColor', + backgroundColor: '$background', + alignItems: 'center', + justifyContent: 'center', + variants: { + selected: { + true: { + borderColor: '$primary', + }, + }, + } as const, +}) + +const RadioDot = styled(Stack, { + width: 10, + height: 10, + borderRadius: '$full', + backgroundColor: '$primary', + variants: { + selected: { + false: { + backgroundColor: 'transparent', + }, + }, + } as const, +}) + +const HousePill = styled(Stack, { + borderRadius: '$full', + borderWidth: 1, + borderColor: '$borderColor', + backgroundColor: '$surface', + paddingHorizontal: '$3', + paddingVertical: '$1', + alignItems: 'center', + justifyContent: 'center', +}) + +interface HouseSelectionCardProps { + house: GovernanceHouse + isSelected: boolean + isDisabled: boolean + stakeAmountLabel: string + onPress: () => void +} + +export function HouseSelectionCard({ + house, + isSelected, + isDisabled, + stakeAmountLabel, + onPress, +}: HouseSelectionCardProps) { + const houseCopy = HOUSE_COPY[house] + + return ( + + + + + + + {houseCopy.title} + {houseCopy.summary} + + + + + + {houseCopy.label} + + + {`${stakeAmountLabel} stake`} + + {isSelected ? ( + + Selected + + ) : null} + + + + + Continue with this house + + + + + ) +} diff --git a/packages/governance-widget/src/onboarding/MembershipStakeBanner.tsx b/packages/governance-widget/src/onboarding/MembershipStakeBanner.tsx new file mode 100644 index 0000000..68970d7 --- /dev/null +++ b/packages/governance-widget/src/onboarding/MembershipStakeBanner.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import { Badge, BadgeText, Card, Icon, Text, XStack, YStack } from '@goodwidget/ui' + +interface MembershipStakeBannerProps { + stakeAmountLabel: string + warningMessage: string +} + +/** + * Staking banner shown above the profile form. Highlights the required stake + * amount and a wallet-funds warning so members can confirm they are funded + * before submitting the governance application. + */ +export function MembershipStakeBanner({ stakeAmountLabel, warningMessage }: MembershipStakeBannerProps) { + return ( + + + + + + + + + Membership Stake + + + {stakeAmountLabel} + + + + + + + + + Wallet funding required + + {warningMessage} + + + + + ) +} diff --git a/packages/governance-widget/src/onboarding/OnboardingIdentityCard.tsx b/packages/governance-widget/src/onboarding/OnboardingIdentityCard.tsx new file mode 100644 index 0000000..3d3e926 --- /dev/null +++ b/packages/governance-widget/src/onboarding/OnboardingIdentityCard.tsx @@ -0,0 +1,152 @@ +import React from 'react' +import { Stack, styled } from 'tamagui' +import { AddressDisplay, Button, ButtonText, Card, Icon, Text, XStack, YStack } from '@goodwidget/ui' +import type { GovernanceIdentityStatus } from '../types' + +interface OnboardingIdentityCardProps { + identityStatus: GovernanceIdentityStatus + walletAddress?: string + onVerifyPress?: () => void + onProceedPress?: () => void +} + +/** Left-border accent row used for the Identity Status field when verified. */ +const AccentRow = styled(XStack, { + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: '$3', + paddingHorizontal: '$3', + borderRadius: '$2', + backgroundColor: '$backgroundHover', + borderLeftWidth: 3, + variants: { + verified: { + true: { borderLeftColor: '$success' }, + false: { borderLeftColor: '$warning' }, + }, + } as const, +}) + +/** Plain field row without a left accent (e.g. Wallet Address). */ +const FieldRow = styled(XStack, { + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: '$3', + paddingHorizontal: '$3', + borderRadius: '$2', + backgroundColor: '$backgroundHover', +}) + +export function OnboardingIdentityCard({ + identityStatus, + walletAddress, + onVerifyPress, + onProceedPress, +}: OnboardingIdentityCardProps) { + const isVerified = identityStatus === 'verified' + + return ( + + + {/* ── Centered icon ─────────────────────────────────────── */} + + + + + + + {/* ── Field rows ────────────────────────────────────────── */} + + {/* Wallet Address row */} + + + Wallet Address + + {walletAddress ? ( + + ) : ( + + Not connected + + )} + + + {/* Identity Status row — green left accent when verified */} + + + + Identity Status + + + {isVerified ? 'Verified' : 'Verification required'} + + + {isVerified ? ( + + + + ) : ( + + + + )} + + + + {/* ── CTA buttons ───────────────────────────────────────── */} + + {!isVerified && ( + + )} + + + + + ) +} diff --git a/packages/governance-widget/src/onboarding/OnboardingNotice.tsx b/packages/governance-widget/src/onboarding/OnboardingNotice.tsx new file mode 100644 index 0000000..1dc5275 --- /dev/null +++ b/packages/governance-widget/src/onboarding/OnboardingNotice.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { Badge, BadgeText, Card, Heading, Icon, Text, XStack, YStack } from '@goodwidget/ui' + +export type OnboardingNoticeBadgeType = 'info' | 'warning' | 'success' +export type OnboardingNoticeIconName = 'info' | 'check' | 'alert-triangle' + +interface OnboardingNoticeProps { + badgeLabel: string + badgeType: OnboardingNoticeBadgeType + title: string + description: string + iconName: OnboardingNoticeIconName +} + +export function OnboardingNotice({ + badgeLabel, + badgeType, + title, + description, + iconName, +}: OnboardingNoticeProps) { + return ( + + + + + + + + {badgeLabel} + + + {title} + {description} + + + + + ) +} diff --git a/packages/governance-widget/src/onboarding/ProfileField.tsx b/packages/governance-widget/src/onboarding/ProfileField.tsx new file mode 100644 index 0000000..8131b2c --- /dev/null +++ b/packages/governance-widget/src/onboarding/ProfileField.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import { InputError, InputFrame, InputLabel, Text, YStack } from '@goodwidget/ui' + +interface ProfileFieldProps { + label: string + placeholder: string + value?: string + helperText?: string + errorMessage?: string + onChangeText: (nextValue: string) => void +} + +export function ProfileField({ + label, + placeholder, + value, + helperText, + errorMessage, + onChangeText, +}: ProfileFieldProps) { + return ( + + {label} + ) => { + onChangeText(event.currentTarget.value) + }} + /> + {errorMessage ? {errorMessage} : null} + {helperText ? {helperText} : null} + + ) +} diff --git a/packages/governance-widget/src/onboarding/ProfileTextAreaField.tsx b/packages/governance-widget/src/onboarding/ProfileTextAreaField.tsx new file mode 100644 index 0000000..5a7a1f4 --- /dev/null +++ b/packages/governance-widget/src/onboarding/ProfileTextAreaField.tsx @@ -0,0 +1,58 @@ +import React from 'react' +import { TextArea } from 'tamagui' +import { InputError, InputLabel, Text, YStack } from '@goodwidget/ui' + +interface ProfileTextAreaFieldProps { + label: string + placeholder: string + value?: string + helperText?: string + errorMessage?: string + onChangeText: (nextValue: string) => void +} + +export function ProfileTextAreaField({ + label, + placeholder, + value, + helperText, + errorMessage, + onChangeText, +}: ProfileTextAreaFieldProps) { + return ( + + {label} +