diff --git a/apps/superfluid-campaign-web/src/App.tsx b/apps/superfluid-campaign-web/src/App.tsx index 9cc3799..c6f0b0d 100644 --- a/apps/superfluid-campaign-web/src/App.tsx +++ b/apps/superfluid-campaign-web/src/App.tsx @@ -1,10 +1,12 @@ -import React, { useRef } from 'react' +import React, { useMemo, useRef } from 'react' import { SuperfluidCampaignWidget } from '@goodwidget/superfluid-campaign-widget' import type { EIP1193Provider } from '@goodwidget/core' import { + DEFAULT_APPKIT_NETWORKS, DefaultAppKitProvider, useAppKit, useAppKitAccount, + useAppKitNetwork, useAppKitProvider, } from '@goodwidget/embed/appkit-provider' import { TamaguiProvider } from '@tamagui/core' @@ -12,18 +14,88 @@ import { YStack, defaultConfig } from '@goodwidget/ui' const DESKTOP_WIDGET_MAX_WIDTH = 960 +/** + * Pressing the disconnect-menu item under AppKit opens AppKit's own Account + * view (see disconnectOverride below) rather than ending the session + * directly, so the button is labeled to match what it actually does. + */ +const APPKIT_DISCONNECT_LABEL = 'Network settings' + +const SWITCH_CHAIN_MANUAL_SELECTION_ERROR = 'Select the network in the wallet dialog, then try again.' + function AppKitSuperfluidCampaignWidget() { const { open } = useAppKit() - const { address } = useAppKitAccount() + const { address, status: accountStatus } = useAppKitAccount() const { walletProvider } = useAppKitProvider('eip155') + const { chainId, switchNetwork, approvedCaipNetworkIds } = useAppKitNetwork() const addressRef = useRef(address) addressRef.current = address + // AppKit reports `address: undefined` both while it's still restoring a prior + // session ('connecting'/'reconnecting'/not yet reported) and once it has + // definitively resolved to "no wallet". Only the latter is a real override — + // during the unresolved window this stays `undefined` so the core provider's + // own EIP-1193 fallback tracking (rather than a premature "disconnected" + // override) covers the gap until AppKit reports a final status. `chainId` + // below is gated on the same flag so it doesn't fall back to a stale + // previously-tracked chain once the account is known to be disconnected. + const isAccountResolved = accountStatus === 'connected' || accountStatus === 'disconnected' + + // AppKit's switchNetwork takes the network descriptor object, not a chain id, + // so this looks up the descriptor for whichever chain the widget wants to + // switch to. AppKit's own active-network state (chainId here) is always the + // source of truth for what's "current" — the widget never tracks it separately. + const appKitNetworksByChainId = useMemo( + () => new Map(DEFAULT_APPKIT_NETWORKS.map((network) => [Number(network.id), network])), + [], + ) + + // CAIP network ids are formatted like "eip155:122" — the widget's execute + // gating needs plain numeric chain ids. `undefined` (AppKit hasn't reported + // approved networks yet) is kept as `null`, meaning "no restriction known" + // rather than "nothing is available". + const availableChainIds = useMemo( + () => + approvedCaipNetworkIds + ? approvedCaipNetworkIds + .map((caipId) => Number(caipId.split(':')[1])) + .filter((id) => Number.isFinite(id)) + : null, + [approvedCaipNetworkIds], + ) + return ( { + const targetNetwork = appKitNetworksByChainId.get(targetChainId) + // Falling through to AppKit's own network-selection modal is the + // documented recovery path when a target chain has no direct + // programmatic descriptor here, or when switchNetwork itself fails + // (e.g. the connected wallet rejects the automatic switch request). + // The modal only lets the user attempt the switch themselves — it + // never confirms synchronously that one actually happened — so this + // throws rather than resolves, otherwise the caller (core's + // switchChain, and the claim widget's error handling built on top of + // it) would treat an opened modal as an already-finished switch. + if (!targetNetwork) { + await open({ view: 'Networks' }) + throw new Error(SWITCH_CHAIN_MANUAL_SELECTION_ERROR) + } + try { + await switchNetwork(targetNetwork) + } catch { + await open({ view: 'Networks' }) + throw new Error(SWITCH_CHAIN_MANUAL_SELECTION_ERROR) + } + }} + disconnectLabel={APPKIT_DISCONNECT_LABEL} + disconnectIcon="settings" connectOverride={async () => { await open({ view: 'Connect' }) if (!addressRef.current) throw new Error('wallet_connect_cancelled') @@ -35,7 +107,7 @@ function AppKitSuperfluidCampaignWidget() { 'flow-state-vote': 'https://ubi.gd/4fEqvrS', 'flow-state-funding': 'https://ubi.gd/4z2tH8y', 'gardens-donation': 'https://ubi.gd/4xhk4kv', - 'gardens-funding': 'https:/ubi.gd/3TABl9O', + 'gardens-funding': 'https://ubi.gd/3TABl9O', 'invite-users': 'https://ubi.gd/4xhYTyH', 'claim-ubi': 'https://ubi.gd/3RNtzJd', }} diff --git a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx index 25722ec..c2b5e3a 100644 --- a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx +++ b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react' -import { GoodWidgetProvider } from '@goodwidget/core' +import { GoodWidgetProvider, useWallet } from '@goodwidget/core' import type { EIP1193Provider } from '@goodwidget/core' import { createComponent, @@ -21,7 +21,7 @@ import { WidgetTabs, } from '@goodwidget/ui' import { SupportedChains } from '@goodsdks/citizen-sdk' -import { useCitizenClaimAdapter } from './adapter' +import { getChainDisplayName, useCitizenClaimAdapter } from './adapter' import type { CitizenClaimWidgetProps, CitizenClaimWidgetSuccessDetail, @@ -143,19 +143,6 @@ const ClaimDailyStatsRow = createComponent(Text, { display: 'flex' as const, }) -function getChainName(chainId: number): string { - switch (chainId) { - case SupportedChains.FUSE: - return 'Fuse' - case SupportedChains.CELO: - return 'Celo' - case SupportedChains.XDC: - return 'XDC' - default: - return `Chain ${chainId}` - } -} - function formatCompactNumber(value: number): string { return new Intl.NumberFormat('en-US', { style: 'decimal', @@ -235,13 +222,17 @@ function CitizenClaimInner({ const chainNameById = useMemo(() => { const map = new Map() for (const entry of claimablesByChain) { - map.set(entry.chainId, getChainName(entry.chainId)) + map.set(entry.chainId, getChainDisplayName(entry.chainId)) } return map }, [claimablesByChain]) /** Dispatch the primary action and surface callbacks for claim outcomes. */ const handlePrimaryAction = useCallback(async () => { + // Tracks which chain a 'switch_chain' attempt targeted, so a failure can + // name that chain in the catch block below (the switch/case scope it's + // set in doesn't otherwise survive into the shared catch). + let switchChainTargetId: number | null = null try { switch (primaryAction) { case 'connect': @@ -253,7 +244,7 @@ function CitizenClaimInner({ case 'claim': { const claimPlan = [...claimablesByChain] if (claimPlan.length === 0) { - const singleChainName = chainId ? getChainName(chainId) : 'active chain' + const singleChainName = chainId ? getChainDisplayName(chainId) : 'active chain' const toastId = createToast({ message: `Claim initiated on ${singleChainName}`, status: 'pending', @@ -292,7 +283,7 @@ function CitizenClaimInner({ const toastByChain = new Map() for (const claimEntry of claimPlan) { const entryChainName = - chainNameById.get(claimEntry.chainId) ?? getChainName(claimEntry.chainId) + chainNameById.get(claimEntry.chainId) ?? getChainDisplayName(claimEntry.chainId) toastByChain.set( claimEntry.chainId, createToast({ @@ -307,7 +298,7 @@ function CitizenClaimInner({ for (const claimResult of claimResults) { const entryChainName = - chainNameById.get(claimResult.chainId) ?? getChainName(claimResult.chainId) + chainNameById.get(claimResult.chainId) ?? getChainDisplayName(claimResult.chainId) const toastId = toastByChain.get(claimResult.chainId) if (!toastId) continue @@ -348,8 +339,11 @@ function CitizenClaimInner({ await actions.refresh() break case 'switch_chain': - // Default to Celo (42220) as the first preferred supported chain - await actions.switchChain?.(42220) + // Prefer switching to a chain the wallet already has a claimable + // balance on; when none is known yet, fall back to XDC, the + // default supported chain. + switchChainTargetId = claimablesByChain[0]?.chainId ?? SupportedChains.XDC + await actions.switchChain?.(switchChainTargetId) break } } catch (err: unknown) { @@ -359,6 +353,25 @@ function CitizenClaimInner({ chainId: chainId ?? null, message: err instanceof Error ? err.message : 'Claim failed', }) + } else if (primaryAction === 'switch_chain') { + // Previously swallowed entirely: a failed switch (unsupported chain + // not yet added to the wallet, a raw provider error with no + // integrator override to fall back to, etc.) produced no toast and + // no onClaimError call, leaving the user with zero feedback that + // nothing happened. Log the underlying error for debugging, but + // surface a message naming the specific chain rather than the raw + // wallet/provider error text. + console.error('[CitizenClaimWidget] switchChain failed', err) + const targetChainName = switchChainTargetId + ? getChainDisplayName(switchChainTargetId) + : 'the requested chain' + const message = `Couldn't switch to ${targetChainName}. Please try again from your wallet.` + createToast({ message, status: 'error', duration: 0 }) + onClaimError?.({ + address: address ?? null, + chainId: switchChainTargetId, + message, + }) } } }, [ @@ -376,7 +389,7 @@ function CitizenClaimInner({ return ( - {status === 'not_whitelisted' && claimablesByChain.length === 0 && ( + {status === 'not_whitelisted' && ( @@ -399,7 +412,7 @@ function CitizenClaimInner({ {/* ------------------------------------------------------------------ */} {/* Main claim card */} {/* ------------------------------------------------------------------ */} - {(status !== 'not_whitelisted' || claimablesByChain.length > 0) && ( + {status !== 'not_whitelisted' && ( {/* Status content */} @@ -412,9 +425,32 @@ function CitizenClaimInner({ )} - {(status === 'eligible' || status === 'claiming' || claimablesByChain.length > 0) && ( + {status === 'unsupported_chain' && ( <> - Ready to claim + + {chainId + ? `Claiming isn't available on ${getChainDisplayName(chainId)}. Switch network to continue.` + : 'Switch to a supported network to claim daily G$'} + + + )} + + {status !== 'unsupported_chain' && + (status === 'eligible' || status === 'claiming' || claimablesByChain.length > 0) && ( + <> + {/* + Declarative to claim status: an already_claimed wallet with + other chains still available must not read as "no claims + left" (the "Just a little longer" copy below is reserved for + when every chain has actually been claimed for the day). + */} + + {status === 'already_claimed' && claimablesByChain.length === 1 + ? `G$ Claim is still available on ${getChainDisplayName(claimablesByChain[0].chainId)}` + : status === 'already_claimed' && claimablesByChain.length > 1 + ? 'G$ Claim is still available on other chains' + : 'Ready to claim'} + {displayAmount && } )} @@ -445,7 +481,7 @@ function CitizenClaimInner({ {claimablesByChain.map((entry, index) => ( - {getChainName(entry.chainId)} + {getChainDisplayName(entry.chainId)} {index < claimablesByChain.length - 1 && ( · @@ -534,44 +570,36 @@ function CitizenClaimInner({ } // --------------------------------------------------------------------------- -// Public component +// Shell — rendered inside GoodWidgetProvider so the network pill reflects the +// live wallet chain id from useWallet() (itself driven by chainIdOverride when +// the integrator supplies one) rather than a static prop fixed at mount. // --------------------------------------------------------------------------- -/** - * CitizenClaimWidget — real SDK-backed GoodDollar UBI claim flow. - * - * Aligned to GoodWalletV2 claim behavior and the claim-widget-theme-demo visual baseline. - * - * Usage as a React component: - * - * - * Also available as a Web Component via the `element` or `register` entry points. - * - * Provider-first runtime path: - * host provider → GoodWidgetProvider → citizen-claim adapter → citizen-sdk - */ -export function CitizenClaimWidget({ - provider, - environment = 'production', - chainId, +interface CitizenClaimShellProps { + /** Used only until the live wallet chain id resolves, or while disconnected. */ + fallbackChainId?: number + environment: CitizenClaimWidgetEnvironment + clientFactory?: CitizenClaimWidgetClientFactory + claimExecution?: CitizenClaimWidgetCustodialExecution + onClaimSuccess?: (detail: CitizenClaimWidgetSuccessDetail) => void + onClaimError?: (detail: CitizenClaimWidgetErrorDetail) => void + initialTab?: CitizenClaimTab +} + +function CitizenClaimShell({ + fallbackChainId, + environment, clientFactory, claimExecution, - themeOverrides, - config, - defaultTheme = 'dark', onClaimSuccess, onClaimError, initialTab, -}: CitizenClaimWidgetProps) { +}: CitizenClaimShellProps) { + const { chainId } = useWallet() // Initial tab only — not synced after mount, matching existing internal-state pattern. const [activeTab, setActiveTab] = useState(initialTab ?? 'claim') return ( - + <> setActiveTab(tabId as CitizenClaimTab)} - chainId={chainId ?? 42220} + chainId={chainId ?? fallbackChainId ?? SupportedChains.XDC} /> {activeTab === 'claim' ? ( <> @@ -600,6 +628,65 @@ export function CitizenClaimWidget({ )} + + ) +} + +// --------------------------------------------------------------------------- +// Public component +// --------------------------------------------------------------------------- +/** + * CitizenClaimWidget — real SDK-backed GoodDollar UBI claim flow. + * + * Aligned to GoodWalletV2 claim behavior and the claim-widget-theme-demo visual baseline. + * + * Usage as a React component: + * + * + * Also available as a Web Component via the `element` or `register` entry points. + * + * Provider-first runtime path: + * host provider → GoodWidgetProvider → citizen-claim adapter → citizen-sdk + */ +export function CitizenClaimWidget({ + provider, + environment = 'production', + chainId, + clientFactory, + claimExecution, + themeOverrides, + config, + defaultTheme = 'dark', + onClaimSuccess, + onClaimError, + initialTab, + addressOverride, + chainIdOverride, + connectOverride, + switchChainOverride, + availableChainIdsOverride, +}: CitizenClaimWidgetProps) { + return ( + + ) } diff --git a/packages/citizen-claim-widget/src/adapter.ts b/packages/citizen-claim-widget/src/adapter.ts index 2159208..ba13684 100644 --- a/packages/citizen-claim-widget/src/adapter.ts +++ b/packages/citizen-claim-widget/src/adapter.ts @@ -63,6 +63,34 @@ const CHAIN_CONFIGS: Record = { const SUPPORTED_CHAINS = citizenSdkCapabilities.chains const AVAILABLE_ENVIRONMENTS = citizenSdkCapabilities.environments +// Display names for chains a connected wallet can land on outside the 3 +// citizen-sdk supports (e.g. the other networks this app's own wallet-connect +// modal offers) — "unsupported chain" messaging only ever needs to name a +// chain outside CHAIN_CONFIGS, so these are looked up separately from the +// viem Chain descriptors above. +const KNOWN_CHAIN_NAMES: Record = { + ...Object.fromEntries(Object.entries(CHAIN_CONFIGS).map(([id, chain]) => [id, chain.name])), + 1: 'Ethereum', + 8453: 'Base', +} + +/** Resolves a chain id to its display name, falling back to the raw id only when truly unknown. */ +export function getChainDisplayName(chainId: number): string { + return KNOWN_CHAIN_NAMES[chainId] ?? `Chain ${chainId}` +} + +/** + * Thrown for adapter-level failures whose message is already user-facing + * (e.g. naming the specific chain an action cannot run on). humanReadableError + * passes these through verbatim instead of remapping them to a generic string. + */ +class CitizenClaimAdapterError extends Error { + constructor(message: string) { + super(message) + this.name = 'CitizenClaimAdapterError' + } +} + // --------------------------------------------------------------------------- // humanReadableError — converts a raw SDK/viem error into a short, user-friendly // string. The full technical error is always logged to the console for debugging. @@ -80,6 +108,10 @@ const AVAILABLE_ENVIRONMENTS = citizenSdkCapabilities.environments function humanReadableError(err: unknown): string { console.error('[CitizenClaimWidget]', err) + if (err instanceof CitizenClaimAdapterError) { + return err.message + } + if (!(err instanceof Error)) { // Log the raw value so non-Error throws are still traceable console.error('[CitizenClaimWidget] non-Error thrown:', typeof err, err) @@ -172,7 +204,8 @@ type CitizenEnvironment = 'production' | 'staging' | 'development' * * State transitions (mirrors GoodWalletV2 ClaimView.tsx logic): * not_connected → [connect] → loading - * loading → not_whitelisted | eligible | already_claimed | error + * loading → not_whitelisted | eligible | already_claimed | unsupported_chain | error + * unsupported_chain → [switch_chain] → loading * not_whitelisted → [verify] → (external FV flow) → loading after return * eligible → [claim] → claiming → success | error * error → [refresh] → loading @@ -180,7 +213,8 @@ type CitizenEnvironment = 'production' | 'staging' | 'development' export function useCitizenClaimAdapter( options: UseCitizenClaimAdapterOptions = {}, ): CitizenClaimWidgetAdapterResult { - const { address, chainId, isConnected, provider, connect } = useWallet() + const { address, chainId, isConnected, provider, availableChainIds, connect, switchChain } = + useWallet() const clientFactory = options.clientFactory const claimExecution = options.claimExecution @@ -193,9 +227,6 @@ export function useCitizenClaimAdapter( : 'production' ) as CitizenEnvironment - // Whether the connected wallet is on a chain supported by citizen-sdk - const onSupportedChain = chainId !== null && isSupportedChain(chainId) - const [status, setStatus] = useState( isConnected ? 'loading' : 'not_connected', ) @@ -355,57 +386,106 @@ export function useCitizenClaimAdapter( [createSdkInstances, resolveClientsForChain], ) + // --------------------------------------------------------------------------- + // Read-only client resolution — balance/entitlement reads never need a + // connected account or the passed-down provider, only the address to read + // for. Custodial mode reuses its own configured per-chain clients (already + // address-scoped); the default path builds an RPC-backed publicClient plus + // a signer-less walletClient carrying just `account`, since checkEntitlement/ + // getWalletClaimStatus only use walletClient.account to identify whose + // entitlement to read and never send a transaction through it. + // --------------------------------------------------------------------------- + const createReadOnlyClientsForChain = useCallback( + (targetChainId: number) => { + if (!address) return null + + if (isCustodialExecution) { + const configuredClients = claimExecution?.clientsByChain[targetChainId] + return configuredClients ? normalizeClientBundle(configuredClients) : null + } + + const chain = CHAIN_CONFIGS[targetChainId] + const rpcUrl = chain?.rpcUrls.default.http[0] + if (!chain || !rpcUrl) return null + + const publicClient = createPublicClient({ chain, transport: http(rpcUrl) }) + const walletClient = createWalletClient({ + account: address as `0x${string}`, + chain, + transport: http(rpcUrl), + }) + return { publicClient, walletClient } + }, + [address, claimExecution, isCustodialExecution, normalizeClientBundle], + ) + + const createReadOnlySdkForChain = useCallback( + (targetChainId: number) => createSdkInstances(createReadOnlyClientsForChain(targetChainId)), + [createReadOnlyClientsForChain, createSdkInstances], + ) + /** * Collects claimable UBI amounts for all citizen-sdk supported chains. * This mirrors GoodWalletV2's claim breakdown model (eligible amounts per chain). + * + * These are personalized reads once an address is known, but they never need + * a connected account or the passed-down provider — each supported chain gets + * its own independently-scoped, read-only SDK instance (RPC-backed publicClient + * + a signer-less walletClient carrying just the address), so no chain's read + * depends on any other chain being "active". */ const loadClaimablesByChain = useCallback(async (): Promise => { const eligible: Array<{ chainId: number; amount: string }> = [] - await Promise.all( - SUPPORTED_CHAINS.map(async (supportedChainId) => { - try { - let entitlement: bigint - if (isConnected && address) { - const sdk = await createSdkInstancesForChain(supportedChainId) + if (address) { + await Promise.all( + SUPPORTED_CHAINS.map(async (supportedChainId) => { + try { + const sdk = createReadOnlySdkForChain(supportedChainId) if (!sdk) return - // The no-argument generic entitlement is the chain pool amount, - // not this wallet's entitlement. Use the SDK's account-scoped - // check when the wallet is connected so claimAll only targets - // chains this account can actually claim on. - entitlement = (await sdk.claimSDK.checkEntitlement()).amount - } else { + const result = await sdk.claimSDK.checkEntitlement() + if (result.amount <= 0n) return + + const decimals = CHAIN_DECIMALS[supportedChainId] ?? 18 + eligible.push({ + chainId: supportedChainId, + amount: formatUnits(result.amount, decimals), + }) + } catch { + // Keep per-chain reads best-effort: one RPC/SDK failure should not block the widget. + } + }), + ) + } else { + // No address at all: fall back to the non-personalized, chain-level entitlement reads. + await Promise.all( + SUPPORTED_CHAINS.map(async (supportedChainId) => { + try { const publicClient = getPublicClientForChain(supportedChainId) if (!publicClient) return - entitlement = await checkGenericEntitlement({ + const entitlement = await checkGenericEntitlement({ publicClient, chainId: supportedChainId, env, }) - } - if (entitlement <= 0n) return + if (entitlement <= 0n) return - const decimals = CHAIN_DECIMALS[supportedChainId] ?? 18 - eligible.push({ - chainId: supportedChainId, - amount: formatUnits(entitlement, decimals), - }) - } catch { - // Keep per-chain reads best-effort: one RPC/SDK failure should not block the widget. - } - }), - ) + const decimals = CHAIN_DECIMALS[supportedChainId] ?? 18 + eligible.push({ + chainId: supportedChainId, + amount: formatUnits(entitlement, decimals), + }) + } catch { + // Keep per-chain reads best-effort: one RPC/SDK failure should not block the widget. + } + }), + ) + } if (!mountedRef.current) return eligible.sort((a, b) => b.chainId - a.chainId) setClaimablesByChain(eligible) - }, [ - address, - createSdkInstancesForChain, - env, - getPublicClientForChain, - isConnected, - ]) + }, [address, createReadOnlySdkForChain, env, getPublicClientForChain]) const loadDailyStats = useCallback(async (): Promise => { let maxClaimers = 0 @@ -450,25 +530,80 @@ export function useCitizenClaimAdapter( loadDailyStats(), ]) - if (!isConnected || !address) { + if (!address) { await auxiliaryReads + // No wallet address: clear any personalized entitlement left over from a + // prior connected session so a disconnected user never sees stale amounts. + setAmount(null) + setNextClaimTime(null) setStatus('not_connected') return } - if (!onSupportedChain) { + if (!isCustodialExecution && chainId === null) { + // Wallet is connected but hasn't reported an active chain yet (common + // right after connecting) — treat this as still resolving rather than + // unsupported, since it's unknown whether the eventual chain will be + // supported. The chainId-keyed effect below reruns this once it + // resolves, so this never gets stuck. await auxiliaryReads - // Wallet connected but on an unsupported chain — surface switch_chain action - setStatus('not_connected') + setStatus('loading') + return + } + + // Custodial execution submits claims through its own configured per-chain + // clients, never the active wallet chain, so it has no dependency on + // `chainId` at all — read the personalized status from whichever + // configured chain comes first, rather than gating on an "active chain" + // that may never resolve to a supported one (or may not exist). + const statusChainId = isCustodialExecution + ? SUPPORTED_CHAINS.find((supportedChainId) => claimExecution?.clientsByChain[supportedChainId]) ?? + null + : chainId + + if (isCustodialExecution && statusChainId === null) { + await auxiliaryReads + // Custodial execution has no wallet chain to switch, so a missing + // client for every supported chain is an integrator configuration + // problem rather than something the "switch network" narrative below + // could ever resolve — route it to a plain error instead. + setAmount(null) + setNextClaimTime(null) + setStatus('error') + setError( + humanReadableError( + new CitizenClaimAdapterError('Claim execution is not configured for any supported chain.'), + ), + ) + return + } + + if (statusChainId === null || !isSupportedChain(statusChainId)) { + await auxiliaryReads + // Chain is known but unsupported — a distinct status from not_connected + // so the UI can show "switch chain" copy instead of misleadingly asking + // an already-connected wallet to connect. Clear personalized entitlement + // from whatever chain was previously active. + setAmount(null) + setNextClaimTime(null) + setStatus('unsupported_chain') return } setStatus('loading') setError(null) - const sdk = await createSdkInstancesForChain(chainId) + // A personalized status read, but still address-only: no connected + // account or passed-down provider is required, only the address itself. + const sdk = createReadOnlySdkForChain(statusChainId) if (!sdk) { - setStatus('not_connected') + // The address is known and statusChainId passed the supported-chain + // check above, so a null sdk here means client/RPC setup itself + // failed (e.g. a misconfigured chain) rather than no wallet being + // connected — 'not_connected' would tell an already-connected user + // to do something they've already done. + setStatus('error') + setError(humanReadableError(new CitizenClaimAdapterError('Unable to load claim status for this chain right now.'))) return } @@ -483,7 +618,7 @@ export function useCitizenClaimAdapter( } else if (walletStatus.status === 'can_claim') { // User is whitelisted and has unclaimed UBI setStatus('eligible') - const decimals = CHAIN_DECIMALS[chainId as SupportedChains] ?? 18 + const decimals = CHAIN_DECIMALS[statusChainId] ?? 18 setAmount(formatUnits(walletStatus.entitlement, decimals)) } else { // User is whitelisted but has already claimed for this period @@ -497,11 +632,11 @@ export function useCitizenClaimAdapter( setError(humanReadableError(err)) } }, [ - isConnected, address, - onSupportedChain, chainId, - createSdkInstancesForChain, + claimExecution, + isCustodialExecution, + createReadOnlySdkForChain, loadClaimablesByChain, loadDailyStats, ]) @@ -518,11 +653,26 @@ export function useCitizenClaimAdapter( // --------------------------------------------------------------------------- const claimOnChain = useCallback( async (targetChainId: number): Promise => { - if (!isCustodialExecution && !provider) throw new Error('No wallet provider available') - if (!address && !isCustodialExecution) throw new Error('Wallet not connected') + if (!isCustodialExecution && !provider) { + throw new CitizenClaimAdapterError('No wallet provider available') + } + if (!address && !isCustodialExecution) { + throw new CitizenClaimAdapterError('Wallet not connected') + } if (!isSupportedChain(targetChainId)) { - throw new Error(`Unsupported chain for citizen-sdk: ${targetChainId}`) + throw new CitizenClaimAdapterError( + `Unsupported chain for citizen-sdk: ${getChainDisplayName(targetChainId)}`, + ) + } + + // Execute actions must stay within the chains the passed-down provider + // can actually sign for right now. Custodial execution supplies its own + // pre-configured per-chain clients and is not subject to this restriction. + if (!isCustodialExecution && availableChainIds && !availableChainIds.includes(targetChainId)) { + throw new CitizenClaimAdapterError( + `Claim is not available on ${getChainDisplayName(targetChainId)} for this connection.`, + ) } setStatus('claiming') @@ -530,23 +680,49 @@ export function useCitizenClaimAdapter( // A single EIP-1193 provider has one active chain. Custodial clients are // already chain-bound, so switching would introduce a race between claims. + // switchChain tries the raw wallet_switchEthereumChain request first and + // falls back to the integrator's own switch/network-modal flow (e.g. + // AppKit) when the active connector rejects or ignores it. if (!isCustodialExecution) { - await ( - provider as { - request: (args: { method: string; params: unknown[] }) => Promise - } - ).request({ - method: 'wallet_switchEthereumChain', - params: [{ chainId: `0x${targetChainId.toString(16)}` }], - }) + await switchChain(targetChainId) } const sdk = await createSdkInstancesForChain(targetChainId) - if (!sdk) throw new Error('Unable to initialize SDK clients for target chain') + if (!sdk) { + throw new CitizenClaimAdapterError( + `Unable to initialize SDK clients for ${getChainDisplayName(targetChainId)}`, + ) + } return sdk.claimSDK.claim() }, - [provider, address, createSdkInstancesForChain, isCustodialExecution], + [address, availableChainIds, createSdkInstancesForChain, isCustodialExecution, provider, switchChain], + ) + + // --------------------------------------------------------------------------- + // handleSwitchChain — the switchChain action exposed to widget UI (the + // standalone "switch to a supported chain" prompt, as opposed to the + // claim-flow's internal switchChain call inside claimOnChain). Wraps the raw + // useWallet() switchChain so a wallet rejection or RPC failure always reaches + // the widget as a humanized message in state.error, never as a raw error. + // --------------------------------------------------------------------------- + const handleSwitchChain = useCallback( + async (targetChainId: number): Promise => { + setError(null) + try { + await switchChain(targetChainId) + } catch (err: unknown) { + if (!mountedRef.current) return + // Sets state.error so the inline banner shows a humanized message + // immediately, then rethrows — mirroring handleClaim above — so the + // widget's own catch can also name the specific chain that failed + // in a toast/onClaimError, which needs the raw error to reach it. + setStatus('error') + setError(humanReadableError(err)) + throw err + } + }, + [switchChain], ) const claimAll = useCallback( @@ -647,41 +823,31 @@ export function useCitizenClaimAdapter( } }, [connect, loadClaimStatus]) - // --------------------------------------------------------------------------- - // handleSwitchChain — requests the wallet to switch to a supported chain. - // Uses the EIP-3326 wallet_switchEthereumChain method. - // --------------------------------------------------------------------------- - const handleSwitchChain = useCallback( - async (targetChainId: number): Promise => { - 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], - ) - // --------------------------------------------------------------------------- // Derived state: primaryAction and primaryLabel // --------------------------------------------------------------------------- const primaryAction: CitizenClaimWidgetAdapterState['primaryAction'] = useMemo(() => { if (status === 'connecting') return 'connect' - // Custodial execution is multi-chain. An account-scoped entitlement on any - // configured chain must take precedence over the active chain's status. - if (isConnected && address && claimablesByChain.length > 0) return 'claim' - if (status === 'not_connected') { - // Custodial clients are already configured per chain, so they never need - // the active wallet chain to be switched. Native wallet integrations keep - // the existing switch-chain behavior. - if (isCustodialExecution) return 'none' - return isConnected && !onSupportedChain ? 'switch_chain' : 'connect' - } + // A non-custodial claim always executes on the active wallet chain (see + // claimOnChain/handleClaim below) — a claimable balance on some other + // supported chain is irrelevant until that chain itself is switched to, + // so this must be checked before the claimablesByChain precedence below, + // or the button would offer "Claim" and immediately fail against the + // active unsupported chain. + if (status === 'unsupported_chain' && !isCustodialExecution) return 'switch_chain' + // Whitelisting is required on the chain getWalletClaimStatus checked + // (the active/default chain), and identity is account-scoped rather than + // per-chain — a claimable balance surfaced on some other chain must not + // offer "Claim" ahead of resolving that, since claimAll would otherwise + // attempt (and fail) real claims on every other chain before the wallet + // has even completed face verification. if (status === 'not_whitelisted') return 'verify' + // Custodial execution is multi-chain and has no "active" wallet chain, so + // an account-scoped entitlement on any configured chain takes precedence + // over the unsupported_chain status entirely. + if (isConnected && address && claimablesByChain.length > 0) return 'claim' + if (status === 'unsupported_chain') return 'none' + if (status === 'not_connected') return 'connect' // Keep the claim button mounted while a claim is in-flight so UI copy can // switch to "Claiming..." without hiding the action surface. if (status === 'claiming') return 'claim' @@ -692,9 +858,8 @@ export function useCitizenClaimAdapter( status, address, isConnected, - onSupportedChain, - claimablesByChain, isCustodialExecution, + claimablesByChain, ]) const primaryLabel: string = useMemo(() => { diff --git a/packages/citizen-claim-widget/src/widgetRuntimeContract.ts b/packages/citizen-claim-widget/src/widgetRuntimeContract.ts index 923a54d..61e7c01 100644 --- a/packages/citizen-claim-widget/src/widgetRuntimeContract.ts +++ b/packages/citizen-claim-widget/src/widgetRuntimeContract.ts @@ -7,6 +7,8 @@ export type CitizenClaimWidgetStatus = | 'loading' | 'connecting' | 'not_connected' + /** Wallet is connected but its active chain isn't one citizen-sdk supports. */ + | 'unsupported_chain' | 'not_whitelisted' | 'eligible' | 'already_claimed' @@ -127,11 +129,43 @@ export interface CitizenClaimWidgetChainClaimResult { export interface CitizenClaimWidgetProps { provider?: unknown environment?: CitizenClaimWidgetEnvironment + /** + * Fallback chain id shown only until the live wallet chain resolves via + * `provider`/`chainIdOverride`, or while disconnected. Once a live chain is + * known it always takes precedence over this value. + */ chainId?: number clientFactory?: CitizenClaimWidgetClientFactory claimExecution?: CitizenClaimWidgetCustodialExecution onClaimSuccess?: (detail: CitizenClaimWidgetSuccessDetail) => void onClaimError?: (detail: CitizenClaimWidgetErrorDetail) => void + /** + * Integrator-owned live address (e.g. from a wallet-connection SDK's own + * reactive account hook). See `GoodWidgetProviderProps.addressOverride`. + */ + addressOverride?: string | null + /** + * Integrator-owned live chain id, mirroring `addressOverride`. See + * `GoodWidgetProviderProps.chainIdOverride`. + */ + chainIdOverride?: number | null + /** + * Integrator-owned connect fallback (e.g. opening a wallet-connect modal + * instead of requesting the injected provider directly). See + * `GoodWidgetProviderProps.connectOverride`. + */ + connectOverride?: () => Promise + /** + * Integrator-owned chain-switch fallback. See + * `GoodWidgetProviderProps.switchChainOverride`. + */ + switchChainOverride?: (chainId: number) => Promise + /** + * Chain ids the passed-down provider can currently execute on. See + * `GoodWidgetProviderProps.availableChainIdsOverride`. Claim execution is + * scoped to this set; balance/entitlement reads are unaffected. + */ + availableChainIdsOverride?: number[] | null // ---- Theming (optional, passed through to GoodWidgetProvider) ---- /** Token and theme overrides applied at the widget boundary. */ themeOverrides?: GoodWidgetThemeOverrides diff --git a/packages/core/src/provider.tsx b/packages/core/src/provider.tsx index 4cb32dc..1cf8c10 100644 --- a/packages/core/src/provider.tsx +++ b/packages/core/src/provider.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState, useCallback } from 'react' import { TamaguiProvider } from 'tamagui' import { createGoodWidgetConfig, mergeThemeOverrides, YStack, Stack } from '@goodwidget/ui' +import type { IconName } from '@goodwidget/ui' import { detectHost } from './detect' import type { EIP1193Provider } from './eip1193' import type { @@ -23,6 +24,11 @@ const DEFAULT_CAPABILITIES: HostCapabilities = { export interface WalletContextValue extends WalletState { connect: () => Promise disconnect: () => Promise + /** Label for the disconnect action; see `GoodWidgetProviderProps.disconnectLabel`. */ + disconnectLabel: string + /** Icon for the disconnect action; see `GoodWidgetProviderProps.disconnectIcon`. */ + disconnectIcon: IconName + switchChain: (chainId: number) => Promise } export type HostContextValue = HostState @@ -30,6 +36,53 @@ export type HostContextValue = HostState export interface GoodWidgetContextValue extends GoodWidgetState { connect: () => Promise disconnect: () => Promise + disconnectLabel: string + disconnectIcon: IconName + switchChain: (chainId: number) => Promise +} + +const SWITCH_CHAIN_UNAVAILABLE_ERROR = 'No wallet provider available to switch chains' +const SWITCH_CHAIN_TIMEOUT_ERROR = 'Timed out waiting for the wallet to respond to the network switch request' +const SWITCH_CHAIN_REQUEST_TIMEOUT_MS = 10_000 + +const noopSwitchChain = async () => { + throw new Error(SWITCH_CHAIN_UNAVAILABLE_ERROR) +} + +/** + * Races a promise against a timeout so a request the wallet never settles + * (some WalletConnect sessions never resolve or reject + * wallet_switchEthereumChain at all) doesn't hang the caller forever — a + * timeout is treated the same as any other rejection, so switchChain's + * existing override fallback below still applies. + */ +function raceWithTimeout(promise: Promise, timeoutMs: number, timeoutMessage: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs) + promise.then( + (value) => { + clearTimeout(timer) + resolve(value) + }, + (err) => { + clearTimeout(timer) + reject(err) + }, + ) + }) +} + +/** + * EIP-1193's standard user-rejection code (4001), plus ethers v6's + * ACTION_REJECTED — both mean the wallet's own switch-chain prompt was + * shown and the user explicitly declined it, as opposed to the request + * being unsupported or never resolving. Falling back to the integrator's + * switch-chain override on a rejection would re-prompt via a second modal + * right after the user just said no to the first one. + */ +function isUserRejectedSwitchChain(err: unknown): boolean { + const code = (err as { code?: number | string } | undefined)?.code + return Number(code) === 4001 || code === 'ACTION_REJECTED' } export const WalletContext = React.createContext({ @@ -37,8 +90,12 @@ export const WalletContext = React.createContext({ chainId: null, isConnected: false, provider: null, + availableChainIds: null, connect: async () => {}, disconnect: async () => {}, + disconnectLabel: 'Disconnect', + disconnectIcon: 'log-out', + switchChain: noopSwitchChain, }) export const HostContext = React.createContext({ @@ -51,16 +108,26 @@ export const GoodWidgetContext = React.createContext({ chainId: null, isConnected: false, provider: null, + availableChainIds: null, host: 'injected', capabilities: DEFAULT_CAPABILITIES, connect: async () => {}, disconnect: async () => {}, + disconnectLabel: 'Disconnect', + disconnectIcon: 'log-out', + switchChain: noopSwitchChain, }) export function GoodWidgetProvider({ provider: explicitProvider, connectOverride, disconnectOverride, + addressOverride, + chainIdOverride, + switchChainOverride, + availableChainIdsOverride, + disconnectLabel = 'Disconnect', + disconnectIcon = 'log-out', config: authorConfig, themeOverrides, defaultTheme = 'dark', @@ -72,8 +139,8 @@ export function GoodWidgetProvider({ ) const [host, setHost] = useState('injected') const [capabilities, setCapabilities] = useState(DEFAULT_CAPABILITIES) - const [address, setAddress] = useState(null) - const [chainId, setChainId] = useState(null) + const [trackedAddress, setTrackedAddress] = useState(null) + const [trackedChainId, setTrackedChainId] = useState(null) useEffect(() => { let cancelled = false @@ -88,37 +155,56 @@ export function GoodWidgetProvider({ } }, [explicitProvider]) + // When the integrator supplies both a live address and chain id (e.g. from + // a wallet connection SDK's own reactive hooks), those become the single + // source of truth and the raw EIP-1193 event listeners below are skipped + // entirely. When only one of the two is supplied, the listeners still run + // so the other value keeps tracking — some connectors (WalletConnect + // sessions bridged through AppKit in particular) do not reliably emit + // accountsChanged/chainChanged, which otherwise leaves that value stale + // after a connect/disconnect/switch. + const hasAddressOverride = addressOverride !== undefined + const hasChainIdOverride = chainIdOverride !== undefined + useEffect(() => { + if (hasAddressOverride && hasChainIdOverride) return if (!resolvedProvider) return const handleAccountsChanged = (accounts: string[]) => { - setAddress(accounts[0] ?? null) + if (!hasAddressOverride) setTrackedAddress(accounts[0] ?? null) } const handleChainChanged = (newChainId: string) => { - setChainId(parseInt(newChainId, 16)) + if (!hasChainIdOverride) setTrackedChainId(parseInt(newChainId, 16)) } resolvedProvider.on('accountsChanged', handleAccountsChanged) resolvedProvider.on('chainChanged', handleChainChanged) - resolvedProvider - .request({ method: 'eth_accounts' }) - .then((accounts) => { - const accs = accounts as string[] - if (accs.length > 0) setAddress(accs[0]) - }) - .catch(() => {}) + if (!hasAddressOverride) { + resolvedProvider + .request({ method: 'eth_accounts' }) + .then((accounts) => { + const accs = accounts as string[] + if (accs.length > 0) setTrackedAddress(accs[0]) + }) + .catch(() => {}) + } - resolvedProvider - .request({ method: 'eth_chainId' }) - .then((id) => setChainId(parseInt(id as string, 16))) - .catch(() => {}) + if (!hasChainIdOverride) { + resolvedProvider + .request({ method: 'eth_chainId' }) + .then((id) => setTrackedChainId(parseInt(id as string, 16))) + .catch(() => {}) + } return () => { resolvedProvider.removeListener('accountsChanged', handleAccountsChanged) resolvedProvider.removeListener('chainChanged', handleChainChanged) } - }, [resolvedProvider]) + }, [resolvedProvider, hasAddressOverride, hasChainIdOverride]) + + const address = hasAddressOverride ? (addressOverride ?? null) : trackedAddress + const chainId = hasChainIdOverride ? (chainIdOverride ?? null) : trackedChainId const connect = useCallback(async () => { if (connectOverride) { @@ -130,18 +216,51 @@ export function GoodWidgetProvider({ const accounts = (await resolvedProvider.request({ method: 'eth_requestAccounts', })) as string[] - if (accounts.length > 0) { - setAddress(accounts[0]) + if (accounts.length > 0 && !hasAddressOverride) { + setTrackedAddress(accounts[0]) } - }, [connectOverride, resolvedProvider]) + }, [connectOverride, resolvedProvider, hasAddressOverride]) // Wallet session ownership stays with the integrator. Provider/account - // updates after the override resolves flow back through the normal EIP-1193 + // updates after the override resolves flow back through addressOverride/ + // chainIdOverride when supplied, otherwise through the normal EIP-1193 // accountsChanged event or a changed provider prop. const disconnect = useCallback(async () => { await disconnectOverride?.() }, [disconnectOverride]) + // Tries the standard EIP-3326 request first; falls back to the + // integrator's own switch/network-modal flow (e.g. AppKit) when the + // active connector rejects, does not support it, or never settles the + // request at all (some WalletConnect sessions do this, hence the timeout + // race below rather than a bare await). + const switchChain = useCallback( + async (targetChainId: number) => { + if (resolvedProvider) { + try { + await raceWithTimeout( + resolvedProvider.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: `0x${targetChainId.toString(16)}` }], + }), + SWITCH_CHAIN_REQUEST_TIMEOUT_MS, + SWITCH_CHAIN_TIMEOUT_ERROR, + ) + return + } catch (err) { + if (!switchChainOverride || isUserRejectedSwitchChain(err)) throw err + } + } + if (!switchChainOverride) { + throw new Error(SWITCH_CHAIN_UNAVAILABLE_ERROR) + } + await switchChainOverride(targetChainId) + }, + [resolvedProvider, switchChainOverride], + ) + + const availableChainIds = availableChainIdsOverride ?? null + const mergedConfig = useMemo(() => { const finalConfig = mergeThemeOverrides(authorConfig, themeOverrides) return createGoodWidgetConfig(finalConfig ?? undefined) @@ -153,10 +272,24 @@ export function GoodWidgetProvider({ chainId, isConnected: address !== null, provider: resolvedProvider, + availableChainIds, connect, disconnect, + disconnectLabel, + disconnectIcon, + switchChain, }), - [address, chainId, resolvedProvider, connect, disconnect], + [ + address, + chainId, + resolvedProvider, + availableChainIds, + connect, + disconnect, + disconnectLabel, + disconnectIcon, + switchChain, + ], ) const hostValue = useMemo(() => ({ host, capabilities }), [host, capabilities]) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d3c1c75..93f6819 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,6 +1,6 @@ import type { EIP1193Provider } from './eip1193' import type { ReactNode } from 'react' -import type { GoodWidgetConfig, GoodWidgetThemeOverrides } from '@goodwidget/ui' +import type { GoodWidgetConfig, GoodWidgetThemeOverrides, IconName } from '@goodwidget/ui' export type HostEnvironment = 'farcaster' | 'minipay' | 'worldapp' | 'injected' | 'custom' @@ -23,6 +23,13 @@ export interface WalletState { chainId: number | null isConnected: boolean provider: EIP1193Provider | null + /** + * Chain ids the passed-down provider can actually execute on right now + * (e.g. an AppKit connection's approved networks). `null` means no + * restriction is known, so on-chain execute actions should not be gated + * by chain availability. + */ + availableChainIds: number[] | null } export interface HostState { @@ -39,6 +46,50 @@ export interface GoodWidgetProviderProps { provider?: EIP1193Provider connectOverride?: () => Promise disconnectOverride?: () => Promise + /** + * Integrator-owned live address (e.g. from a wallet-connection SDK's own + * reactive account hook). When set, including `null`, this replaces the + * address normally derived from the raw provider's `accountsChanged` + * event/`eth_accounts` call, which some connectors (e.g. a WalletConnect + * session bridged through AppKit) do not reliably emit. + */ + addressOverride?: string | null + /** + * Integrator-owned live chain id, mirroring `addressOverride`. When set, + * including `null`, this replaces the chain id normally derived from the + * raw provider's `chainChanged` event/`eth_chainId` call. + */ + chainIdOverride?: number | null + /** + * Integrator-owned chain-switch fallback, invoked when a raw + * `wallet_switchEthereumChain` request fails or the provider does not + * support it (e.g. to open a connection SDK's own network modal). + */ + switchChainOverride?: (chainId: number) => Promise + /** + * Integrator-owned set of chain ids the passed-down provider can currently + * execute on (e.g. AppKit's approved networks). `null`/omitted means no + * restriction is known. On-chain execute actions must be scoped to this + * set; balance/entitlement reads are unaffected since those use read-only + * clients keyed on `address` alone. + */ + availableChainIdsOverride?: number[] | null + /** + * Label for the disconnect action in the wallet chip menu. Defaults to + * "Disconnect". Integrators whose `disconnectOverride` opens a + * connection-management modal rather than disconnecting directly (e.g. + * AppKit's Account view) should override this to something like "Network + * settings" so the action's label matches what it actually does. + */ + disconnectLabel?: string + /** + * Icon for the disconnect action in the wallet chip menu, mirroring + * `disconnectLabel`. Defaults to 'log-out'. Integrators overriding + * `disconnectLabel` to describe a settings/management action (e.g. + * AppKit's "Network settings") should pair it with a matching icon (e.g. + * 'settings') instead of leaving the literal log-out glyph. + */ + disconnectIcon?: IconName config?: GoodWidgetConfig themeOverrides?: GoodWidgetThemeOverrides defaultTheme?: 'light' | 'dark' diff --git a/packages/embed/src/DefaultAppKitProvider.tsx b/packages/embed/src/DefaultAppKitProvider.tsx index eff2883..e1fddb4 100644 --- a/packages/embed/src/DefaultAppKitProvider.tsx +++ b/packages/embed/src/DefaultAppKitProvider.tsx @@ -3,7 +3,9 @@ import { AppKitProvider } from '@reown/appkit/react' import { base, celo, fuse, mainnet, xdc, type AppKitNetwork } from '@reown/appkit/networks' import { WagmiAdapter } from '@reown/appkit-adapter-wagmi' -const DEFAULT_APPKIT_NETWORKS = [mainnet, base, xdc, fuse, celo] as [ +// xdc leads the list: AppKit treats the first network as the default chain +// a freshly connecting wallet lands on. +export const DEFAULT_APPKIT_NETWORKS = [xdc, mainnet, base, fuse, celo] as [ AppKitNetwork, ...AppKitNetwork[], ] diff --git a/packages/embed/src/appkitProvider.ts b/packages/embed/src/appkitProvider.ts index e812c87..287df22 100644 --- a/packages/embed/src/appkitProvider.ts +++ b/packages/embed/src/appkitProvider.ts @@ -1,2 +1,9 @@ export { DefaultAppKitProvider } from './DefaultAppKitProvider' -export { useAppKit, useAppKitAccount, useAppKitProvider, modal } from '@reown/appkit/react' +export { + useAppKit, + useAppKitAccount, + useAppKitNetwork, + useAppKitProvider, + modal, +} from '@reown/appkit/react' +export { DEFAULT_APPKIT_NETWORKS } from './DefaultAppKitProvider' diff --git a/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx b/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx index f95b881..c7f70eb 100644 --- a/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx +++ b/packages/superfluid-campaign-widget/src/SuperfluidCampaignWidget.tsx @@ -36,6 +36,7 @@ interface SuperfluidCampaignRuntimeProps { citizenClaimEnvironment: SuperfluidCampaignWidgetProps['citizenClaimEnvironment'] citizenClaimExecution: SuperfluidCampaignWidgetProps['citizenClaimExecution'] disableClaim: boolean + disableWalletButton?: SuperfluidCampaignWidgetProps['disableWalletButton'] initialView: SuperfluidCampaignView poolAddresses?: SuperfluidCampaignWidgetProps['poolAddresses'] /** Forwarded to the embedded CitizenClaimWidget so it shares the same provider/config/theme context. */ @@ -43,6 +44,12 @@ interface SuperfluidCampaignRuntimeProps { config?: SuperfluidCampaignWidgetProps['config'] themeOverrides?: SuperfluidCampaignWidgetProps['themeOverrides'] defaultTheme?: SuperfluidCampaignWidgetProps['defaultTheme'] + /** Forwarded to the embedded CitizenClaimWidget's own GoodWidgetProvider. */ + addressOverride?: SuperfluidCampaignWidgetProps['addressOverride'] + chainIdOverride?: SuperfluidCampaignWidgetProps['chainIdOverride'] + connectOverride?: SuperfluidCampaignWidgetProps['connectOverride'] + switchChainOverride?: SuperfluidCampaignWidgetProps['switchChainOverride'] + availableChainIdsOverride?: SuperfluidCampaignWidgetProps['availableChainIdsOverride'] hasDisconnectOverride: boolean } @@ -111,15 +118,21 @@ function SuperfluidCampaignRuntime({ citizenClaimEnvironment, citizenClaimExecution, disableClaim, + disableWalletButton, initialView, poolAddresses, provider, config, themeOverrides, defaultTheme, + addressOverride, + chainIdOverride, + connectOverride, + switchChainOverride, + availableChainIdsOverride, hasDisconnectOverride, }: SuperfluidCampaignRuntimeProps) { - const { isConnected, connect, disconnect, address } = useWallet() + const { isConnected, connect, disconnect, disconnectLabel, disconnectIcon, address } = useWallet() const [view, setView] = useState(initialView) const [embeddedClaimTab, setEmbeddedClaimTab] = useState(null) const [leaderboardRefreshKey, setLeaderboardRefreshKey] = useState(0) @@ -146,13 +159,21 @@ function SuperfluidCampaignRuntime({ isConnected={isConnected} onConnect={connect} onDisconnect={hasDisconnectOverride ? disconnect : undefined} + disconnectLabel={hasDisconnectOverride ? disconnectLabel : undefined} + disconnectIcon={hasDisconnectOverride ? disconnectIcon : undefined} onClose={() => setEmbeddedClaimTab(null)} + disableWalletButton={disableWalletButton} /> setView('content')} leaderboardRefreshKey={leaderboardRefreshKey} userPointsAdapter={isMockRuntime ? dataClient.userPoints : undefined} + disableWalletButton={disableWalletButton} /> ) } @@ -188,6 +212,9 @@ function SuperfluidCampaignRuntime({ isConnected={isConnected} onConnect={connect} onDisconnect={hasDisconnectOverride ? disconnect : undefined} + disconnectLabel={hasDisconnectOverride ? disconnectLabel : undefined} + disconnectIcon={hasDisconnectOverride ? disconnectIcon : undefined} + disableWalletButton={disableWalletButton} /> diff --git a/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx b/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx index 5e7deca..fe4b023 100644 --- a/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx +++ b/packages/superfluid-campaign-widget/src/components/CampaignHeader.tsx @@ -1,5 +1,6 @@ import React from 'react' import { Badge, BadgeText, Button, ButtonText, Heading, Icon, Text, XStack, YStack } from '@goodwidget/ui' +import type { IconName } from '@goodwidget/ui' import type { CampaignDefinition } from '../widgetRuntimeContract' import { ConnectWalletPrompt } from './ConnectWalletPrompt' import { compactButtonProps } from './shared/styles' @@ -17,8 +18,14 @@ interface CampaignHeaderProps { isConnected: boolean onConnect: () => void onDisconnect?: () => Promise + /** Label for the WalletChip's disconnect action. See WalletChip's own prop for details. */ + disconnectLabel?: string + /** Icon for the WalletChip's disconnect action. See WalletChip's own prop for details. */ + disconnectIcon?: IconName /** Present when the campaign shell is showing an in-place child widget. */ onClose?: () => void + /** Disables the connect/wallet-status button. See `SuperfluidCampaignWidgetProps.disableWalletButton`. */ + disableWalletButton?: boolean } /** @@ -36,7 +43,10 @@ export function CampaignHeader({ isConnected, onConnect, onDisconnect, + disconnectLabel, + disconnectIcon, onClose, + disableWalletButton = false, }: CampaignHeaderProps) { return ( @@ -60,9 +70,15 @@ export function CampaignHeader({ {isConnected ? ( - + ) : ( - + )} {onClose && ( ) diff --git a/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx b/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx index c78353d..d256085 100644 --- a/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx +++ b/packages/superfluid-campaign-widget/src/components/LeaderboardView.tsx @@ -12,6 +12,7 @@ import { XStack, YStack, } from '@goodwidget/ui' +import type { IconName } from '@goodwidget/ui' import type { CampaignLeaderboardAdapter, CampaignPointsAccount, @@ -42,9 +43,15 @@ interface LeaderboardViewProps { isConnected: boolean onConnect: () => void onDisconnect?: () => Promise + /** Label for the WalletChip's disconnect action. See WalletChip's own prop for details. */ + disconnectLabel?: string + /** Icon for the WalletChip's disconnect action. See WalletChip's own prop for details. */ + disconnectIcon?: IconName onClose: () => void leaderboardRefreshKey: number userPointsAdapter?: CampaignUserPointsAdapter + /** Disables the connect/wallet-status button. See `SuperfluidCampaignWidgetProps.disableWalletButton`. */ + disableWalletButton?: boolean } /** @@ -85,9 +92,12 @@ export function LeaderboardView({ isConnected, onConnect, onDisconnect, + disconnectLabel, + disconnectIcon, onClose, leaderboardRefreshKey, userPointsAdapter, + disableWalletButton = false, }: LeaderboardViewProps) { const [searchQuery, setSearchQuery] = useState('') const [activeCampaignTab, setActiveCampaignTab] = useState(pools[0]?.id ?? '') @@ -177,9 +187,20 @@ export function LeaderboardView({ {isConnected ? ( - + ) : ( - )} diff --git a/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx b/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx index 7b98f1f..845050a 100644 --- a/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx +++ b/packages/superfluid-campaign-widget/src/components/shared/WalletChip.tsx @@ -1,26 +1,53 @@ -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { Button, ButtonText, Icon, Text, XStack, YStack } from '@goodwidget/ui' +import type { IconName } from '@goodwidget/ui' import { truncateAddress } from './styles' interface WalletChipProps { address: string | null onDisconnect?: () => Promise + /** + * Label for the menu action. Defaults to "Disconnect", which only makes + * sense when onDisconnect directly ends the wallet session. Integrators + * whose onDisconnect instead opens a connection-management modal (e.g. + * AppKit's Account view) should pass something like "Network settings" so + * the action's label matches what it actually does. + */ + disconnectLabel?: string + /** Icon for the menu action, mirroring `disconnectLabel`. Defaults to 'log-out'. */ + disconnectIcon?: IconName + /** When true, the chip no longer opens its menu and renders at reduced opacity. */ + disabled?: boolean } /** * Connected-wallet chip (status dot + truncated address + chevron) shared by * CampaignHeader and LeaderboardView so both headers stay identical instead - * of duplicating the markup. Pressing the chip opens a single-action - * "Disconnect" dropdown, following the same relative/absolute positioning + * of duplicating the markup. Pressing the chip opens a single-action menu + * (default "Disconnect", customizable via disconnectLabel/disconnectIcon — + * see their doc comments), following the same relative/absolute positioning * pattern as InfoTooltip in ai-credits-widget rather than pulling in the * heavier Drawer/ActionSheet primitives for one menu item. */ -export function WalletChip({ address, onDisconnect }: WalletChipProps) { +export function WalletChip({ + address, + onDisconnect, + disconnectLabel = 'Disconnect', + disconnectIcon = 'log-out', + disabled = false, +}: WalletChipProps) { const [isMenuOpen, setIsMenuOpen] = useState(false) const [disconnectMessage, setDisconnectMessage] = useState(null) + // Resets the open state itself (not just its render) when disabled turns + // true, so re-enabling later starts from closed instead of the menu + // silently reappearing from whatever state it was left in. + useEffect(() => { + if (disabled) setIsMenuOpen(false) + }, [disabled]) + return ( - + { + if (disabled) return setDisconnectMessage(null) setIsMenuOpen((open) => !open) }} aria-label="Wallet options" + aria-disabled={disabled} > {address ? truncateAddress(address) : ''} - {isMenuOpen && ( + {/* Also gated on !disabled: if disabled flips true while the menu is + already open, this stops rendering it (and its disconnect action) + immediately, rather than leaving a stale open menu the disabled + chip can no longer be pressed to close. */} + {isMenuOpen && !disabled && ( <> {/* Invisible full-viewport layer so any outside press closes the menu, same dismiss approach as ActionSheet's overlay. Sits below the menu @@ -73,15 +106,15 @@ export function WalletChip({ address, onDisconnect }: WalletChipProps) { variant="list" onPress={async () => { if (!onDisconnect) { - setDisconnectMessage('Disconnect should be done in your wallets session') + setDisconnectMessage("Disconnect should be done in your wallet's session") return } setIsMenuOpen(false) await onDisconnect() }} > - - Disconnect + + {disconnectLabel} {disconnectMessage && ( diff --git a/packages/superfluid-campaign-widget/src/widgetRuntimeContract.ts b/packages/superfluid-campaign-widget/src/widgetRuntimeContract.ts index 59cbf93..c33c3e6 100644 --- a/packages/superfluid-campaign-widget/src/widgetRuntimeContract.ts +++ b/packages/superfluid-campaign-widget/src/widgetRuntimeContract.ts @@ -1,4 +1,4 @@ -import type { GoodWidgetConfig, GoodWidgetThemeOverrides } from '@goodwidget/ui' +import type { GoodWidgetConfig, GoodWidgetThemeOverrides, IconName } from '@goodwidget/ui' import type { CitizenClaimWidgetCustodialExecution, CitizenClaimWidgetEnvironment, @@ -194,6 +194,37 @@ export interface SuperfluidCampaignWidgetProps { connectOverride?: () => Promise /** Integrator-owned wallet disconnect flow. */ disconnectOverride?: () => Promise + /** + * Integrator-owned live address (e.g. from a wallet-connection SDK's own + * reactive account hook). See `GoodWidgetProviderProps.addressOverride`. + */ + addressOverride?: string | null + /** + * Integrator-owned live chain id, mirroring `addressOverride`. See + * `GoodWidgetProviderProps.chainIdOverride`. + */ + chainIdOverride?: number | null + /** + * Integrator-owned chain-switch fallback. See + * `GoodWidgetProviderProps.switchChainOverride`. + */ + switchChainOverride?: (chainId: number) => Promise + /** + * Chain ids the passed-down provider can currently execute on. See + * `GoodWidgetProviderProps.availableChainIdsOverride`. Claim execution is + * scoped to this set; balance/entitlement reads are unaffected. + */ + availableChainIdsOverride?: number[] | null + /** + * Label for the wallet chip's disconnect action. See + * `GoodWidgetProviderProps.disconnectLabel`. + */ + disconnectLabel?: string + /** + * Icon for the wallet chip's disconnect action, mirroring `disconnectLabel`. + * See `GoodWidgetProviderProps.disconnectIcon`. + */ + disconnectIcon?: IconName environment?: SuperfluidCampaignWidgetEnvironment themeOverrides?: GoodWidgetThemeOverrides config?: GoodWidgetConfig @@ -219,6 +250,11 @@ export interface SuperfluidCampaignWidgetProps { citizenClaimExecution?: CitizenClaimWidgetCustodialExecution /** Redirect the Claim CTA to GoodWallet instead of embedding CitizenClaimWidget. */ disableClaim?: boolean + /** + * Disables the header's connect-wallet CTA and connected-wallet status chip, + * e.g. while the host page isn't ready to accept a wallet connection yet. + */ + disableWalletButton?: boolean /** * View shown on first render. Defaults to 'content'. Lets Storybook fixtures * and deep links land directly on the leaderboard without a click.