diff --git a/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx b/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx index 12520e8..5aa7e52 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,190 @@ 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 = '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045' +const DEMO_RECEIVER = '0x1111111111111111111111111111111111111111' +const DEMO_SENDER = '0x2222222222222222222222222222222222222222' +const DEMO_TOKEN = '0x3333333333333333333333333333333333333333' +const DEMO_POOL = '0x4444444444444444444444444444444444444444' + +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', + }, +] + +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, + 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, + 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 +211,10 @@ function StreamingWidgetStoryShell({ dataTestId: string }) { return ( - + ) @@ -36,22 +230,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 +259,6 @@ function InjectedWalletStory() { ) } -// --------------------------------------------------------------------------- -// Custodial fixture story — uses the pre-configured test wallet from the fixture -// --------------------------------------------------------------------------- function CustodialLocalFixtureStory() { try { const provider = createCustodialEip1193Provider() @@ -77,7 +270,10 @@ function CustodialLocalFixtureStory() { ) } catch (error: unknown) { return ( - + Custodial fixture not configured {error instanceof Error @@ -89,37 +285,238 @@ function CustodialLocalFixtureStory() { } } -// --------------------------------------------------------------------------- -// No-wallet story — demonstrates the connect-prompt state -// --------------------------------------------------------------------------- -function NoWalletStory() { - return ( - - ) -} - -// --------------------------------------------------------------------------- -// 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 PoolClaimPending: Story = { + render: () => ( + + ), +} + +export const PoolClaimSuccess: Story = { + render: () => ( + + ), +} + +export const PoolClaimError: 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..15b4a15 100644 --- a/packages/streaming-widget/src/StreamingWidget.tsx +++ b/packages/streaming-widget/src/StreamingWidget.tsx @@ -31,10 +31,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. @@ -116,7 +120,7 @@ function formatFlowRateDisplay(flowRate: bigint, decimals = 18): string { /** 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', @@ -216,11 +220,11 @@ function SetStreamForm({ onSubmit, onReset, }: { - form: ReturnType['state']['setStreamForm'] + form: StreamingWidgetAdapterState['setStreamForm'] status: WriteStatus error: string | null txHash: string | null - onUpdate: (partial: Partial) => void + onUpdate: (partial: Partial) => void onSubmit: () => void onReset: () => void }) { @@ -228,13 +232,13 @@ function SetStreamForm({ return ( - {form.receiver ? 'Update Stream' : 'Create Stream'} + Create / Update Stream {/* Recipient address */} Recipient address onUpdate({ receiver: v })} editable={!isSubmitting} @@ -268,7 +272,7 @@ function SetStreamForm({ {form.flowRate !== null && form.flowRate > 0n && ( - ≈ {formatUnits(form.flowRate, 18)} tokens/s + About {formatUnits(form.flowRate, 18)} tokens/s )} @@ -284,9 +288,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)}... )} @@ -321,7 +330,7 @@ function StreamCard({ stream }: { stream: StreamListItem }) { - {stream.direction === 'incoming' ? '↓ Incoming' : '↑ Outgoing'} + {stream.direction === 'incoming' ? 'Incoming' : 'Outgoing'} Since {formatTimestamp(stream.createdAtTimestamp)} @@ -360,16 +369,23 @@ 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 [historyLimit, setHistoryLimit] = useState(4) + const recentStreams = state.streamHistory.slice(0, historyLimit) + const hasMoreHistory = state.streamHistory.length > historyLimit return ( @@ -397,6 +413,8 @@ function StreamsTab({ + Active streams + {/* Direction filter */} {(['all', 'incoming', 'outgoing'] as StreamDirection[]).map((d) => ( @@ -414,7 +432,7 @@ function StreamsTab({ {state.streamsLoading && ( - Loading streams… + Loading streams... )} @@ -430,7 +448,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 && ( + + )} ) } @@ -452,16 +515,24 @@ function PoolCard({ pool, connectStatus, connectError, + claimStatus, + claimError, onConnect, onDisconnect, + onClaim, }: { pool: PoolMembershipItem connectStatus: WriteStatus connectError: string | null + claimStatus: WriteStatus + claimError: string | null onConnect: (poolAddress: Address) => void onDisconnect: (poolAddress: Address) => void + onClaim: (poolAddress: Address) => void }) { - const isPending = connectStatus === 'pending' + const isConnectPending = connectStatus === 'pending' + const isClaimPending = claimStatus === 'pending' + const canClaim = pool.isConnected && pool.claimableAmount > 0n return ( @@ -472,6 +543,13 @@ function PoolCard({ + + + Claimable + + {formatUnits(pool.claimableAmount, 18)} + + Total claimed @@ -484,19 +562,33 @@ function PoolCard({ {connectError} )} + {claimError && ( + + {claimError} + + )} {pool.isConnected ? ( - ) : ( - )} + + {pool.isConnected && ( + + + + + )} ) } @@ -508,15 +600,15 @@ function PoolsTab({ state, actions, }: { - state: ReturnType['state'] - actions: ReturnType['actions'] + state: StreamingWidgetAdapterState + actions: StreamingWidgetAdapterActions }) { return ( {state.poolsLoading && ( - Loading pool memberships… + Loading pool memberships... )} @@ -548,8 +640,11 @@ function PoolsTab({ pool={pool} connectStatus={state.poolConnectStatus[pool.poolId] ?? 'idle'} connectError={state.poolConnectError[pool.poolId] ?? null} + claimStatus={state.poolClaimStatus[pool.poolId] ?? 'idle'} + claimError={state.poolClaimError[pool.poolId] ?? null} onConnect={actions.connectToPool} onDisconnect={actions.disconnectFromPool} + onClaim={actions.claimFromPool} /> ))} @@ -563,8 +658,8 @@ function BalancesTab({ state, actions, }: { - state: ReturnType['state'] - actions: ReturnType['actions'] + state: StreamingWidgetAdapterState + actions: StreamingWidgetAdapterActions }) { const isOnBase = state.chainId === STREAMING_CHAINS.BASE @@ -575,7 +670,7 @@ function BalancesTab({ Super Token Balance @@ -648,8 +743,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 === 'pools' && } {activeTab === 'balances' && } @@ -685,6 +802,37 @@ function StreamingWidgetInner({ ) } +export interface StreamingWidgetPreviewProps + extends Pick { + 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..4612781 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,12 @@ 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, totalAmountClaimed: pool.totalAmountClaimed, isConnected: pool.isConnected ?? false, } @@ -148,7 +170,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 +200,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([]) @@ -203,6 +228,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 +286,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 +316,38 @@ 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, + } + } catch { + return pool + } + }), + ) + setPools(poolsWithClaimable) } catch (err) { setPoolsError(humanReadableError(err)) } finally { setPoolsLoading(false) } - }, [gdaSDK, address]) + }, [gdaSDK, address, viemClients]) // --------------------------------------------------------------------------- // Fetch Super Token balance @@ -344,9 +403,14 @@ export function useStreamingAdapter({ if (!isConnected || !address || isWrongChain) { setStreams([]) + setStreamHistory([]) setPools([]) setSuperTokenBalance(null) setSupReserveBalance(null) + setPoolConnectStatus({}) + setPoolConnectError({}) + setPoolClaimStatus({}) + setPoolClaimError({}) return } @@ -453,6 +517,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 +572,9 @@ export function useStreamingAdapter({ streams, streamsLoading, streamsError, + streamHistory, + streamHistoryLoading, + streamHistoryError, pools, poolsLoading, poolsError, @@ -495,6 +590,8 @@ export function useStreamingAdapter({ setStreamTxHash, poolConnectStatus, poolConnectError, + poolClaimStatus, + poolClaimError, }), [ isConnected, @@ -504,6 +601,9 @@ export function useStreamingAdapter({ streams, streamsLoading, streamsError, + streamHistory, + streamHistoryLoading, + streamHistoryError, pools, poolsLoading, poolsError, @@ -519,6 +619,8 @@ export function useStreamingAdapter({ setStreamTxHash, poolConnectStatus, poolConnectError, + poolClaimStatus, + poolClaimError, ], ) @@ -527,6 +629,7 @@ export function useStreamingAdapter({ connect, switchChain, refreshStreams: fetchStreams, + refreshStreamHistory: fetchStreams, refreshPools: fetchPools, refreshBalance: fetchBalance, updateSetStreamForm, @@ -534,6 +637,7 @@ export function useStreamingAdapter({ resetSetStream, connectToPool, disconnectFromPool, + claimFromPool, }), [ connect, @@ -546,6 +650,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..8c420d5 100644 --- a/packages/streaming-widget/src/index.ts +++ b/packages/streaming-widget/src/index.ts @@ -20,4 +20,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..a935c46 100644 --- a/packages/streaming-widget/src/widgetRuntimeContract.ts +++ b/packages/streaming-widget/src/widgetRuntimeContract.ts @@ -62,6 +62,8 @@ export interface PoolMembershipItem { poolId: Address poolToken: Address totalUnits: bigint + /** Claimable incoming distribution amount in wei, when exposed by the data source */ + claimableAmount: bigint totalAmountClaimed: bigint /** Whether this account has actively connected to the pool distribution */ isConnected: boolean @@ -96,6 +98,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[] @@ -121,6 +126,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 +138,7 @@ export interface StreamingWidgetAdapterActions { connect: () => Promise switchChain: (chainId: number) => Promise refreshStreams: () => Promise + refreshStreamHistory: () => Promise refreshPools: () => Promise refreshBalance: () => Promise @@ -144,6 +153,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/tests/widgets/streaming-widget/states.spec.ts b/tests/widgets/streaming-widget/states.spec.ts index ea576b5..b2d2045 100644 --- a/tests/widgets/streaming-widget/states.spec.ts +++ b/tests/widgets/streaming-widget/states.spec.ts @@ -1,285 +1,183 @@ -/** - * 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', '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('Pools').first().click() + await expectBodyToContain(page, ['Claimable', 'Connect to claim']) -// ─── 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('Balances').first().click() + await expectBodyToContain(page, ['Super Token Balance', 'SUP Reserve']) - await gotoStory(page, CUSTODIAL_STORY_URL) + await saveScreenshot(page, 'sw-02-tab-navigation') +}) - // Tab bar should render first - const tabsVisible = await waitForText(page, ['Streams', 'Pools', 'Balances'], 30_000) - expect(tabsVisible).toBeTruthy() +test('StreamingWidget shows the unsupported network prompt', async ({ page }) => { + await gotoStory(page, 'wrong-chain') - await page.screenshot({ - path: 'tests/widgets/streaming-widget/test-results/sw-04-loading.png', - fullPage: true, - }) + await expectBodyToContain(page, [ + 'Unsupported network', + 'Switch to Celo', + 'Switch to Base', + ]) + await saveScreenshot(page, 'sw-03-wrong-chain') }) -// ─── 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', - ) - - // 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()) +test('StreamingWidget shows loading states for streams and history', async ({ page }) => { + await gotoStory(page, 'loading-state') - await gotoStory(page, CUSTODIAL_STORY_URL) + await expectBodyToContain(page, ['Loading streams', 'Loading stream history']) + await saveScreenshot(page, 'sw-04-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() +test('StreamingWidget shows empty states for streams and history', async ({ page }) => { + await gotoStory(page, 'empty-state') - await page.screenshot({ - path: 'tests/widgets/streaming-widget/test-results/sw-05-error.png', - fullPage: true, - }) + await expectBodyToContain(page, ['No streams found.', 'No stream history found.']) + await saveScreenshot(page, 'sw-05-empty-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 error states for streams and history', async ({ page }) => { + await gotoStory(page, 'error-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 gotoStory(page, CUSTODIAL_STORY_URL) + await expectBodyToContain(page, [ + 'Unable to reach the network', + 'Unable to load stream history.', + 'Retry', + ]) + await saveScreenshot(page, 'sw-06-error-state') +}) - // Wait for tab bar to render - await waitForText(page, ['Streams', 'Pools', 'Balances'], 20_000) +test('StreamingWidget shows populated incoming and outgoing stream views', async ({ page }) => { + await gotoStory(page, 'populated-state') - // Click the Pools tab - const poolsTab = page.getByText('Pools').first() - await expect(poolsTab).toBeVisible() - await poolsTab.click() + await expectBodyToContain(page, [ + 'Active streams', + 'Stream history', + 'Incoming', + 'Outgoing', + 'Show more', + ]) - await page.waitForTimeout(500) + await page.getByText('Incoming').first().click() + await expectBodyToContain(page, ['Incoming']) - await page.screenshot({ - path: 'tests/widgets/streaming-widget/test-results/sw-06-pools-tab.png', - fullPage: true, - }) + await page.getByText('Outgoing').first().click() + await expectBodyToContain(page, ['Outgoing']) - // 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 saveScreenshot(page, 'sw-07-populated-streams') }) -// ─── 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', - ) - - // Block external calls - await page.route('https://forno.celo.org/**', (route) => route.abort()) - await page.route('https://subgraph-gateway.superfluid.finance/**', (route) => route.abort()) +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', 'Active streams', 'Stream history']) + await saveScreenshot(page, 'sw-18-mobile-populated') - await gotoStory(page, CUSTODIAL_STORY_URL) - - await waitForText(page, ['Streams', 'Pools', 'Balances'], 20_000) + await page.setViewportSize({ width: 1280, height: 900 }) + await gotoStory(page, 'populated-state') + await expectBodyToContain(page, ['Streams', 'Pools', 'Balances', 'Active streams']) + await saveScreenshot(page, 'sw-19-desktop-populated') +}) - // Click Balances tab - const balancesTab = page.getByText('Balances').first() - await expect(balancesTab).toBeVisible() - await balancesTab.click() +test('StreamingWidget create/update form shows invalid input feedback', async ({ page }) => { + await gotoStory(page, 'create-update-invalid-input') - await page.waitForTimeout(500) + await expectBodyToContain(page, [ + 'Create / Update Stream', + 'Recipient must be a valid Ethereum address', + ]) + await saveScreenshot(page, 'sw-08-create-update-invalid') +}) - await page.screenshot({ - path: 'tests/widgets/streaming-widget/test-results/sw-07-balances-tab.png', - fullPage: true, - }) +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') - // 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 gotoStory(page, 'create-update-success') + await expectBodyToContain(page, ['Create / Update Stream', 'Stream set! Tx:']) + await saveScreenshot(page, 'sw-10-create-update-success') }) -// ─── 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 failure state', async ({ page }) => { + await gotoStory(page, 'create-update-failure') - // 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 gotoStory(page, CUSTODIAL_STORY_URL) + await expectBodyToContain(page, ['Create / Update Stream', 'Transaction cancelled by wallet.']) + await saveScreenshot(page, 'sw-11-create-update-failure') +}) - // Wait for Streams tab to render with the New Stream button - await waitForText(page, ['New Stream'], 20_000) +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') - const newStreamBtn = page.getByText('+ New Stream').first() - await expect(newStreamBtn).toBeVisible() - await newStreamBtn.click() + await gotoStory(page, 'pool-claim-pending') + await expectBodyToContain(page, ['Claimable', '12.5']) + await saveScreenshot(page, 'sw-13-pool-claim-pending') - // Form should appear with recipient and amount fields - await waitForText(page, ['Recipient address', 'Amount', 'Set Stream'], 5_000) + await gotoStory(page, 'pool-claim-success') + await expectBodyToContain(page, ['Connected', 'Done']) + await saveScreenshot(page, 'sw-14-pool-claim-success') - const bodyText = await page.evaluate(() => document.body.innerText) - expect(bodyText).toMatch(/Recipient|Amount|Set Stream/i) + await gotoStory(page, 'pool-claim-error') + await expectBodyToContain(page, ['Pool claim failed. Please retry.', 'Failed']) + await saveScreenshot(page, 'sw-15-pool-claim-error') +}) - 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 (Staked)', '95.25']) + 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..e887a88 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..49e1b18 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..aff96b4 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..3d5bf05 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..9c56a2b 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..a18961e 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..04851af 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..0cb733c 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..684c86a 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..798c917 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..765021e 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..958da9b 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..ece39c7 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..54feed9 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..7920136 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..7e8f8ce 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..49e1b18 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..4f8f982 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..981acdc Binary files /dev/null and b/tests/widgets/streaming-widget/test-results/sw-19-desktop-populated.png differ