diff --git a/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx b/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx index 12520e8..04aff7f 100644 --- a/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx +++ b/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx @@ -1,6 +1,17 @@ import React from 'react' import type { Meta, StoryObj } from '@storybook/react' -import { StreamingWidget } from '@goodwidget/streaming-widget' +import { + STREAMING_CHAINS, + StreamingWidget, + StreamingWidgetPreview, + type PoolMembershipItem, + type SetStreamFormState, + type StreamingWidgetAdapterActions, + type StreamingWidgetAdapterResult, + type StreamingWidgetAdapterState, + type StreamingWidgetTab, + type StreamListItem, +} from '@goodwidget/streaming-widget' import { YStack } from '@goodwidget/ui' import { getInjectedEip1193Provider, @@ -8,10 +19,199 @@ import { } from '../../fixtures/injectedEip1193' import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193' -// --------------------------------------------------------------------------- -// Story shell — renders the widget inside a fixed-width container that mirrors -// the GoodWalletV2 sidebar / bottom-sheet form factor. -// --------------------------------------------------------------------------- +const DEMO_ADDRESS = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' +const DEMO_RECEIVER = '0x1111111111111111111111111111111111111111' +const DEMO_SENDER = '0x2222222222222222222222222222222222222222' +const DEMO_TOKEN = '0x3333333333333333333333333333333333333333' +const DEMO_POOL = '0x4444444444444444444444444444444444444444' +const DEMO_RESERVE_LOCKER = '0x8888888888888888888888888888888888888888' + +const defaultForm: SetStreamFormState = { + receiver: '', + amount: '', + timeUnit: 'month', + flowRate: null, + validationError: null, +} + +const validForm: SetStreamFormState = { + receiver: DEMO_RECEIVER, + amount: '42', + timeUnit: 'month', + flowRate: 16203703703703n, + validationError: null, +} + +const invalidForm: SetStreamFormState = { + receiver: '0x123', + amount: '0', + timeUnit: 'month', + flowRate: null, + validationError: 'Recipient must be a valid Ethereum address (0x...).', +} + +const sampleStreams: StreamListItem[] = [ + { + id: 'outgoing-demo-stream', + sender: DEMO_ADDRESS, + receiver: DEMO_RECEIVER, + token: DEMO_TOKEN, + flowRate: 38580246913580n, + streamedSoFar: 15000000000000000000n, + createdAtTimestamp: 1767225600, + updatedAtTimestamp: 1767312000, + direction: 'outgoing', + }, + { + id: 'incoming-demo-stream', + sender: DEMO_SENDER, + receiver: DEMO_ADDRESS, + token: DEMO_TOKEN, + flowRate: 19290123456790n, + streamedSoFar: 7800000000000000000n, + createdAtTimestamp: 1767139200, + updatedAtTimestamp: 1767312000, + direction: 'incoming', + }, +] + +// Mirrors the current SDK-backed adapter; diverge this once past-stream history is fetched separately. +const sampleStreamHistory: StreamListItem[] = [ + ...sampleStreams, + { + id: 'history-outgoing-demo-stream-2', + sender: DEMO_ADDRESS, + receiver: '0x5555555555555555555555555555555555555555', + token: DEMO_TOKEN, + flowRate: 9645061728395n, + streamedSoFar: 4300000000000000000n, + createdAtTimestamp: 1767052800, + updatedAtTimestamp: 1767139200, + direction: 'outgoing', + }, + { + id: 'history-incoming-demo-stream-2', + sender: '0x6666666666666666666666666666666666666666', + receiver: DEMO_ADDRESS, + token: DEMO_TOKEN, + flowRate: 5787037037037n, + streamedSoFar: 2200000000000000000n, + createdAtTimestamp: 1766966400, + updatedAtTimestamp: 1767052800, + direction: 'incoming', + }, + { + id: 'history-outgoing-demo-stream-3', + sender: DEMO_ADDRESS, + receiver: '0x7777777777777777777777777777777777777777', + token: DEMO_TOKEN, + flowRate: 3858024691358n, + streamedSoFar: 1400000000000000000n, + createdAtTimestamp: 1766880000, + updatedAtTimestamp: 1766966400, + direction: 'outgoing', + }, +] + +const samplePools: PoolMembershipItem[] = [ + { + poolId: DEMO_POOL, + poolToken: DEMO_TOKEN, + totalUnits: 250000000000000000000n, + claimableAmount: 12500000000000000000n, + claimableAmountError: false, + totalAmountClaimed: 48000000000000000000n, + isConnected: false, + }, +] + +function createAdapter( + stateOverrides: Partial = {}, + actionOverrides: Partial = {}, +): StreamingWidgetAdapterResult { + const baseState: StreamingWidgetAdapterState = { + isConnected: true, + address: DEMO_ADDRESS, + chainId: STREAMING_CHAINS.CELO, + isWrongChain: false, + streams: sampleStreams, + streamsLoading: false, + streamsError: null, + streamHistory: sampleStreamHistory, + streamHistoryLoading: false, + streamHistoryError: null, + pools: samplePools, + poolsLoading: false, + poolsError: null, + superTokenBalance: '128.50', + balanceLoading: false, + balanceError: null, + supReserveBalance: null, + supReserveLockers: [], + supReserveLoading: false, + supReserveError: null, + setStreamForm: defaultForm, + setStreamStatus: 'idle', + setStreamError: null, + setStreamTxHash: null, + poolConnectStatus: {}, + poolConnectError: {}, + poolClaimStatus: {}, + poolClaimError: {}, + } + + const actions: StreamingWidgetAdapterActions = { + connect: async () => {}, + switchChain: async () => {}, + refreshStreams: async () => {}, + refreshStreamHistory: async () => {}, + refreshPools: async () => {}, + refreshBalance: async () => {}, + updateSetStreamForm: () => {}, + submitSetStream: async () => {}, + resetSetStream: () => {}, + connectToPool: async () => {}, + disconnectFromPool: async () => {}, + claimFromPool: async () => {}, + ...actionOverrides, + } + + return { + state: { ...baseState, ...stateOverrides }, + actions, + } +} + +function PreviewStoryShell({ + adapter, + dataTestId, + initialTab = 'streams', + initialStreamsFormOpen = false, +}: { + adapter: StreamingWidgetAdapterResult + dataTestId: string + initialTab?: StreamingWidgetTab + initialStreamsFormOpen?: boolean +}) { + return ( + + + + ) +} + function StreamingWidgetStoryShell({ provider, dataTestId, @@ -20,7 +220,15 @@ function StreamingWidgetStoryShell({ dataTestId: string }) { return ( - + ) @@ -36,22 +244,24 @@ const meta: Meta = { export default meta type Story = StoryObj -// --------------------------------------------------------------------------- -// Injected wallet story — uses window.ethereum if present in the browser -// --------------------------------------------------------------------------- function InjectedWalletStory() { const injectedProvider = getInjectedEip1193Provider() const usableProvider = isInjectedProviderUsable(injectedProvider) if (!usableProvider) { return ( - - No injected wallet found - - Install/enable MetaMask (or another EIP-1193 wallet) in this browser, then refresh - Storybook. The widget supports Celo (G$) and Base (SUP). - - + ) } @@ -63,9 +273,6 @@ function InjectedWalletStory() { ) } -// --------------------------------------------------------------------------- -// Custodial fixture story — uses the pre-configured test wallet from the fixture -// --------------------------------------------------------------------------- function CustodialLocalFixtureStory() { try { const provider = createCustodialEip1193Provider() @@ -77,7 +284,10 @@ function CustodialLocalFixtureStory() { ) } catch (error: unknown) { return ( - + Custodial fixture not configured {error instanceof Error @@ -89,37 +299,294 @@ function CustodialLocalFixtureStory() { } } -// --------------------------------------------------------------------------- -// No-wallet story — demonstrates the connect-prompt state -// --------------------------------------------------------------------------- -function NoWalletStory() { +function PoolClaimableAmountErrorStory() { + const [retrying, setRetrying] = React.useState(false) + return ( - { + setRetrying(true) + }, + }, + )} + dataTestId="StreamingWidget-pool-claimable-amount-error" + initialTab="pools" /> ) } -// --------------------------------------------------------------------------- -// Story exports -// --------------------------------------------------------------------------- - -/** Uses window.ethereum if available — shows the full connected experience. */ export const InjectedWallet: Story = { render: () => , } -/** - * Uses a pre-configured custodial test wallet backed by a local private key. - * Starts on Celo (chain 42220) and uses the development environment. - * The test key has no on-chain streaming history, so streams/pools lists will be empty. - */ export const CustodialLocalFixture: Story = { render: () => , } -/** No provider — shows the wallet-connection prompt for both Streams and Pools tabs. */ export const NoWallet: Story = { - render: () => , + render: () => ( + + ), +} + +export const WrongChain: Story = { + render: () => ( + + ), +} + +export const LoadingState: Story = { + render: () => ( + + ), +} + +export const EmptyState: Story = { + render: () => ( + + ), +} + +export const ErrorState: Story = { + render: () => ( + + ), +} + +export const PopulatedState: Story = { + render: () => ( + + ), +} + +export const CreateUpdateForm: Story = { + render: () => ( + + ), +} + +export const CreateUpdateInvalidInput: Story = { + render: () => ( + + ), +} + +export const CreateUpdatePending: Story = { + render: () => ( + + ), +} + +export const CreateUpdateSuccess: Story = { + render: () => ( + + ), +} + +export const CreateUpdateFailure: Story = { + render: () => ( + + ), +} + +export const PoolClaimState: Story = { + render: () => ( + + ), +} + +export const PoolConnectedState: Story = { + render: () => ( + + ), +} + +// Claim lifecycle stories use isConnected: true so write status badges render correctly. +export const PoolClaimPending: Story = { + render: () => ( + + ), +} + +export const PoolClaimSuccess: Story = { + render: () => ( + + ), +} + +export const PoolClaimError: Story = { + render: () => ( + + ), +} + +export const PoolClaimableAmountError: Story = { + render: () => , +} + +export const BaseSupBalanceAndReserve: Story = { + render: () => ( + + ), +} + +export const NonBaseSupReserveDisabled: Story = { + render: () => ( + + ), } diff --git a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx index 783f5a0..424a41c 100644 --- a/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx +++ b/packages/citizen-claim-widget/src/CitizenClaimWidget.tsx @@ -172,7 +172,6 @@ function Countdown({ nextClaim }: { nextClaim: Date }) { useEffect(() => { const id = setInterval(() => setTimeLeft(getTimeLeft()), 1000) return () => clearInterval(id) - // eslint-disable-next-line react-hooks/exhaustive-deps }, [nextClaim]) const h = Math.floor(timeLeft / 3600) diff --git a/packages/citizen-claim-widget/src/adapter.ts b/packages/citizen-claim-widget/src/adapter.ts index 4a0b7c3..1b326f3 100644 --- a/packages/citizen-claim-widget/src/adapter.ts +++ b/packages/citizen-claim-widget/src/adapter.ts @@ -420,7 +420,6 @@ export function useCitizenClaimAdapter( // Auto-refresh claim status whenever wallet connection or chain changes useEffect(() => { void loadClaimStatus() - // eslint-disable-next-line react-hooks/exhaustive-deps }, [isConnected, address, chainId]) // --------------------------------------------------------------------------- diff --git a/packages/core/src/provider.tsx b/packages/core/src/provider.tsx index 0aa38f2..21d5ed5 100644 --- a/packages/core/src/provider.tsx +++ b/packages/core/src/provider.tsx @@ -24,7 +24,7 @@ export interface WalletContextValue extends WalletState { connect: () => Promise } -export interface HostContextValue extends HostState {} +export type HostContextValue = HostState export interface GoodWidgetContextValue extends GoodWidgetState { connect: () => Promise diff --git a/packages/streaming-widget/src/StreamingWidget.tsx b/packages/streaming-widget/src/StreamingWidget.tsx index f837009..8439633 100644 --- a/packages/streaming-widget/src/StreamingWidget.tsx +++ b/packages/streaming-widget/src/StreamingWidget.tsx @@ -6,6 +6,7 @@ import { Card, Heading, Text, + Anchor, Button, ButtonText, Spinner, @@ -20,6 +21,7 @@ import { AddressDisplay, TokenAmount, WidgetTabs, + formatDisplayAmount, } from '@goodwidget/ui' import type { Address } from 'viem' import { formatUnits } from 'viem' @@ -31,10 +33,14 @@ import type { StreamListItem, PoolMembershipItem, StreamTimeUnit, + StreamingWidgetAdapterResult, WriteStatus, } from './widgetRuntimeContract' import { STREAMING_CHAINS } from './widgetRuntimeContract' +type StreamingWidgetAdapterState = StreamingWidgetAdapterResult['state'] +type StreamingWidgetAdapterActions = StreamingWidgetAdapterResult['actions'] + // --------------------------------------------------------------------------- // Named styled sub-components — participate in the component sub-theme system. // Integrators can override via themeOverrides. @@ -111,12 +117,12 @@ function formatFlowRateDisplay(flowRate: bigint, decimals = 18): string { if (flowRate === 0n) return '0' // Convert wei/s → per-month amount for display const perMonth = flowRate * BigInt(30 * 24 * 60 * 60) - return formatUnits(perMonth, decimals) + return formatDisplayAmount(formatUnits(perMonth, decimals)) } /** Formats a unix timestamp (seconds) to a short locale date string */ function formatTimestamp(unixSeconds: number): string { - if (!unixSeconds) return '—' + if (!unixSeconds) return 'N/A' return new Date(unixSeconds * 1000).toLocaleDateString('en-US', { month: 'short', day: 'numeric', @@ -131,12 +137,30 @@ function chainName(chainId: number): string { return `Chain ${chainId}` } +function tokenSymbol(chainId: number | null): 'G$' | 'SUP' { + return chainId === STREAMING_CHAINS.BASE ? 'SUP' : 'G$' +} + +function formatWeiAmount(amount: bigint): string { + return formatDisplayAmount(formatUnits(amount, 18)) +} + +function superfluidExplorerAccountUrl(chainId: number | null, address: Address): string { + const networkSlug = chainId === STREAMING_CHAINS.BASE ? 'base-mainnet' : 'celo' + return `https://explorer.superfluid.org/${networkSlug}/accounts/${address}` +} + // --------------------------------------------------------------------------- // Write-status badge helper // --------------------------------------------------------------------------- function WriteStatusBadge({ status }: { status: WriteStatus }) { if (status === 'idle') return null - if (status === 'pending') return + if (status === 'pending') + return ( + + Pending + + ) if (status === 'success') return ( @@ -209,6 +233,7 @@ function WalletGate({ // --------------------------------------------------------------------------- function SetStreamForm({ form, + token, status, error, txHash, @@ -216,11 +241,12 @@ function SetStreamForm({ onSubmit, onReset, }: { - form: ReturnType['state']['setStreamForm'] + form: StreamingWidgetAdapterState['setStreamForm'] + token: 'G$' | 'SUP' status: WriteStatus error: string | null txHash: string | null - onUpdate: (partial: Partial) => void + onUpdate: (partial: Partial) => void onSubmit: () => void onReset: () => void }) { @@ -228,13 +254,13 @@ function SetStreamForm({ return ( - {form.receiver ? 'Update Stream' : 'Create Stream'} + Create / Update Stream {/* Recipient address */} Recipient address onUpdate({ receiver: v })} editable={!isSubmitting} @@ -244,7 +270,7 @@ function SetStreamForm({ {/* Amount + time unit */} - Amount + Amount ({token}) 0n && ( - ≈ {formatUnits(form.flowRate, 18)} tokens/s + About {formatWeiAmount(form.flowRate)} {token}/s )} @@ -284,9 +310,14 @@ function SetStreamForm({ {error} )} + {status === 'pending' && ( + + Transaction pending... + + )} {status === 'success' && txHash && ( - Stream set! Tx: {txHash.slice(0, 10)}… + Stream set! Tx: {txHash.slice(0, 10)}... )} @@ -312,7 +343,7 @@ function SetStreamForm({ // --------------------------------------------------------------------------- // Single stream row // --------------------------------------------------------------------------- -function StreamCard({ stream }: { stream: StreamListItem }) { +function StreamCard({ stream, token }: { stream: StreamListItem; token: 'G$' | 'SUP' }) { const counterparty = stream.direction === 'outgoing' ? stream.receiver : stream.sender const flowPerMonth = formatFlowRateDisplay(stream.flowRate) @@ -321,7 +352,7 @@ function StreamCard({ stream }: { stream: StreamListItem }) { - {stream.direction === 'incoming' ? '↓ Incoming' : '↑ Outgoing'} + {stream.direction === 'incoming' ? 'Incoming' : 'Outgoing'} Since {formatTimestamp(stream.createdAtTimestamp)} @@ -334,9 +365,12 @@ function StreamCard({ stream }: { stream: StreamListItem }) { Flow rate - - {flowPerMonth}/mo - + + + + /mo + + {stream.streamedSoFar > 0n && ( @@ -344,7 +378,7 @@ function StreamCard({ stream }: { stream: StreamListItem }) { Streamed so far - {formatUnits(stream.streamedSoFar, 18)} + )} @@ -360,16 +394,21 @@ const DIRECTION_LABELS: Record = { function StreamsTab({ state, actions, + initialFormOpen = false, }: { - state: ReturnType['state'] - actions: ReturnType['actions'] + state: StreamingWidgetAdapterState + actions: StreamingWidgetAdapterActions + initialFormOpen?: boolean }) { const [direction, setDirection] = useState('all') - const [showForm, setShowForm] = useState(false) + const [showForm, setShowForm] = useState(initialFormOpen) const filteredStreams = state.streams.filter( (s) => direction === 'all' || s.direction === direction, ) + const emptyStreamsMessage = + direction === 'all' ? 'No streams found.' : `No ${direction} streams found.` + const activeToken = tokenSymbol(state.chainId) return ( @@ -383,6 +422,7 @@ function StreamsTab({ {showForm && ( + Active streams + {/* Direction filter */} {(['all', 'incoming', 'outgoing'] as StreamDirection[]).map((d) => ( @@ -414,7 +456,7 @@ function StreamsTab({ {state.streamsLoading && ( - Loading streams… + Loading streams... )} @@ -430,7 +472,7 @@ function StreamsTab({ {!state.streamsLoading && !state.streamsError && filteredStreams.length === 0 && ( - No {direction === 'all' ? '' : direction} streams found. + {emptyStreamsMessage} + + + {state.streamHistoryLoading && ( + + + Loading stream history... + + )} + + {!state.streamHistoryLoading && state.streamHistoryError && ( + + {state.streamHistoryError} + + + )} + + {!state.streamHistoryLoading && + !state.streamHistoryError && + recentStreams.length === 0 && ( + + + No stream history found. + + + )} + + {!state.streamHistoryLoading && + !state.streamHistoryError && + recentStreams.map((stream) => ( + + ))} + + {!state.streamHistoryLoading && !state.streamHistoryError && hasMoreHistory && ( + + )} ) } @@ -450,18 +559,30 @@ function StreamsTab({ // --------------------------------------------------------------------------- function PoolCard({ pool, + token, connectStatus, connectError, + claimStatus, + claimError, onConnect, onDisconnect, + onClaim, + onRetryClaimable, }: { pool: PoolMembershipItem + token: 'G$' | 'SUP' connectStatus: WriteStatus connectError: string | null + claimStatus: WriteStatus + claimError: string | null onConnect: (poolAddress: Address) => void onDisconnect: (poolAddress: Address) => void + onClaim: (poolAddress: Address) => void + onRetryClaimable: () => void }) { - const isPending = connectStatus === 'pending' + const isConnectPending = connectStatus === 'pending' + const isClaimPending = claimStatus === 'pending' + const canClaim = !pool.isConnected && pool.claimableAmount > 0n && !pool.claimableAmountError return ( @@ -472,11 +593,18 @@ function PoolCard({ + + + Claimable + + + + Total claimed - {formatUnits(pool.totalAmountClaimed, 18)} + {connectError && ( @@ -484,17 +612,57 @@ function PoolCard({ {connectError} )} + {claimError && ( + + {claimError} + + )} + {pool.claimableAmountError && ( + + + Could not load claimable amount. + + + + )} - - + {pool.isConnected ? ( - + <> + + + ) : ( - + <> + + + + + )} @@ -508,15 +676,17 @@ function PoolsTab({ state, actions, }: { - state: ReturnType['state'] - actions: ReturnType['actions'] + state: StreamingWidgetAdapterState + actions: StreamingWidgetAdapterActions }) { + const activeToken = tokenSymbol(state.chainId) + return ( {state.poolsLoading && ( - Loading pool memberships… + Loading pool memberships... )} @@ -546,10 +716,15 @@ function PoolsTab({ ))} @@ -563,10 +738,11 @@ function BalancesTab({ state, actions, }: { - state: ReturnType['state'] - actions: ReturnType['actions'] + state: StreamingWidgetAdapterState + actions: StreamingWidgetAdapterActions }) { const isOnBase = state.chainId === STREAMING_CHAINS.BASE + const activeToken = tokenSymbol(state.chainId) return ( @@ -575,7 +751,7 @@ function BalancesTab({ Super Token Balance @@ -589,7 +765,7 @@ function BalancesTab({ {!state.balanceLoading && !state.balanceError && state.superTokenBalance !== null && ( @@ -606,7 +782,7 @@ function BalancesTab({ {isOnBase ? ( - SUP Reserve (Staked) + SUP Reserve {state.supReserveLoading && } @@ -621,8 +797,44 @@ function BalancesTab({ )} - SUP tokens locked as reserve on Base. + SUP held in reserve lockers on Base. + + {!state.supReserveLoading && + !state.supReserveError && + state.supReserveLockers.map((locker) => ( + + + + Reserve locker + + + + + + Available + + + + + + Staked + + + + + Open in Superfluid Explorer + + + ))} ) : ( @@ -648,8 +860,24 @@ function StreamingWidgetInner({ environment: StreamingWidgetProps['environment'] apiKey?: string }) { - const { state, actions } = useStreamingAdapter({ environment, apiKey }) - const [activeTab, setActiveTab] = useState('streams') + const adapter = useStreamingAdapter({ environment, apiKey }) + + return +} + +interface StreamingWidgetViewProps { + adapter: StreamingWidgetAdapterResult + initialTab?: StreamingWidgetTab + initialStreamsFormOpen?: boolean +} + +function StreamingWidgetView({ + adapter, + initialTab = 'streams', + initialStreamsFormOpen = false, +}: StreamingWidgetViewProps) { + const { state, actions } = adapter + const [activeTab, setActiveTab] = useState(initialTab) const walletGate = ( - {activeTab === 'streams' && } + {activeTab === 'streams' && ( + + )} + {activeTab === 'history' && } {activeTab === 'pools' && } {activeTab === 'balances' && } ) return ( - + { + adapter: StreamingWidgetAdapterResult + initialTab?: StreamingWidgetTab + initialStreamsFormOpen?: boolean +} + +export function StreamingWidgetPreview({ + adapter, + initialTab, + initialStreamsFormOpen, + themeOverrides, + config, + defaultTheme = 'light', +}: StreamingWidgetPreviewProps) { + return ( + + + + + ) +} + // --------------------------------------------------------------------------- // Public component // --------------------------------------------------------------------------- diff --git a/packages/streaming-widget/src/adapter.ts b/packages/streaming-widget/src/adapter.ts index 313ce59..9517152 100644 --- a/packages/streaming-widget/src/adapter.ts +++ b/packages/streaming-widget/src/adapter.ts @@ -30,6 +30,26 @@ import type { WriteStatus, } from './widgetRuntimeContract' +const GDA_POOL_CLAIM_ABI = [ + { + type: 'function', + name: 'getClaimableNow', + inputs: [{ name: 'memberAddr', type: 'address' }], + outputs: [ + { name: 'claimableBalance', type: 'int256' }, + { name: 'timestamp', type: 'uint256' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + name: 'claimAll', + inputs: [], + outputs: [{ name: '', type: 'bool' }], + stateMutability: 'nonpayable', + }, +] as const + // --------------------------------------------------------------------------- // Chain descriptors for Superfluid-supported chains (Celo and Base) // --------------------------------------------------------------------------- @@ -124,10 +144,13 @@ function toStreamListItem(stream: StreamInfo, address: Address): StreamListItem // Derive PoolMembershipItem from the SDK GDAPool // --------------------------------------------------------------------------- function toPoolMembershipItem(pool: GDAPool): PoolMembershipItem { + const poolWithClaimable = pool as GDAPool & { claimableAmount?: bigint } return { poolId: pool.id, poolToken: pool.token, totalUnits: pool.totalUnits, + claimableAmount: poolWithClaimable.claimableAmount ?? 0n, + claimableAmountError: false, totalAmountClaimed: pool.totalAmountClaimed, isConnected: pool.isConnected ?? false, } @@ -148,7 +171,7 @@ function validateSetStreamForm(form: SetStreamFormState): SetStreamFormState { return { ...form, flowRate: null, - validationError: 'Recipient must be a valid Ethereum address (0x…).', + validationError: 'Recipient must be a valid Ethereum address (0x...).', } } @@ -178,6 +201,9 @@ export function useStreamingAdapter({ const [streams, setStreams] = useState([]) const [streamsLoading, setStreamsLoading] = useState(false) const [streamsError, setStreamsError] = useState(null) + const [streamHistory, setStreamHistory] = useState([]) + const [streamHistoryLoading, setStreamHistoryLoading] = useState(false) + const [streamHistoryError, setStreamHistoryError] = useState(null) // --- pools state --- const [pools, setPools] = useState([]) @@ -191,6 +217,9 @@ export function useStreamingAdapter({ // --- SUP reserve state (Base only) --- const [supReserveBalance, setSupReserveBalance] = useState(null) + const [supReserveLockers, setSupReserveLockers] = useState< + StreamingWidgetAdapterState['supReserveLockers'] + >([]) const [supReserveLoading, setSupReserveLoading] = useState(false) const [supReserveError, setSupReserveError] = useState(null) @@ -203,6 +232,8 @@ export function useStreamingAdapter({ // --- pool connect/disconnect state keyed by pool address --- const [poolConnectStatus, setPoolConnectStatus] = useState>({}) const [poolConnectError, setPoolConnectError] = useState>({}) + const [poolClaimStatus, setPoolClaimStatus] = useState>({}) + const [poolClaimError, setPoolClaimError] = useState>({}) // Chain validity const isWrongChain = !!chainId && !isSupportedChain(chainId) @@ -259,16 +290,23 @@ export function useStreamingAdapter({ setStreamsLoading(true) setStreamsError(null) + setStreamHistoryLoading(true) + setStreamHistoryError(null) try { const result = await streamingSDK.getActiveStreams({ account: address as Address, direction: 'all', }) - setStreams(result.map((s) => toStreamListItem(s, address as Address))) + const normalizedStreams = result.map((s) => toStreamListItem(s, address as Address)) + setStreams(normalizedStreams) + setStreamHistory(normalizedStreams) } catch (err) { - setStreamsError(humanReadableError(err)) + const message = humanReadableError(err) + setStreamsError(message) + setStreamHistoryError(message) } finally { setStreamsLoading(false) + setStreamHistoryLoading(false) } }, [streamingSDK, address]) @@ -282,13 +320,39 @@ export function useStreamingAdapter({ setPoolsError(null) try { const result = await gdaSDK.getDistributionPools(address as Address) - setPools(result.map(toPoolMembershipItem)) + const normalizedPools = result.map(toPoolMembershipItem) + if (!viemClients) { + setPools(normalizedPools) + return + } + + const poolsWithClaimable = await Promise.all( + normalizedPools.map(async (pool) => { + try { + const [claimableAmount] = await viemClients.publicClient.readContract({ + address: pool.poolId, + abi: GDA_POOL_CLAIM_ABI, + functionName: 'getClaimableNow', + args: [address as Address], + }) + + return { + ...pool, + claimableAmount: claimableAmount > 0n ? claimableAmount : 0n, + claimableAmountError: false, + } + } catch { + return { ...pool, claimableAmountError: true } + } + }), + ) + setPools(poolsWithClaimable) } catch (err) { setPoolsError(humanReadableError(err)) } finally { setPoolsLoading(false) } - }, [gdaSDK, address]) + }, [gdaSDK, address, viemClients]) // --------------------------------------------------------------------------- // Fetch Super Token balance @@ -312,8 +376,9 @@ export function useStreamingAdapter({ // Fetch SUP reserve balance (Base only) // --------------------------------------------------------------------------- const fetchSupReserve = useCallback(async () => { - if (!subgraphClient || !address || chainId !== SupportedChains.BASE) { + if (!subgraphClient || !streamingSDK || !address || chainId !== SupportedChains.BASE) { setSupReserveBalance(null) + setSupReserveLockers([]) return } @@ -321,14 +386,31 @@ export function useStreamingAdapter({ setSupReserveError(null) try { const lockers = await subgraphClient.querySUPReserves(address as Address) - const total = lockers.reduce((sum, l) => sum + l.stakedBalance, 0n) + const lockersWithBalances = await Promise.all( + lockers.map(async (locker) => { + const unstakedBalance = await streamingSDK.getSuperTokenBalance( + locker.id as Address, + 'SUP', + ) + return { + address: locker.id as Address, + stakedBalance: locker.stakedBalance, + unstakedBalance, + totalBalance: locker.stakedBalance + unstakedBalance, + } + }), + ) + const total = lockersWithBalances.reduce((sum, locker) => sum + locker.totalBalance, 0n) + setSupReserveLockers(lockersWithBalances) setSupReserveBalance(formatUnits(total, 18)) } catch (err) { + setSupReserveBalance(null) + setSupReserveLockers([]) setSupReserveError(humanReadableError(err)) } finally { setSupReserveLoading(false) } - }, [subgraphClient, address, chainId]) + }, [subgraphClient, streamingSDK, address, chainId]) // --------------------------------------------------------------------------- // Auto-fetch on wallet/chain change @@ -344,9 +426,15 @@ export function useStreamingAdapter({ if (!isConnected || !address || isWrongChain) { setStreams([]) + setStreamHistory([]) setPools([]) setSuperTokenBalance(null) setSupReserveBalance(null) + setSupReserveLockers([]) + setPoolConnectStatus({}) + setPoolConnectError({}) + setPoolClaimStatus({}) + setPoolClaimError({}) return } @@ -453,6 +541,34 @@ export function useStreamingAdapter({ [gdaSDK, fetchPools], ) + const claimFromPool = useCallback( + async (poolAddress: Address) => { + if (!viemClients || !address) return + + setPoolClaimStatus((prev) => ({ ...prev, [poolAddress]: 'pending' })) + setPoolClaimError((prev) => ({ ...prev, [poolAddress]: null })) + + try { + const hash = await viemClients.walletClient.writeContract({ + account: address as Address, + address: poolAddress, + abi: GDA_POOL_CLAIM_ABI, + functionName: 'claimAll', + }) + await viemClients.publicClient.waitForTransactionReceipt({ hash }) + setPoolClaimStatus((prev) => ({ ...prev, [poolAddress]: 'success' })) + void fetchPools() + } catch (err) { + setPoolClaimStatus((prev) => ({ ...prev, [poolAddress]: 'error' })) + setPoolClaimError((prev) => ({ + ...prev, + [poolAddress]: humanReadableError(err), + })) + } + }, + [viemClients, address, fetchPools], + ) + // --------------------------------------------------------------------------- // Chain switch via EIP-1193 // --------------------------------------------------------------------------- @@ -480,6 +596,9 @@ export function useStreamingAdapter({ streams, streamsLoading, streamsError, + streamHistory, + streamHistoryLoading, + streamHistoryError, pools, poolsLoading, poolsError, @@ -487,6 +606,7 @@ export function useStreamingAdapter({ balanceLoading, balanceError, supReserveBalance, + supReserveLockers, supReserveLoading, supReserveError, setStreamForm, @@ -495,6 +615,8 @@ export function useStreamingAdapter({ setStreamTxHash, poolConnectStatus, poolConnectError, + poolClaimStatus, + poolClaimError, }), [ isConnected, @@ -504,6 +626,9 @@ export function useStreamingAdapter({ streams, streamsLoading, streamsError, + streamHistory, + streamHistoryLoading, + streamHistoryError, pools, poolsLoading, poolsError, @@ -511,6 +636,7 @@ export function useStreamingAdapter({ balanceLoading, balanceError, supReserveBalance, + supReserveLockers, supReserveLoading, supReserveError, setStreamForm, @@ -519,6 +645,8 @@ export function useStreamingAdapter({ setStreamTxHash, poolConnectStatus, poolConnectError, + poolClaimStatus, + poolClaimError, ], ) @@ -527,6 +655,7 @@ export function useStreamingAdapter({ connect, switchChain, refreshStreams: fetchStreams, + refreshStreamHistory: fetchStreams, refreshPools: fetchPools, refreshBalance: fetchBalance, updateSetStreamForm, @@ -534,6 +663,7 @@ export function useStreamingAdapter({ resetSetStream, connectToPool, disconnectFromPool, + claimFromPool, }), [ connect, @@ -546,6 +676,7 @@ export function useStreamingAdapter({ resetSetStream, connectToPool, disconnectFromPool, + claimFromPool, ], ) diff --git a/packages/streaming-widget/src/index.ts b/packages/streaming-widget/src/index.ts index 77266d2..2981ad4 100644 --- a/packages/streaming-widget/src/index.ts +++ b/packages/streaming-widget/src/index.ts @@ -7,6 +7,7 @@ export type { StreamTimeUnit, StreamListItem, PoolMembershipItem, + SupReserveLockerItem, SetStreamFormState, WriteStatus, StreamingWidgetAdapterState, @@ -20,4 +21,5 @@ export { useStreamingAdapter } from './adapter' export type { UseStreamingAdapterOptions } from './adapter' // Widget component -export { StreamingWidget } from './StreamingWidget' +export { StreamingWidget, StreamingWidgetPreview } from './StreamingWidget' +export type { StreamingWidgetPreviewProps } from './StreamingWidget' diff --git a/packages/streaming-widget/src/widgetRuntimeContract.ts b/packages/streaming-widget/src/widgetRuntimeContract.ts index 9f1900c..f281390 100644 --- a/packages/streaming-widget/src/widgetRuntimeContract.ts +++ b/packages/streaming-widget/src/widgetRuntimeContract.ts @@ -29,7 +29,7 @@ export type StreamTimeUnit = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'mo // --------------------------------------------------------------------------- // Widget tab IDs // --------------------------------------------------------------------------- -export type StreamingWidgetTab = 'streams' | 'pools' | 'balances' +export type StreamingWidgetTab = 'streams' | 'history' | 'pools' | 'balances' // --------------------------------------------------------------------------- // Operation lifecycle status for write actions @@ -62,11 +62,25 @@ export interface PoolMembershipItem { poolId: Address poolToken: Address totalUnits: bigint + /** Claimable incoming distribution amount in wei, when exposed by the data source */ + claimableAmount: bigint + /** True when the claimable amount read fails and should be retried */ + claimableAmountError: boolean totalAmountClaimed: bigint /** Whether this account has actively connected to the pool distribution */ isConnected: boolean } +// --------------------------------------------------------------------------- +// SUP reserve locker displayed on Base +// --------------------------------------------------------------------------- +export interface SupReserveLockerItem { + address: Address + stakedBalance: bigint + unstakedBalance: bigint + totalBalance: bigint +} + // --------------------------------------------------------------------------- // Form state for create/update stream // --------------------------------------------------------------------------- @@ -96,6 +110,9 @@ export interface StreamingWidgetAdapterState { streams: StreamListItem[] streamsLoading: boolean streamsError: string | null + streamHistory: StreamListItem[] + streamHistoryLoading: boolean + streamHistoryError: string | null /** GDA pool memberships for the connected address */ pools: PoolMembershipItem[] @@ -109,6 +126,7 @@ export interface StreamingWidgetAdapterState { /** SUP reserve data — only populated on Base */ supReserveBalance: string | null + supReserveLockers: SupReserveLockerItem[] supReserveLoading: boolean supReserveError: string | null @@ -121,6 +139,9 @@ export interface StreamingWidgetAdapterState { /** Pool connect/disconnect write status keyed by pool address */ poolConnectStatus: Record poolConnectError: Record + /** Pool claim write status keyed by pool address */ + poolClaimStatus: Record + poolClaimError: Record } // --------------------------------------------------------------------------- @@ -130,6 +151,7 @@ export interface StreamingWidgetAdapterActions { connect: () => Promise switchChain: (chainId: number) => Promise refreshStreams: () => Promise + refreshStreamHistory: () => Promise refreshPools: () => Promise refreshBalance: () => Promise @@ -144,6 +166,8 @@ export interface StreamingWidgetAdapterActions { connectToPool: (poolAddress: Address) => Promise /** Disconnect wallet from a GDA pool */ disconnectFromPool: (poolAddress: Address) => Promise + /** Claim all currently claimable distributions from a GDA pool */ + claimFromPool: (poolAddress: Address) => Promise } export interface StreamingWidgetAdapterResult { diff --git a/packages/ui/src/components/Button.tsx b/packages/ui/src/components/Button.tsx index 09fafe4..24874ed 100644 --- a/packages/ui/src/components/Button.tsx +++ b/packages/ui/src/components/Button.tsx @@ -32,8 +32,8 @@ export const ButtonFrame = createComponent(Stack, { backgroundColor: '$background', // GoodWalletV2 brand: solid buttons use pill radius by default borderRadius: '$full', - paddingHorizontal: '$4', - height: '$10', + paddingHorizontal: '$3', + height: '$7', gap: '$2', cursor: 'pointer', borderWidth: 0, @@ -109,9 +109,9 @@ export const ButtonFrame = createComponent(Stack, { // Standard interactive sizes size: { - sm: { height: '$8', paddingHorizontal: '$3', gap: '$1' }, - md: { height: '$10', paddingHorizontal: '$4', gap: '$2' }, - lg: { height: '$11', paddingHorizontal: '$5', gap: '$2' }, + sm: { height: '$6', paddingHorizontal: '$2', gap: '$1' }, + md: { height: '$7', paddingHorizontal: '$3', gap: '$2' }, + lg: { height: '$8', paddingHorizontal: '$4', gap: '$2' }, }, // Icon-only sizes (transparent bg, square, no padding) diff --git a/packages/ui/src/components/TokenAmount.tsx b/packages/ui/src/components/TokenAmount.tsx index fd6ebe3..4326128 100644 --- a/packages/ui/src/components/TokenAmount.tsx +++ b/packages/ui/src/components/TokenAmount.tsx @@ -51,19 +51,31 @@ interface TokenAmountProps { decimals?: number variant?: 'secondary' useAbbreviations?: boolean + maxSignificantDigits?: number } -const getDecimals = (value: number): number => { - const absValue = Math.abs(value) - if (absValue >= 1 || absValue === 0) return 2 - if (absValue >= 0.1) return 3 - if (absValue >= 0.01) return 4 - if (absValue >= 0.001) return 5 - if (absValue >= 0.0001) return 6 - if (absValue >= 0.00001) return 7 - if (absValue >= 0.000001) return 8 - if (absValue >= 0.0000001) return 9 - return 10 +export function formatDisplayAmount( + amount: string | number, + { + decimals = 2, + useAbbreviations = true, + maxSignificantDigits = 6, + }: { + decimals?: number + useAbbreviations?: boolean + maxSignificantDigits?: number + } = {}, +): string { + const amountNumber = typeof amount === 'number' ? amount : Number(amount) + if (!Number.isFinite(amountNumber)) return String(amount) + if (!useAbbreviations) return amountNumber.toFixed(decimals) + + return new Intl.NumberFormat('en-US', { + maximumSignificantDigits: maxSignificantDigits, + minimumFractionDigits: 0, + notation: Math.abs(amountNumber) >= 1_000_000 ? 'compact' : 'standard', + useGrouping: true, + }).format(amountNumber) } export function TokenAmount({ @@ -73,19 +85,15 @@ export function TokenAmount({ decimals = 2, variant, useAbbreviations = true, + maxSignificantDigits = 6, }: TokenAmountProps) { const fontSize = { sm: '$3', md: '$5', lg: '$7', xl: '$9' } as const - const amountNumber = typeof amount === 'number' ? amount : parseFloat(amount) - const formatted = useAbbreviations - ? new Intl.NumberFormat('en-US', { - style: 'decimal', - minimumFractionDigits: 0, - maximumFractionDigits: getDecimals(amountNumber), - useGrouping: true, - notation: 'compact', - }).format(amountNumber) - : amountNumber.toFixed(decimals) + const formatted = formatDisplayAmount(amount, { + decimals, + useAbbreviations, + maxSignificantDigits, + }) return ( diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 27a33dc..32225c5 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -86,7 +86,7 @@ export type { DialogConfig, DialogStatus } from './components/Dialog' // Web3 export { AddressDisplay } from './components-test/AddressDisplay' -export { TokenAmount } from './components/TokenAmount' +export { TokenAmount, formatDisplayAmount } from './components/TokenAmount' export { TransactionButton } from './components-test/TransactionButton' export { ChainBadge } from './components-test/ChainBadge' export { WalletInfo } from './components-test/WalletInfo' diff --git a/tests/widgets/streaming-widget/states.spec.ts b/tests/widgets/streaming-widget/states.spec.ts index ea576b5..c22fed2 100644 --- a/tests/widgets/streaming-widget/states.spec.ts +++ b/tests/widgets/streaming-widget/states.spec.ts @@ -1,285 +1,227 @@ -/** - * states.spec.ts — Playwright smoke tests for the StreamingWidget. - * - * Tests use the NoWallet story (no provider) to verify the widget shell renders and - * surfaces the connect-wallet prompt, and the CustodialLocalFixture story to verify - * the connected flow on Celo with an empty-history test wallet. - * - * Story URLs: - * /iframe.html?id=widgets-streamingwidget--no-wallet&viewMode=story - * /iframe.html?id=widgets-streamingwidget--custodial-local-fixture&viewMode=story - * - * Browser flags (set globally in playwright.config.ts): - * --disable-web-security : allows viem fetch calls from localhost to external HTTPS RPC - * --ignore-certificate-errors: allows Chromium to accept RPC endpoint TLS certs - * - * Running: - * pnpm storybook (in one terminal) - * pnpm test:demo (in another terminal) - * - * Artifact output: - * tests/widgets/streaming-widget/test-results/ (widget screenshot evidence) - * test-results/ (Playwright traces/videos/attachments) - */ import { test, expect, type Page } from '@playwright/test' -const NO_WALLET_STORY_URL = - '/iframe.html?id=widgets-streamingwidget--no-wallet&viewMode=story' +const STORY_PREFIX = '/iframe.html?id=widgets-streamingwidget--' -const CUSTODIAL_STORY_URL = - '/iframe.html?id=widgets-streamingwidget--custodial-local-fixture&viewMode=story' +function storyUrl(storyId: string): string { + return `${STORY_PREFIX}${storyId}&viewMode=story` +} -/** Navigate directly to the story iframe (bypasses Storybook shell for speed). */ -async function gotoStory(page: Page, url: string): Promise { - await page.goto(url) +async function gotoStory(page: Page, storyId: string): Promise { + await page.goto(storyUrl(storyId)) await page.waitForLoadState('domcontentloaded') + await page.waitForFunction(() => document.body.innerText.trim().length > 0) +} + +async function bodyText(page: Page): Promise { + return page.evaluate(() => document.body.innerText) } -/** Poll the page until any of the given text patterns appears in the body. */ -async function waitForText( - page: Page, - patterns: string[], - timeoutMs = 30_000, -): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - const text = await page.evaluate(() => document.body.innerText) - for (const p of patterns) { - if (text.includes(p)) return p +async function expectBodyToContain(page: Page, patterns: Array) { + const text = await bodyText(page) + for (const pattern of patterns) { + if (typeof pattern === 'string') { + expect(text).toContain(pattern) + } else { + expect(text).toMatch(pattern) } - await page.waitForTimeout(500) } - return '' } -// ─── no-wallet state ───────────────────────────────────────────────────────── -test('StreamingWidget shows connect-wallet prompt when no provider is given', async ({ page }) => { - await gotoStory(page, NO_WALLET_STORY_URL) - - // The widget should render the tab bar and the connect-wallet prompt - const matched = await waitForText(page, ['Connect Wallet', 'not connected', 'Streams'], 20_000) - expect(matched, 'Expected connect-wallet prompt').toBeTruthy() - - const bodyText = await page.evaluate(() => document.body.innerText) - expect(bodyText).toMatch(/Connect Wallet|not connected/i) - +async function saveScreenshot(page: Page, name: string) { await page.screenshot({ - path: 'tests/widgets/streaming-widget/test-results/sw-01-no-wallet.png', + path: `tests/widgets/streaming-widget/test-results/${name}.png`, fullPage: true, }) -}) - -// ─── tab navigation ─────────────────────────────────────────────────────────── -test('StreamingWidget renders Streams, Pools, Balances tabs', async ({ page }) => { - await gotoStory(page, NO_WALLET_STORY_URL) - - const matched = await waitForText(page, ['Streams', 'Pools', 'Balances'], 20_000) - expect(matched, 'Tab bar must render').toBeTruthy() +} - const bodyText = await page.evaluate(() => document.body.innerText) - expect(bodyText).toContain('Streams') - expect(bodyText).toContain('Pools') - expect(bodyText).toContain('Balances') +test('StreamingWidget shows the disconnected wallet gate', async ({ page }) => { + await gotoStory(page, 'no-wallet') - await page.screenshot({ - path: 'tests/widgets/streaming-widget/test-results/sw-02-tabs-visible.png', - fullPage: true, - }) + await expectBodyToContain(page, ['Wallet not connected', 'Connect Wallet']) + await saveScreenshot(page, 'sw-01-no-wallet') }) -// ─── wrong-chain state ──────────────────────────────────────────────────────── -test('StreamingWidget shows wrong-chain prompt when wallet is on unsupported chain', async ({ - page, -}) => { - // Inject a minimal EIP-1193 provider that reports an unsupported chain (chain 1 = Ethereum mainnet) - await gotoStory(page, NO_WALLET_STORY_URL) - - // Evaluate a mock provider in the page context to simulate wrong chain - await page.evaluate(() => { - const mockProvider = { - on: () => {}, - removeListener: () => {}, - request: async ({ method }: { method: string }) => { - if (method === 'eth_accounts' || method === 'eth_requestAccounts') - return ['0x1234567890123456789012345678901234567890'] - if (method === 'eth_chainId') return '0x1' // Ethereum mainnet (unsupported) - if (method === 'net_version') return '1' - return null - }, - } - ;(window as unknown as Record).__mockProvider = mockProvider - }) +test('StreamingWidget renders tab navigation and switches views', async ({ page }) => { + await gotoStory(page, 'populated-state') - // The story renders with no provider so we can't easily inject — just verify tab bar renders - const matched = await waitForText(page, ['Streams', 'Pools', 'Balances'], 15_000) - expect(matched).toBeTruthy() + await expectBodyToContain(page, ['Streams', 'History', 'Pools', 'Balances', 'Active streams']) - await page.screenshot({ - path: 'tests/widgets/streaming-widget/test-results/sw-03-no-wallet-tabs.png', - fullPage: true, - }) -}) + await page.getByText('History').first().click() + await expectBodyToContain(page, ['Stream history', 'Show more']) -// ─── custodial: loading + empty states ──────────────────────────────────────── -test('StreamingWidget custodial fixture — Streams tab shows loading then empty state', async ({ - page, - browserName, -}) => { - test.skip( - browserName !== 'chromium', - 'Live RPC test requires --disable-web-security / --ignore-certificate-errors', - ) - - // Route subgraph calls to never respond — intentionally hangs to keep the - // widget in the loading state indefinitely so we can screenshot that state. - await page.route('https://subgraph-gateway.superfluid.finance/**', () => { - /* intentional hang — never fulfill, never abort */ - }) - await page.route('https://gateway-arbitrum.network.thegraph.com/**', () => { - /* intentional hang — never fulfill, never abort */ - }) + await page.getByText('Pools').first().click() + await expectBodyToContain(page, ['Claimable', 'Claim', 'Connect']) - await gotoStory(page, CUSTODIAL_STORY_URL) + await page.getByText('Balances').first().click() + await expectBodyToContain(page, ['Super Token Balance', 'SUP Reserve']) - // Tab bar should render first - const tabsVisible = await waitForText(page, ['Streams', 'Pools', 'Balances'], 30_000) - expect(tabsVisible).toBeTruthy() - - await page.screenshot({ - path: 'tests/widgets/streaming-widget/test-results/sw-04-loading.png', - fullPage: true, - }) + await saveScreenshot(page, 'sw-02-tab-navigation') }) -// ─── custodial: RPC blocked → error state ──────────────────────────────────── -test('StreamingWidget custodial fixture — shows error state when RPC is blocked', async ({ - page, - browserName, -}) => { - test.skip( - browserName !== 'chromium', - 'Live RPC test requires --disable-web-security / --ignore-certificate-errors', - ) +test('StreamingWidget shows the unsupported network prompt', async ({ page }) => { + await gotoStory(page, 'wrong-chain') - // Block all Celo RPC and subgraph endpoints to force error state - await page.route('https://forno.celo.org/**', (route) => route.abort()) - await page.route('https://subgraph-gateway.superfluid.finance/**', (route) => route.abort()) - await page.route('https://gateway-arbitrum.network.thegraph.com/**', (route) => route.abort()) + await expectBodyToContain(page, [ + 'Unsupported network', + 'Switch to Celo', + 'Switch to Base', + ]) + await saveScreenshot(page, 'sw-03-wrong-chain') +}) - await gotoStory(page, CUSTODIAL_STORY_URL) +test('StreamingWidget shows loading states for streams and history', async ({ page }) => { + await gotoStory(page, 'loading-state') - // After RPCs abort, adapter should surface error state with Retry button - const matched = await waitForText(page, ['Retry', 'error', 'reach'], 25_000) - expect(matched, 'Expected error state after RPC abort').toBeTruthy() + await expectBodyToContain(page, ['Loading streams']) - await page.screenshot({ - path: 'tests/widgets/streaming-widget/test-results/sw-05-error.png', - fullPage: true, - }) + await page.getByText('History').first().click() + await expectBodyToContain(page, ['Loading stream history']) + await saveScreenshot(page, 'sw-04-loading-state') }) -// ─── custodial: pools tab navigation ───────────────────────────────────────── -test('StreamingWidget custodial fixture — clicking Pools tab changes view', async ({ - page, - browserName, -}) => { - test.skip( - browserName !== 'chromium', - 'Live RPC test requires --disable-web-security / --ignore-certificate-errors', - ) +test('StreamingWidget shows empty states for streams and history', async ({ page }) => { + await gotoStory(page, 'empty-state') - // Block external calls to keep the test deterministic - await page.route('https://forno.celo.org/**', (route) => route.abort()) - await page.route('https://subgraph-gateway.superfluid.finance/**', (route) => route.abort()) + await expectBodyToContain(page, ['No streams found.']) - await gotoStory(page, CUSTODIAL_STORY_URL) - - // Wait for tab bar to render - await waitForText(page, ['Streams', 'Pools', 'Balances'], 20_000) - - // Click the Pools tab - const poolsTab = page.getByText('Pools').first() - await expect(poolsTab).toBeVisible() - await poolsTab.click() + await page.getByText('History').first().click() + await expectBodyToContain(page, ['No stream history found.']) + await saveScreenshot(page, 'sw-05-empty-state') +}) - await page.waitForTimeout(500) +test('StreamingWidget shows error states for streams and history', async ({ page }) => { + await gotoStory(page, 'error-state') - await page.screenshot({ - path: 'tests/widgets/streaming-widget/test-results/sw-06-pools-tab.png', - fullPage: true, - }) + await expectBodyToContain(page, ['Unable to reach the network', 'Retry']) - // Verify the tab content changed (either loading, error, or empty state for pools) - const bodyText = await page.evaluate(() => document.body.innerText) - expect(bodyText).toContain('Pools') + await page.getByText('History').first().click() + await expectBodyToContain(page, ['Unable to load stream history.', 'Retry']) + await saveScreenshot(page, 'sw-06-error-state') }) -// ─── custodial: balances tab navigation ────────────────────────────────────── -test('StreamingWidget custodial fixture — clicking Balances tab shows balance section', async ({ - page, - browserName, -}) => { - test.skip( - browserName !== 'chromium', - 'Live RPC test requires --disable-web-security / --ignore-certificate-errors', - ) +test('StreamingWidget shows populated incoming and outgoing stream views', async ({ page }) => { + await gotoStory(page, 'populated-state') - // Block external calls - await page.route('https://forno.celo.org/**', (route) => route.abort()) - await page.route('https://subgraph-gateway.superfluid.finance/**', (route) => route.abort()) + await expectBodyToContain(page, [ + 'Active streams', + 'Incoming', + 'Outgoing', + /100\s+G\$\s*\/mo/, + ]) - await gotoStory(page, CUSTODIAL_STORY_URL) + await page.getByText('Incoming').first().click() + await expectBodyToContain(page, ['Incoming']) - await waitForText(page, ['Streams', 'Pools', 'Balances'], 20_000) + await page.getByText('Outgoing').first().click() + await expectBodyToContain(page, ['Outgoing']) - // Click Balances tab - const balancesTab = page.getByText('Balances').first() - await expect(balancesTab).toBeVisible() - await balancesTab.click() + await saveScreenshot(page, 'sw-07-populated-streams') - await page.waitForTimeout(500) + await page.getByText('History').first().click() + await expectBodyToContain(page, ['Stream history', 'Show more']) + await saveScreenshot(page, 'sw-22-stream-history-tab') +}) - await page.screenshot({ - path: 'tests/widgets/streaming-widget/test-results/sw-07-balances-tab.png', - fullPage: true, - }) +test('StreamingWidget renders usable mobile and desktop layouts', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }) + await gotoStory(page, 'populated-state') + await expectBodyToContain(page, ['Streams', 'History', 'Active streams']) + await saveScreenshot(page, 'sw-18-mobile-populated') - // Balances tab shows Super Token Balance section and the SUP reserve disabled notice for non-Base - const bodyText = await page.evaluate(() => document.body.innerText) - expect(bodyText).toMatch(/Balance|Refresh|Super Token/i) + await page.setViewportSize({ width: 1280, height: 900 }) + await gotoStory(page, 'populated-state') + await expectBodyToContain(page, ['Streams', 'History', 'Pools', 'Balances', 'Active streams']) + await saveScreenshot(page, 'sw-19-desktop-populated') }) -// ─── custodial: create-stream form toggle ──────────────────────────────────── -test('StreamingWidget custodial fixture — New Stream button toggles form', async ({ - page, - browserName, -}) => { - test.skip( - browserName !== 'chromium', - 'Live RPC test requires --disable-web-security / --ignore-certificate-errors', - ) +test('StreamingWidget create/update form shows invalid input feedback', async ({ page }) => { + await gotoStory(page, 'create-update-invalid-input') - // Block external calls for determinism - await page.route('https://forno.celo.org/**', (route) => route.abort()) - await page.route('https://subgraph-gateway.superfluid.finance/**', (route) => route.abort()) + await expectBodyToContain(page, [ + 'Create / Update Stream', + 'Recipient must be a valid Ethereum address', + ]) + await saveScreenshot(page, 'sw-08-create-update-invalid') +}) - await gotoStory(page, CUSTODIAL_STORY_URL) +test('StreamingWidget create/update form shows pending and success states', async ({ page }) => { + await gotoStory(page, 'create-update-pending') + await expectBodyToContain(page, ['Create / Update Stream', 'Transaction pending...']) + await saveScreenshot(page, 'sw-09-create-update-pending') - // Wait for Streams tab to render with the New Stream button - await waitForText(page, ['New Stream'], 20_000) + await gotoStory(page, 'create-update-success') + await expectBodyToContain(page, ['Create / Update Stream', 'Stream set! Tx:']) + await saveScreenshot(page, 'sw-10-create-update-success') +}) - const newStreamBtn = page.getByText('+ New Stream').first() - await expect(newStreamBtn).toBeVisible() - await newStreamBtn.click() +test('StreamingWidget create/update form shows failure state', async ({ page }) => { + await gotoStory(page, 'create-update-failure') - // Form should appear with recipient and amount fields - await waitForText(page, ['Recipient address', 'Amount', 'Set Stream'], 5_000) + await expectBodyToContain(page, ['Create / Update Stream', 'Transaction cancelled by wallet.']) + await saveScreenshot(page, 'sw-11-create-update-failure') +}) - const bodyText = await page.evaluate(() => document.body.innerText) - expect(bodyText).toMatch(/Recipient|Amount|Set Stream/i) +test('StreamingWidget shows pool claim amount and lifecycle states', async ({ page }) => { + await gotoStory(page, 'pool-claim-state') + await expectBodyToContain(page, ['Claimable', '12.5', 'Claim']) + await saveScreenshot(page, 'sw-12-pool-claim') + + await gotoStory(page, 'pool-connected-state') + await expectBodyToContain(page, ['Connected', 'Claimable', 'Disconnect']) + await expect(page.getByText('Claim', { exact: true })).toHaveCount(0) + await expect(page.getByText('Connect', { exact: true })).toHaveCount(0) + await saveScreenshot(page, 'sw-23-pool-connected') + + await gotoStory(page, 'pool-claim-pending') + await expectBodyToContain(page, ['Connected', 'Claimable', '12.5', 'Pending', 'Disconnect']) + await expect(page.getByText('Claim', { exact: true })).toHaveCount(0) + await expect(page.getByText('Connect', { exact: true })).toHaveCount(0) + await saveScreenshot(page, 'sw-13-pool-claim-pending') + + await gotoStory(page, 'pool-claim-success') + await expectBodyToContain(page, ['Connected', 'Claimable', 'Done', 'Disconnect']) + await expect(page.getByText('Claim', { exact: true })).toHaveCount(0) + await expect(page.getByText('Connect', { exact: true })).toHaveCount(0) + await saveScreenshot(page, 'sw-14-pool-claim-success') + + await gotoStory(page, 'pool-claim-error') + await expectBodyToContain(page, [ + 'Connected', + 'Pool claim failed. Please retry.', + 'Failed', + 'Disconnect', + ]) + await expect(page.getByText('Claim', { exact: true })).toHaveCount(0) + await expect(page.getByText('Connect', { exact: true })).toHaveCount(0) + await saveScreenshot(page, 'sw-15-pool-claim-error') + + await gotoStory(page, 'pool-claimable-amount-error') + await expectBodyToContain(page, ['Could not load claimable amount.', 'Retry']) + await saveScreenshot(page, 'sw-20-pool-claimable-amount-error') + + await page.getByText('Retry').first().click() + await expectBodyToContain(page, ['Loading pool memberships']) + await saveScreenshot(page, 'sw-21-pool-claimable-retry-loading') +}) - await page.screenshot({ - path: 'tests/widgets/streaming-widget/test-results/sw-08-create-stream-form.png', - fullPage: true, - }) +test('StreamingWidget shows Base SUP reserve and disables reserve off Base', async ({ page }) => { + await gotoStory(page, 'base-sup-balance-and-reserve') + await expectBodyToContain(page, [ + 'Super Token Balance', + 'SUP Reserve', + '112.75', + 'Reserve locker', + 'Available', + 'Staked', + 'Open in Superfluid Explorer', + ]) + await saveScreenshot(page, 'sw-16-base-sup-reserve') + + await gotoStory(page, 'non-base-sup-reserve-disabled') + await expectBodyToContain(page, [ + 'Super Token Balance', + 'SUP Reserve', + 'Reserve data is only available on Base', + ]) + await saveScreenshot(page, 'sw-17-non-base-reserve-disabled') }) diff --git a/tests/widgets/streaming-widget/test-results/sw-01-no-wallet.png b/tests/widgets/streaming-widget/test-results/sw-01-no-wallet.png new file mode 100644 index 0000000..2160ecb Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-01-no-wallet.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-02-tab-navigation.png b/tests/widgets/streaming-widget/test-results/sw-02-tab-navigation.png new file mode 100644 index 0000000..66eaf30 Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-02-tab-navigation.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-03-wrong-chain.png b/tests/widgets/streaming-widget/test-results/sw-03-wrong-chain.png new file mode 100644 index 0000000..312c1b6 Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-03-wrong-chain.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-04-loading-state.png b/tests/widgets/streaming-widget/test-results/sw-04-loading-state.png new file mode 100644 index 0000000..796aecd Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-04-loading-state.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-05-empty-state.png b/tests/widgets/streaming-widget/test-results/sw-05-empty-state.png new file mode 100644 index 0000000..86ef300 Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-05-empty-state.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-06-error-state.png b/tests/widgets/streaming-widget/test-results/sw-06-error-state.png new file mode 100644 index 0000000..b3ad019 Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-06-error-state.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-07-populated-streams.png b/tests/widgets/streaming-widget/test-results/sw-07-populated-streams.png new file mode 100644 index 0000000..308ad6c Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-07-populated-streams.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-08-create-update-invalid.png b/tests/widgets/streaming-widget/test-results/sw-08-create-update-invalid.png new file mode 100644 index 0000000..3b91e4d Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-08-create-update-invalid.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-09-create-update-pending.png b/tests/widgets/streaming-widget/test-results/sw-09-create-update-pending.png new file mode 100644 index 0000000..3e18f6d Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-09-create-update-pending.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-10-create-update-success.png b/tests/widgets/streaming-widget/test-results/sw-10-create-update-success.png new file mode 100644 index 0000000..6843024 Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-10-create-update-success.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-11-create-update-failure.png b/tests/widgets/streaming-widget/test-results/sw-11-create-update-failure.png new file mode 100644 index 0000000..7d340bb Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-11-create-update-failure.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-12-pool-claim.png b/tests/widgets/streaming-widget/test-results/sw-12-pool-claim.png new file mode 100644 index 0000000..f77055a Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-12-pool-claim.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-13-pool-claim-pending.png b/tests/widgets/streaming-widget/test-results/sw-13-pool-claim-pending.png new file mode 100644 index 0000000..e9fb1a7 Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-13-pool-claim-pending.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-14-pool-claim-success.png b/tests/widgets/streaming-widget/test-results/sw-14-pool-claim-success.png new file mode 100644 index 0000000..be9d030 Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-14-pool-claim-success.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-15-pool-claim-error.png b/tests/widgets/streaming-widget/test-results/sw-15-pool-claim-error.png new file mode 100644 index 0000000..c75842d Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-15-pool-claim-error.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-16-base-sup-reserve.png b/tests/widgets/streaming-widget/test-results/sw-16-base-sup-reserve.png new file mode 100644 index 0000000..bb85df8 Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-16-base-sup-reserve.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-17-non-base-reserve-disabled.png b/tests/widgets/streaming-widget/test-results/sw-17-non-base-reserve-disabled.png new file mode 100644 index 0000000..66eaf30 Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-17-non-base-reserve-disabled.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-18-mobile-populated.png b/tests/widgets/streaming-widget/test-results/sw-18-mobile-populated.png new file mode 100644 index 0000000..742cc15 Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-18-mobile-populated.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-19-desktop-populated.png b/tests/widgets/streaming-widget/test-results/sw-19-desktop-populated.png new file mode 100644 index 0000000..0ea1111 Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-19-desktop-populated.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-20-pool-claimable-amount-error.png b/tests/widgets/streaming-widget/test-results/sw-20-pool-claimable-amount-error.png new file mode 100644 index 0000000..a2c2fb8 Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-20-pool-claimable-amount-error.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-21-pool-claimable-retry-loading.png b/tests/widgets/streaming-widget/test-results/sw-21-pool-claimable-retry-loading.png new file mode 100644 index 0000000..5088ac4 Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-21-pool-claimable-retry-loading.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-22-stream-history-tab.png b/tests/widgets/streaming-widget/test-results/sw-22-stream-history-tab.png new file mode 100644 index 0000000..c4a40fc Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-22-stream-history-tab.png differ diff --git a/tests/widgets/streaming-widget/test-results/sw-23-pool-connected.png b/tests/widgets/streaming-widget/test-results/sw-23-pool-connected.png new file mode 100644 index 0000000..7ac8124 Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-23-pool-connected.png differ