diff --git a/examples/storybook/package.json b/examples/storybook/package.json index 8188cc93..7fbfd22a 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/connect-a-wallet-widget": "workspace:*", "@goodwidget/goodreserve-widget": "workspace:*", "@goodwidget/governance-widget": "workspace:*", "@goodwidget/streaming-widget": "workspace:*", diff --git a/examples/storybook/src/stories/connect-a-wallet-widget/ConnectAWalletWidget.mdx b/examples/storybook/src/stories/connect-a-wallet-widget/ConnectAWalletWidget.mdx new file mode 100644 index 00000000..df57b478 --- /dev/null +++ b/examples/storybook/src/stories/connect-a-wallet-widget/ConnectAWalletWidget.mdx @@ -0,0 +1,78 @@ +import { Canvas, Meta } from '@storybook/blocks'; +import * as ShowcaseStories from './ConnectAWalletWidgetShowcase.stories'; +import { DocsCallout, DocsCard, DocsGrid, DocsPage, DocsSection } from '../docs/DocsLayout'; + + + + + + + + Gates on `GoodWidgetProvider`'s wallet connection before anything else is shown. + + + Validates the typed address, then loads its wallet-link status on Fuse, Celo, and XDC via + `IdentitySDK.checkConnectedStatus`. + + + Each chain row always shows either Connect or Disconnect — never hidden — with a loading + indicator while the transaction is in flight. `IdentitySDK.connectAccount` / + `disconnectAccount` drive the on-chain call, switching the host wallet to that chain first. + + + + + + + + + + + Wallet gate, connecting, no-input, address-checking, mixed per-chain row statuses, + unsupported-network warning, and top-level error/retry states live in + `QA / ConnectAWalletWidget / Runtime Fixtures`. + + + + + + + Manual review in a browser with an injected wallet, exercising the real + `useConnectAWalletAdapter` hook end to end. + + + Controlled adapter states for screenshots, automation, and debugging without live runtime + dependencies. + + + + + + + Use the showcase story for product-facing wallet-link checks. Use the QA fixtures when you + need repeatable screenshots, per-chain row-status coverage, or debugging. + + + diff --git a/examples/storybook/src/stories/connect-a-wallet-widget/ConnectAWalletWidgetQA.stories.tsx b/examples/storybook/src/stories/connect-a-wallet-widget/ConnectAWalletWidgetQA.stories.tsx new file mode 100644 index 00000000..01469801 --- /dev/null +++ b/examples/storybook/src/stories/connect-a-wallet-widget/ConnectAWalletWidgetQA.stories.tsx @@ -0,0 +1,49 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { ConnectAWalletWidget } from '@goodwidget/connect-a-wallet-widget' +import { + CheckingAddressStory, + ConnectedNoInputStory, + ConnectingStory, + NotConnectedStory, + ReadyMixedRowStatusesStory, + TopLevelErrorWithRetryStory, + UnsupportedNetworkStory, +} from '../helpers/connectAWalletWidgetStories' + +const meta: Meta = { + title: 'QA/ConnectAWalletWidget/Runtime Fixtures', + component: ConnectAWalletWidget, + tags: ['autodocs', 'qa'], + parameters: { layout: 'padded' }, +} + +export default meta +type Story = StoryObj + +export const NotConnected: Story = { + render: () => , +} + +export const Connecting: Story = { + render: () => , +} + +export const ConnectedNoInput: Story = { + render: () => , +} + +export const CheckingAddress: Story = { + render: () => , +} + +export const ReadyMixedRowStatuses: Story = { + render: () => , +} + +export const UnsupportedNetwork: Story = { + render: () => , +} + +export const TopLevelErrorWithRetry: Story = { + render: () => , +} diff --git a/examples/storybook/src/stories/connect-a-wallet-widget/ConnectAWalletWidgetShowcase.stories.tsx b/examples/storybook/src/stories/connect-a-wallet-widget/ConnectAWalletWidgetShowcase.stories.tsx new file mode 100644 index 00000000..cc80b0b4 --- /dev/null +++ b/examples/storybook/src/stories/connect-a-wallet-widget/ConnectAWalletWidgetShowcase.stories.tsx @@ -0,0 +1,17 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { ConnectAWalletWidget } from '@goodwidget/connect-a-wallet-widget' +import { InjectedWalletStory } from '../helpers/connectAWalletWidgetStories' + +const meta: Meta = { + title: 'Widgets/ConnectAWalletWidget/Showcase', + component: ConnectAWalletWidget, + tags: ['integrator', 'manual', 'showcase'], + parameters: { layout: 'padded' }, +} + +export default meta +type Story = StoryObj + +export const InjectedWallet: Story = { + render: () => , +} diff --git a/examples/storybook/src/stories/helpers/connectAWalletWidgetStories.tsx b/examples/storybook/src/stories/helpers/connectAWalletWidgetStories.tsx new file mode 100644 index 00000000..4901d978 --- /dev/null +++ b/examples/storybook/src/stories/helpers/connectAWalletWidgetStories.tsx @@ -0,0 +1,265 @@ +import React from 'react' +import { + CONNECT_A_WALLET_CHAINS, + ConnectAWalletWidget, + ConnectAWalletWidgetPreview, + type ConnectAWalletChainId, + type ConnectAWalletChainLinkState, + type ConnectAWalletWidgetAdapterActions, + type ConnectAWalletWidgetAdapterResult, + type ConnectAWalletWidgetAdapterState, +} from '@goodwidget/connect-a-wallet-widget' +import { MiniAppShell, YStack } from '@goodwidget/ui' +import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193' +import { getInjectedEip1193Provider, isInjectedProviderUsable } from '../../fixtures/injectedEip1193' + +// Fixed demo addresses so QA screenshots and Playwright text assertions never +// depend on a live wallet or network response. +const DEMO_HOST_WALLET_ADDRESS = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' +const DEMO_SECONDARY_ADDRESS = '0x1111111111111111111111111111111111111111' + +const CHAIN_NAMES: Record = { + 122: 'Fuse', + 42220: 'Celo', + 50: 'XDC', +} + +// One row per supported chain, all 'connected' by default — overridden per +// story to exercise the mixed-status / loading / disconnecting cases. +const readyChainLinks: ConnectAWalletChainLinkState[] = CONNECT_A_WALLET_CHAINS.map((chainId) => ({ + chainId, + chainName: CHAIN_NAMES[chainId], + status: 'connected', +})) + +const checkingChainLinks: ConnectAWalletChainLinkState[] = CONNECT_A_WALLET_CHAINS.map((chainId) => ({ + chainId, + chainName: CHAIN_NAMES[chainId], + status: 'checking', +})) + +/** + * Adapter fixture factory. Defaults to a fully connected, ready state so each + * story only needs to override the handful of fields relevant to the state + * it demonstrates — mirrors streaming-widget's createAdapter pattern. + */ +function createAdapter( + stateOverrides: Partial = {}, + actionOverrides: Partial = {}, +): ConnectAWalletWidgetAdapterResult { + const baseState: ConnectAWalletWidgetAdapterState = { + isWalletConnected: true, + walletAddress: DEMO_HOST_WALLET_ADDRESS, + activeChainId: 42220, + isActiveChainSupported: true, + status: 'ready', + error: null, + secondaryAddressInput: DEMO_SECONDARY_ADDRESS, + secondaryAddress: DEMO_SECONDARY_ADDRESS, + chainLinks: readyChainLinks, + } + + const actions: ConnectAWalletWidgetAdapterActions = { + connectWallet: async () => {}, + setSecondaryAddressInput: () => {}, + checkSecondaryAddress: async () => {}, + connectChain: async () => {}, + disconnectChain: async () => {}, + ...actionOverrides, + } + + return { + state: { ...baseState, ...stateOverrides }, + actions, + } +} + +function StoryShell({ children, dataTestId }: { children: React.ReactNode; dataTestId: string }) { + return ( + + + {children} + + + ) +} + +function PreviewStoryShell({ + adapter, + dataTestId, +}: { + adapter: ConnectAWalletWidgetAdapterResult + dataTestId: string +}) { + return ( + + + + ) +} + +function ConnectAWalletWidgetStoryShell({ provider, dataTestId }: { provider: unknown; dataTestId: string }) { + return ( + + + + ) +} + +export function InjectedWalletStory() { + const injectedProvider = getInjectedEip1193Provider() + const usableProvider = isInjectedProviderUsable(injectedProvider) + + if (!usableProvider) { + return ( + + ) + } + + return ( + + ) +} + +export function CustodialLocalFixtureStory() { + 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'} + + + ) + } +} + +export function NotConnectedStory() { + return ( + + ) +} + +export function ConnectingStory() { + return ( + + ) +} + +export function ConnectedNoInputStory() { + return ( + + ) +} + +export function CheckingAddressStory() { + return ( + + ) +} + +// Proves the "always show Connect or Disconnect, loading indicator while +// pending, never hidden" requirement: one row of each possible per-chain +// status rendered simultaneously. +export function ReadyMixedRowStatusesStory() { + return ( + + ) +} + +export function UnsupportedNetworkStory() { + return ( + + ) +} + +export function TopLevelErrorWithRetryStory() { + return ( + + ) +} diff --git a/packages/connect-a-wallet-widget/package.json b/packages/connect-a-wallet-widget/package.json new file mode 100644 index 00000000..529628b8 --- /dev/null +++ b/packages/connect-a-wallet-widget/package.json @@ -0,0 +1,51 @@ +{ + "name": "@goodwidget/connect-a-wallet-widget", + "version": "0.1.0-beta", + "description": "GoodWidget for linking/unlinking secondary wallets to a GoodDollar identity across Celo, Fuse, and XDC", + "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" + }, + "./element": { + "types": "./dist/element.d.ts", + "import": "./dist/element.js", + "require": "./dist/element.cjs" + }, + "./register": { + "types": "./dist/register.d.ts", + "import": "./dist/register.js", + "require": "./dist/register.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": { + "@goodsdks/citizen-sdk": "1.2.7", + "@goodwidget/core": "workspace:*", + "@goodwidget/embed": "workspace:*", + "@goodwidget/ui": "workspace:*", + "viem": "^2.0.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/connect-a-wallet-widget/src/ConnectAWalletWidget.tsx b/packages/connect-a-wallet-widget/src/ConnectAWalletWidget.tsx new file mode 100644 index 00000000..3d3ddd2c --- /dev/null +++ b/packages/connect-a-wallet-widget/src/ConnectAWalletWidget.tsx @@ -0,0 +1,89 @@ +import React from 'react' +import { GoodWidgetProvider } from '@goodwidget/core' +import type { EIP1193Provider } from '@goodwidget/core' +import { GoodWidgetDialog, ToastContainer } from '@goodwidget/ui' +import { useConnectAWalletAdapter } from './adapter' +import { ConnectAWalletWidgetView } from './components/ConnectAWalletWidgetView' +import type { + ConnectAWalletWidgetAdapterResult, + ConnectAWalletWidgetProps, +} from './widgetRuntimeContract' + +/** + * Thin entry point: wires the runtime adapter to the view. All visual + * composition lives under components/, mirroring streaming-widget's + * decomposition — a standing rule for GoodWidget packages going forward. + */ +interface ConnectAWalletWidgetRuntimeProps { + environment: ConnectAWalletWidgetProps['environment'] + onLinkSuccess: ConnectAWalletWidgetProps['onLinkSuccess'] + onLinkError: ConnectAWalletWidgetProps['onLinkError'] + onUnlinkSuccess: ConnectAWalletWidgetProps['onUnlinkSuccess'] + 'data-testid'?: string +} + +function ConnectAWalletWidgetRuntime({ + environment, + onLinkSuccess, + onLinkError, + onUnlinkSuccess, + 'data-testid': dataTestId, +}: ConnectAWalletWidgetRuntimeProps) { + const adapter = useConnectAWalletAdapter({ environment, onLinkSuccess, onLinkError, onUnlinkSuccess }) + + return +} + +export interface ConnectAWalletWidgetPreviewProps + extends Pick { + adapter: ConnectAWalletWidgetAdapterResult + 'data-testid'?: string +} + +// Deterministic render path for Storybook/test fixtures that provide a fixed adapter. +export function ConnectAWalletWidgetPreview({ + adapter, + themeOverrides, + config, + defaultTheme = 'dark', + 'data-testid': dataTestId, +}: ConnectAWalletWidgetPreviewProps) { + return ( + + + + + + ) +} + +export function ConnectAWalletWidget({ + provider, + environment = 'production', + onLinkSuccess, + onLinkError, + onUnlinkSuccess, + themeOverrides, + config, + defaultTheme = 'dark', + 'data-testid': dataTestId, +}: ConnectAWalletWidgetProps) { + return ( + + + + + + ) +} diff --git a/packages/connect-a-wallet-widget/src/adapter.ts b/packages/connect-a-wallet-widget/src/adapter.ts new file mode 100644 index 00000000..2253574d --- /dev/null +++ b/packages/connect-a-wallet-widget/src/adapter.ts @@ -0,0 +1,412 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useWallet } from '@goodwidget/core' +import { createPublicClient, createWalletClient, custom, isAddress, type Chain, type PublicClient } from 'viem' +import { IdentitySDK, SupportedChains } from '@goodsdks/citizen-sdk' +import { createDialog, closeDialog, createToast, updateToast } from '@goodwidget/ui' +import { + CONNECT_A_WALLET_CHAINS, + type ConnectAWalletChainId, + type ConnectAWalletChainLinkState, + type ConnectAWalletLinkErrorDetail, + type ConnectAWalletLinkEventDetail, + type ConnectAWalletWidgetAdapterActions, + type ConnectAWalletWidgetAdapterResult, + type ConnectAWalletWidgetAdapterState, + type ConnectAWalletWidgetEnvironment, + type ConnectAWalletWidgetStatus, +} from './widgetRuntimeContract' + +// --------------------------------------------------------------------------- +// Minimal viem chain descriptors — same RPC endpoints as citizen-claim-widget, +// kept in sync since both packages target the same citizen-sdk deployment. +// --------------------------------------------------------------------------- +const CHAIN_CONFIGS: Record = { + [SupportedChains.FUSE]: { + id: SupportedChains.FUSE, + name: 'Fuse', + nativeCurrency: { name: 'Fuse', symbol: 'FUSE', decimals: 18 }, + rpcUrls: { default: { http: ['https://rpc.fuse.io'] } }, + } as Chain, + [SupportedChains.CELO]: { + id: SupportedChains.CELO, + name: 'Celo', + nativeCurrency: { name: 'Celo', symbol: 'CELO', decimals: 18 }, + rpcUrls: { default: { http: ['https://forno.celo.org'] } }, + } as Chain, + [SupportedChains.XDC]: { + id: SupportedChains.XDC, + name: 'XDC Network', + nativeCurrency: { name: 'XDC', symbol: 'XDC', decimals: 18 }, + rpcUrls: { default: { http: ['https://rpc.ankr.com/xdc'] } }, + } as Chain, +} + +const CHAIN_NAMES: Record = { + [SupportedChains.FUSE]: 'Fuse', + [SupportedChains.CELO]: 'Celo', + [SupportedChains.XDC]: 'XDC', +} + +const DEFAULT_CHAIN: ConnectAWalletChainId = SupportedChains.CELO + +function humanReadableError(err: unknown): string { + console.error('[ConnectAWalletWidget]', err) + if (!(err instanceof Error)) return 'Something went wrong. Please try again.' + const msg = err.message + if (msg.includes('User rejected') || msg.includes('user rejected') || msg.includes('4001')) { + return 'Rejected by wallet.' + } + if (msg.includes('Failed to fetch') || msg.includes('NetworkError') || msg.includes('ECONNREFUSED')) { + return 'Unable to reach the network. Check your connection and try again.' + } + return 'Something went wrong. Please try again.' +} + +/** + * Opens a blocking GoodWidgetDialog and resolves once the user accepts or + * rejects it. Mirrors citizen-sdk's WalletLinkOptions.onSecurityMessage + * contract: resolve(true) to proceed with the transaction, resolve(false) to + * cancel it. Confirmed by Bounty Lead sign-off: this must block before the + * transaction executes, using packages/ui's Dialog. + */ +function confirmSecurityMessageViaDialog(message: string): Promise { + return new Promise((resolve) => { + createDialog({ + title: 'Security Confirmation', + body: message, + acceptLabel: 'Confirm', + rejectLabel: 'Cancel', + onAccept: () => { + resolve(true) + }, + onReject: () => { + resolve(false) + }, + }) + }) +} + +export interface UseConnectAWalletAdapterOptions { + environment?: ConnectAWalletWidgetEnvironment + onLinkSuccess?: (detail: ConnectAWalletLinkEventDetail) => void + onLinkError?: (detail: ConnectAWalletLinkErrorDetail) => void + onUnlinkSuccess?: (detail: ConnectAWalletLinkEventDetail) => void +} + +/** + * Core adapter hook: bridges @goodsdks/citizen-sdk's wallet-link API to + * GoodWidget state/actions. + * + * Runtime path: + * host provider → GoodWidgetProvider → useWallet() → this adapter → citizen-sdk + * + * Calls IdentitySDK.connectAccount/disconnectAccount/checkConnectedStatus + * directly — no @goodsdks/react-hooks, since those hooks require a Wagmi v2 + * provider tree and GoodWidget's provider-first architecture only exposes a + * raw EIP-1193 provider via useWallet(). Confirmed against every other widget + * in this repo: none of them pull in react-hooks/Wagmi either. + */ +export function useConnectAWalletAdapter( + options: UseConnectAWalletAdapterOptions = {}, +): ConnectAWalletWidgetAdapterResult { + const { onLinkSuccess, onLinkError, onUnlinkSuccess } = options + const env = options.environment ?? 'production' + const { address, chainId, isConnected, provider, connect } = useWallet() + + const isActiveChainSupported = + chainId !== null && (CONNECT_A_WALLET_CHAINS as readonly number[]).includes(chainId) + + const [isConnectingWallet, setIsConnectingWallet] = useState(false) + const [secondaryAddressInput, setSecondaryAddressInputState] = useState('') + const [secondaryAddress, setSecondaryAddress] = useState<`0x${string}` | null>(null) + const [status, setStatus] = useState('not_connected') + const [error, setError] = useState(null) + const [chainLinks, setChainLinks] = useState( + CONNECT_A_WALLET_CHAINS.map((id) => ({ + chainId: id, + chainName: CHAIN_NAMES[id], + status: 'checking', + })), + ) + + const mountedRef = useRef(true) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + // --------------------------------------------------------------------------- + // Client + SDK factory for one chain. The wallet client's account/chain are + // only used when actually signing (connect/disconnect); reads pass an + // explicit publicClients map so they never depend on the host wallet's + // currently active chain. + // --------------------------------------------------------------------------- + const createIdentitySdkForChain = useCallback( + (targetChainId: ConnectAWalletChainId) => { + if (!provider || !address) return null + const chain = CHAIN_CONFIGS[targetChainId] + const transport = custom(provider as Parameters[0]) + const publicClient = createPublicClient({ chain, transport }) + const walletClient = createWalletClient({ + account: address as `0x${string}`, + chain, + transport, + }) + return new IdentitySDK({ publicClient, walletClient, env }) + }, + [provider, address, env], + ) + + const publicClientsByChain = useMemo( + () => + CONNECT_A_WALLET_CHAINS.reduce( + (acc, id) => { + const chain = CHAIN_CONFIGS[id] + acc[id] = createPublicClient({ chain, transport: custom(provider as Parameters[0]) }) + return acc + }, + {} as Record, + ), + [provider], + ) + + // --------------------------------------------------------------------------- + // checkSecondaryAddress — validates the typed-in address, then loads its + // wallet-link status on all 3 supported chains in a single SDK call. + // --------------------------------------------------------------------------- + const checkSecondaryAddress = useCallback(async (): Promise => { + const trimmed = secondaryAddressInput.trim() + if (!isAddress(trimmed)) { + setStatus('connected_no_input') + setError('Enter a valid wallet address.') + return + } + + setSecondaryAddress(trimmed) + setStatus('checking_address') + setError(null) + setChainLinks(CONNECT_A_WALLET_CHAINS.map((id) => ({ chainId: id, chainName: CHAIN_NAMES[id], status: 'checking' }))) + + const sdk = createIdentitySdkForChain(isActiveChainSupported ? (chainId as ConnectAWalletChainId) : DEFAULT_CHAIN) + if (!sdk) { + setStatus('error') + setError('Wallet not connected.') + return + } + + try { + const statuses = await sdk.checkConnectedStatus(trimmed, undefined, publicClientsByChain) + if (!mountedRef.current) return + setChainLinks( + CONNECT_A_WALLET_CHAINS.map((id) => { + const found = statuses.find((entry: { chainId: number }) => entry.chainId === id) + return { + chainId: id, + chainName: CHAIN_NAMES[id], + status: found?.isConnected ? 'connected' : 'not_connected', + } + }), + ) + setStatus('ready') + } catch (err: unknown) { + if (!mountedRef.current) return + setStatus('error') + setError(humanReadableError(err)) + } + }, [secondaryAddressInput, createIdentitySdkForChain, isActiveChainSupported, chainId, publicClientsByChain]) + + const setSecondaryAddressInput = useCallback((value: string) => { + setSecondaryAddressInputState(value) + }, []) + + const handleConnectWallet = useCallback(async (): Promise => { + setIsConnectingWallet(true) + try { + await connect() + } finally { + if (mountedRef.current) setIsConnectingWallet(false) + } + }, [connect]) + + // --------------------------------------------------------------------------- + // connectChain / disconnectChain — link or unlink `secondaryAddress` on one + // chain. Switches the host wallet to that chain first (EIP-3326), same + // pattern as citizen-claim-widget's per-chain claim action. onSecurityMessage + // blocks on a Dialog; tx result surfaces via Toast, never an inline error. + // --------------------------------------------------------------------------- + const setRowStatus = useCallback((targetChainId: ConnectAWalletChainId, rowStatus: ConnectAWalletChainLinkState['status']) => { + setChainLinks((prev) => + prev.map((row) => (row.chainId === targetChainId ? { ...row, status: rowStatus } : row)), + ) + }, []) + + const switchWalletToChain = useCallback( + async (targetChainId: ConnectAWalletChainId) => { + if (!provider) throw new Error('No wallet provider available') + await ( + provider as { request: (args: { method: string; params: unknown[] }) => Promise } + ).request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: `0x${targetChainId.toString(16)}` }], + }) + }, + [provider], + ) + + const connectChain = useCallback( + async (targetChainId: ConnectAWalletChainId): Promise => { + if (!secondaryAddress) return + const previousStatus = chainLinks.find((row) => row.chainId === targetChainId)?.status ?? 'not_connected' + setRowStatus(targetChainId, 'connecting') + + const toastId = createToast({ + message: `Linking on ${CHAIN_NAMES[targetChainId]}…`, + status: 'pending', + duration: 0, + }) + + try { + await switchWalletToChain(targetChainId) + const sdk = createIdentitySdkForChain(targetChainId) + if (!sdk) throw new Error('Unable to initialize SDK clients for target chain') + + let transactionHash: string | undefined + await sdk.connectAccount(secondaryAddress, { + onSecurityMessage: confirmSecurityMessageViaDialog, + onHash: (hash: `0x${string}`) => { + transactionHash = hash + }, + }) + + if (!mountedRef.current) return + closeDialog() + setRowStatus(targetChainId, 'connected') + updateToast(toastId, { + message: `Linked on ${CHAIN_NAMES[targetChainId]}`, + status: 'success', + duration: 3200, + }) + onLinkSuccess?.({ address: secondaryAddress, chainId: targetChainId, transactionHash }) + } catch (err: unknown) { + if (!mountedRef.current) return + closeDialog() + setRowStatus(targetChainId, previousStatus) + updateToast(toastId, { + message: `Failed to link on ${CHAIN_NAMES[targetChainId]}: ${humanReadableError(err)}`, + status: 'error', + duration: 0, + }) + onLinkError?.({ + address: secondaryAddress, + chainId: targetChainId, + message: humanReadableError(err), + }) + } + }, + [secondaryAddress, chainLinks, setRowStatus, switchWalletToChain, createIdentitySdkForChain, onLinkSuccess, onLinkError], + ) + + const disconnectChain = useCallback( + async (targetChainId: ConnectAWalletChainId): Promise => { + if (!secondaryAddress) return + const previousStatus = chainLinks.find((row) => row.chainId === targetChainId)?.status ?? 'connected' + setRowStatus(targetChainId, 'disconnecting') + + const toastId = createToast({ + message: `Unlinking on ${CHAIN_NAMES[targetChainId]}…`, + status: 'pending', + duration: 0, + }) + + try { + await switchWalletToChain(targetChainId) + const sdk = createIdentitySdkForChain(targetChainId) + if (!sdk) throw new Error('Unable to initialize SDK clients for target chain') + + let transactionHash: string | undefined + await sdk.disconnectAccount(secondaryAddress, { + onSecurityMessage: confirmSecurityMessageViaDialog, + onHash: (hash: `0x${string}`) => { + transactionHash = hash + }, + }) + + if (!mountedRef.current) return + closeDialog() + setRowStatus(targetChainId, 'not_connected') + updateToast(toastId, { + message: `Unlinked on ${CHAIN_NAMES[targetChainId]}`, + status: 'success', + duration: 3200, + }) + onUnlinkSuccess?.({ address: secondaryAddress, chainId: targetChainId, transactionHash }) + } catch (err: unknown) { + if (!mountedRef.current) return + closeDialog() + setRowStatus(targetChainId, previousStatus) + updateToast(toastId, { + message: `Failed to unlink on ${CHAIN_NAMES[targetChainId]}: ${humanReadableError(err)}`, + status: 'error', + duration: 0, + }) + onLinkError?.({ + address: secondaryAddress, + chainId: targetChainId, + message: humanReadableError(err), + }) + } + }, + [secondaryAddress, chainLinks, setRowStatus, switchWalletToChain, createIdentitySdkForChain, onUnlinkSuccess, onLinkError], + ) + + // Reset to not_connected / connected_no_input whenever wallet identity changes. + useEffect(() => { + if (!isConnected) { + setStatus('not_connected') + setSecondaryAddress(null) + return + } + setStatus('connected_no_input') + }, [isConnected, address]) + + const state: ConnectAWalletWidgetAdapterState = useMemo( + () => ({ + isWalletConnected: isConnected, + walletAddress: address ?? null, + activeChainId: chainId ?? null, + isActiveChainSupported, + status: isConnectingWallet ? 'connecting' : status, + error, + secondaryAddressInput, + secondaryAddress, + chainLinks, + }), + [ + isConnected, + address, + chainId, + isActiveChainSupported, + isConnectingWallet, + status, + error, + secondaryAddressInput, + secondaryAddress, + chainLinks, + ], + ) + + const actions: ConnectAWalletWidgetAdapterActions = useMemo( + () => ({ + connectWallet: handleConnectWallet, + setSecondaryAddressInput, + checkSecondaryAddress, + connectChain, + disconnectChain, + }), + [handleConnectWallet, setSecondaryAddressInput, checkSecondaryAddress, connectChain, disconnectChain], + ) + + return { state, actions } +} diff --git a/packages/connect-a-wallet-widget/src/components/AddressLinkForm.tsx b/packages/connect-a-wallet-widget/src/components/AddressLinkForm.tsx new file mode 100644 index 00000000..6e7ef366 --- /dev/null +++ b/packages/connect-a-wallet-widget/src/components/AddressLinkForm.tsx @@ -0,0 +1,44 @@ +import React from 'react' +import { ButtonText, Heading, Input, Spinner, Text, XStack, YStack } from '@goodwidget/ui' +import { ActionButton, AddressFormCard } from './shared' + +interface AddressLinkFormProps { + addressInput: string + isChecking: boolean + onChangeAddressInput: (value: string) => void + onCheckAddress: () => void +} + +export function AddressLinkForm({ + addressInput, + isChecking, + onChangeAddressInput, + onCheckAddress, +}: AddressLinkFormProps) { + return ( + + + Wallet address to link + + + Enter the address you want to connect to or disconnect from your GoodID, then check its + status on each supported chain. + + {/* Input and action button share a single row so the pair reads as one + validate bar, matching the design reference's compact address field. */} + + + + + + {isChecking ? : Check address} + + + + ) +} diff --git a/packages/connect-a-wallet-widget/src/components/ChainLinkRow.tsx b/packages/connect-a-wallet-widget/src/components/ChainLinkRow.tsx new file mode 100644 index 00000000..b1a63452 --- /dev/null +++ b/packages/connect-a-wallet-widget/src/components/ChainLinkRow.tsx @@ -0,0 +1,72 @@ +import React from 'react' +import { + AddressDisplay, + Badge, + BadgeText, + ButtonText, + ChainBadge, + Spinner, + Text, + XStack, + createComponent, +} from '@goodwidget/ui' +import type { ConnectAWalletChainLinkState } from '../widgetRuntimeContract' +import { chainLinkRowPresentation } from './format' +import { ActionButton, ChainRowCard } from './shared' + +interface ChainLinkRowProps { + row: ConnectAWalletChainLinkState + address: `0x${string}` + onConnect: () => void + onDisconnect: () => void +} + +const ChainAvatar = createComponent(XStack, { + name: 'ChainAvatar', + width: 28, + height: 28, + borderRadius: '$full', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '$infoMuted', +}) + +/** + * One row per supported chain. Always renders exactly one of Connect / + * Disconnect (never hidden) with a Spinner while a status is in flight, per + * Bounty Lead sign-off on the human-reviewer checklist. + */ +export function ChainLinkRow({ row, address, onConnect, onDisconnect }: ChainLinkRowProps) { + const { actionLabel, isBusy, isDisabled } = chainLinkRowPresentation(row.status) + const handlePress = actionLabel === 'Connect' ? onConnect : onDisconnect + const isDisconnectAction = actionLabel === 'Disconnect' + + return ( + + + + + {row.chainName.charAt(0).toUpperCase()} + + + + + + + {row.status === 'checking' ? 'checking…' : row.status.replace('_', ' ')} + + + {isBusy ? ( + + ) : ( + {actionLabel} + )} + + + ) +} diff --git a/packages/connect-a-wallet-widget/src/components/ConnectAWalletWidgetView.tsx b/packages/connect-a-wallet-widget/src/components/ConnectAWalletWidgetView.tsx new file mode 100644 index 00000000..06f67652 --- /dev/null +++ b/packages/connect-a-wallet-widget/src/components/ConnectAWalletWidgetView.tsx @@ -0,0 +1,107 @@ +import React from 'react' +import { Alert, ButtonText, Heading, Spinner, WidgetTabs } from '@goodwidget/ui' +import type { ConnectAWalletWidgetAdapterResult } from '../widgetRuntimeContract' +import { AddressLinkForm } from './AddressLinkForm' +import { ChainLinkRow } from './ChainLinkRow' +import { PrimaryIdentityCard } from './PrimaryIdentityCard' +import { ActionButton, EmptyStateCard, MultiWalletNotice, SupportedNetworksFooter, WidgetContent } from './shared' +import { WalletGate } from './WalletGate' + +interface ConnectAWalletWidgetViewProps { + adapter: ConnectAWalletWidgetAdapterResult + 'data-testid'?: string +} + +export function ConnectAWalletWidgetView({ + adapter, + 'data-testid': dataTestId = 'connect-a-wallet-widget', +}: ConnectAWalletWidgetViewProps) { + const { state, actions } = adapter + + // Reuse the shared WidgetTabs component (already used by every other + // sibling widget) instead of hand-rolling the header — this also resolves + // the active chain to its friendly display name for free, matching the + // #113 design reference's "Connect identity" tab header. + const header = ( + {}} + chainId={state.activeChainId ?? undefined} + /> + ) + + if (!state.isWalletConnected) { + return ( + + {header} + + + ) + } + + return ( + + {header} + + + + + + {!state.isActiveChainSupported && ( + + )} + + {state.status === 'error' && ( + + + Couldn't load link status + + + + Retry + + + )} + + {state.status !== 'error' && ( + + )} + + {state.status === 'checking_address' && ( + + + + )} + + {state.status === 'ready' && state.secondaryAddress && ( + <> + {state.chainLinks.map((row) => ( + actions.connectChain(row.chainId)} + onDisconnect={() => actions.disconnectChain(row.chainId)} + /> + ))} + + )} + + + + ) +} diff --git a/packages/connect-a-wallet-widget/src/components/ConnectPrompt.tsx b/packages/connect-a-wallet-widget/src/components/ConnectPrompt.tsx new file mode 100644 index 00000000..099ffa8f --- /dev/null +++ b/packages/connect-a-wallet-widget/src/components/ConnectPrompt.tsx @@ -0,0 +1,24 @@ +import React from 'react' +import { ButtonText, Heading, Spinner, Text } from '@goodwidget/ui' +import { ActionButton, EmptyStateCard } from './shared' + +interface ConnectPromptProps { + isConnecting: boolean + onConnect: () => void +} + +export function ConnectPrompt({ isConnecting, onConnect }: ConnectPromptProps) { + return ( + + + Wallet not connected + + + Connect your wallet to link additional addresses to your GoodID. + + + {isConnecting ? : Connect Wallet} + + + ) +} diff --git a/packages/connect-a-wallet-widget/src/components/PrimaryIdentityCard.tsx b/packages/connect-a-wallet-widget/src/components/PrimaryIdentityCard.tsx new file mode 100644 index 00000000..cc7f1a05 --- /dev/null +++ b/packages/connect-a-wallet-widget/src/components/PrimaryIdentityCard.tsx @@ -0,0 +1,57 @@ +import React from 'react' +import { AddressDisplay, Icon, Text, XStack, YStack, createComponent } from '@goodwidget/ui' + +const IdentityIconBadge = createComponent(YStack, { + name: 'PrimaryIdentityIconBadge', + width: 36, + height: 36, + borderRadius: '$full', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '$infoMuted', +}) + +const IdentityCard = createComponent(XStack, { + name: 'PrimaryIdentityCard', + alignItems: 'center', + gap: '$3', + padding: '$3', + borderRadius: '$3', + borderWidth: 1, + borderColor: '$borderColor', +}) + +interface PrimaryIdentityCardProps { + walletAddress: string | null +} + +/** + * Shows which address the per-chain rows below are linking *from* — the + * connected host wallet, i.e. the user's GoodID primary identity. Mirrors + * the "Primary Verified Identity" card in the #113 design reference. + */ +export function PrimaryIdentityCard({ walletAddress }: PrimaryIdentityCardProps) { + if (!walletAddress) { + return null + } + + return ( + + + + + + + Primary verified identity + + + + + ) +} diff --git a/packages/connect-a-wallet-widget/src/components/WalletGate.tsx b/packages/connect-a-wallet-widget/src/components/WalletGate.tsx new file mode 100644 index 00000000..1c9d2002 --- /dev/null +++ b/packages/connect-a-wallet-widget/src/components/WalletGate.tsx @@ -0,0 +1,23 @@ +import React from 'react' +import { ConnectPrompt } from './ConnectPrompt' + +interface WalletGateProps { + isWalletConnected: boolean + isConnecting: boolean + onConnect: () => void +} + +/** + * Gates the widget on host-wallet connection only. Unsupported-chain + * handling is intentionally NOT gated here — per Bounty Lead sign-off it is + * a non-blocking warning shown alongside the per-chain rows, since a + * connected wallet on an unsupported chain can still switch chains inline + * from each row's connect/disconnect action. + */ +export function WalletGate({ isWalletConnected, isConnecting, onConnect }: WalletGateProps) { + if (!isWalletConnected) { + return + } + + return null +} diff --git a/packages/connect-a-wallet-widget/src/components/format.ts b/packages/connect-a-wallet-widget/src/components/format.ts new file mode 100644 index 00000000..860ff4be --- /dev/null +++ b/packages/connect-a-wallet-widget/src/components/format.ts @@ -0,0 +1,27 @@ +import type { ConnectAWalletChainLinkStatus } from '../widgetRuntimeContract' + +/** + * Per-chain row button label and busy state. Extracted because every + * ChainLinkRow needs the same status → (label, busy, disabled) mapping, and + * getting it wrong would violate the "always show Connect or Disconnect, + * never hide it" requirement confirmed by the Bounty Lead. + */ +export function chainLinkRowPresentation(status: ConnectAWalletChainLinkStatus): { + actionLabel: 'Connect' | 'Disconnect' + isBusy: boolean + isDisabled: boolean +} { + switch (status) { + case 'connected': + return { actionLabel: 'Disconnect', isBusy: false, isDisabled: false } + case 'disconnecting': + return { actionLabel: 'Disconnect', isBusy: true, isDisabled: true } + case 'connecting': + return { actionLabel: 'Connect', isBusy: true, isDisabled: true } + case 'checking': + return { actionLabel: 'Connect', isBusy: true, isDisabled: true } + case 'not_connected': + default: + return { actionLabel: 'Connect', isBusy: false, isDisabled: false } + } +} diff --git a/packages/connect-a-wallet-widget/src/components/shared.tsx b/packages/connect-a-wallet-widget/src/components/shared.tsx new file mode 100644 index 00000000..eba68ee6 --- /dev/null +++ b/packages/connect-a-wallet-widget/src/components/shared.tsx @@ -0,0 +1,87 @@ +import React from 'react' +import { Button, type ButtonProps, Card, Icon, Text, XStack, YStack, createComponent } from '@goodwidget/ui' + +export const WidgetContent = createComponent(YStack, { + name: 'ConnectAWalletWidgetContent', + flex: 1, + gap: '$3', + paddingVertical: '$3', +}) + +export const EmptyStateCard = createComponent(Card, { + name: 'EmptyStateCard', + padding: '$6', + alignItems: 'center' as const, + justifyContent: 'center' as const, + gap: '$3', +}) + +export const ChainRowCard = createComponent(Card, { + name: 'ChainRowCard', + padding: '$3', + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'space-between' as const, + gap: '$2', +}) + +export const AddressFormCard = createComponent(Card, { + name: 'AddressFormCard', + padding: '$4', + gap: '$3', +}) + +export function ActionButton({ children, minWidth = 108, size = 'sm', ...props }: ButtonProps) { + return ( + + ) +} + +const MultiWalletNoticeFrame = createComponent(XStack, { + name: 'MultiWalletNoticeFrame', + alignItems: 'flex-start', + gap: '$2', + padding: '$3', + borderRadius: '$2', + borderWidth: 1, + backgroundColor: '$infoMuted', + borderColor: '$primary', +}) + +/** + * Multi-wallet / shared daily-claim notice from the #113 design reference. + * Built locally rather than reusing Alert because Alert only takes a plain + * message string and this copy requires a bold second sentence. + */ +export function MultiWalletNotice() { + return ( + + + + You can connect multiple wallet addresses.{' '} + + However, only one claim per day is available, shared between the connected accounts. + + + + ) +} + +const SupportedNetworksFooterFrame = createComponent(XStack, { + name: 'SupportedNetworksFooterFrame', + justifyContent: 'center', + paddingTop: '$2', +}) + +/** Static footer naming every chain the widget supports, per the design reference. */ +export function SupportedNetworksFooter() { + return ( + + + Supported Networks: Celo, XDC, Fuse + + + ) +} diff --git a/packages/connect-a-wallet-widget/src/element.ts b/packages/connect-a-wallet-widget/src/element.ts new file mode 100644 index 00000000..370148cb --- /dev/null +++ b/packages/connect-a-wallet-widget/src/element.ts @@ -0,0 +1,26 @@ +import { createMiniAppElement } from '@goodwidget/embed' +import { ConnectAWalletWidget } from './ConnectAWalletWidget' +import type React from 'react' + +/** + * A Custom Element class wrapping the ConnectAWalletWidget React component. + * + * Register it with any tag name: + * customElements.define('gw-connect-a-wallet', ConnectAWalletWidgetElement) + * + * Then use in HTML: + * + * + * Set the wallet provider and theme overrides via JS properties: + * const el = document.querySelector('gw-connect-a-wallet') + * el.provider = window.ethereum + * el.themeOverrides = { tokens: { color: { primary: '#00AFFE' } } } + */ +export const ConnectAWalletWidgetElement = createMiniAppElement( + ConnectAWalletWidget as React.ComponentType>, + { + shadow: true, + defaultTheme: 'dark', + events: ['link-success', 'link-error', 'unlink-success'], + }, +) diff --git a/packages/connect-a-wallet-widget/src/index.ts b/packages/connect-a-wallet-widget/src/index.ts new file mode 100644 index 00000000..7450cd09 --- /dev/null +++ b/packages/connect-a-wallet-widget/src/index.ts @@ -0,0 +1,27 @@ +// Integration metadata (links this widget to the citizen-sdk capability manifest) +export { connectAWalletIntegration } from './integration' +export type { ConnectAWalletIntegration } from './integration' + +// Adapter contract types +export type { + ConnectAWalletChainId, + ConnectAWalletChainLinkState, + ConnectAWalletChainLinkStatus, + ConnectAWalletLinkErrorDetail, + ConnectAWalletLinkEventDetail, + ConnectAWalletWidgetAdapterActions, + ConnectAWalletWidgetAdapterResult, + ConnectAWalletWidgetAdapterState, + ConnectAWalletWidgetEnvironment, + ConnectAWalletWidgetProps, + ConnectAWalletWidgetStatus, +} from './widgetRuntimeContract' +export { CONNECT_A_WALLET_CHAINS } from './widgetRuntimeContract' + +// Adapter hook +export { useConnectAWalletAdapter } from './adapter' +export type { UseConnectAWalletAdapterOptions } from './adapter' + +// Widget component +export { ConnectAWalletWidget, ConnectAWalletWidgetPreview } from './ConnectAWalletWidget' +export type { ConnectAWalletWidgetPreviewProps } from './ConnectAWalletWidget' diff --git a/packages/connect-a-wallet-widget/src/integration.ts b/packages/connect-a-wallet-widget/src/integration.ts new file mode 100644 index 00000000..35b73322 --- /dev/null +++ b/packages/connect-a-wallet-widget/src/integration.ts @@ -0,0 +1,18 @@ +export const connectAWalletIntegration = { + id: 'connect-a-wallet', + sdk: '@goodsdks/citizen-sdk', + capabilitySource: 'citizenSdkCapabilities', + uses: ['connectAccount', 'disconnectAccount', 'checkConnectedStatus'], + chains: [122, 42220, 50], + states: [ + 'not_connected', + 'connecting', + 'connected_no_input', + 'checking_address', + 'ready', + 'error', + ], + events: ['link-success', 'link-error', 'unlink-success'], +} as const + +export type ConnectAWalletIntegration = typeof connectAWalletIntegration diff --git a/packages/connect-a-wallet-widget/src/register.ts b/packages/connect-a-wallet-widget/src/register.ts new file mode 100644 index 00000000..73e3b665 --- /dev/null +++ b/packages/connect-a-wallet-widget/src/register.ts @@ -0,0 +1,27 @@ +import { ConnectAWalletWidgetElement } from './element' + +const DEFAULT_TAG_NAME = 'gw-connect-a-wallet' + +/** + * Register the custom element. + * + * Call once at the top of your app or in a