diff --git a/examples/storybook/package.json b/examples/storybook/package.json index 25373c9..75a3ae5 100644 --- a/examples/storybook/package.json +++ b/examples/storybook/package.json @@ -13,6 +13,7 @@ "@goodwidget/ui": "workspace:*", "@goodwidget/claim-widget-theme-demo": "workspace:*", "@goodwidget/citizen-claim-widget": "workspace:*", + "@goodwidget/streaming-widget": "workspace:*", "react": "^18.3.0", "react-dom": "^18.3.0", "react-native-web": "^0.19.13", diff --git a/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx b/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx new file mode 100644 index 0000000..12520e8 --- /dev/null +++ b/examples/storybook/src/stories/streaming-widget/StreamingWidget.stories.tsx @@ -0,0 +1,125 @@ +import React from 'react' +import type { Meta, StoryObj } from '@storybook/react' +import { StreamingWidget } from '@goodwidget/streaming-widget' +import { YStack } from '@goodwidget/ui' +import { + getInjectedEip1193Provider, + isInjectedProviderUsable, +} 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. +// --------------------------------------------------------------------------- +function StreamingWidgetStoryShell({ + provider, + dataTestId, +}: { + provider: unknown + dataTestId: string +}) { + return ( + + + + ) +} + +const meta: Meta = { + title: 'Widgets/StreamingWidget', + component: StreamingWidget, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +} + +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). + + + ) + } + + return ( + + ) +} + +// --------------------------------------------------------------------------- +// Custodial fixture story — uses the pre-configured test wallet from the fixture +// --------------------------------------------------------------------------- +function CustodialLocalFixtureStory() { + try { + const provider = createCustodialEip1193Provider() + return ( + + ) + } catch (error: unknown) { + return ( + + Custodial fixture not configured + + {error instanceof Error + ? error.message + : 'Set a local private key in custodialEip1193.ts'} + + + ) + } +} + +// --------------------------------------------------------------------------- +// 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: () => , +} diff --git a/packages/streaming-widget/package.json b/packages/streaming-widget/package.json new file mode 100644 index 0000000..57eaf0c --- /dev/null +++ b/packages/streaming-widget/package.json @@ -0,0 +1,51 @@ +{ + "name": "@goodwidget/streaming-widget", + "version": "0.1.0-beta", + "description": "GoodWidget for Superfluid streaming flows — streams, GDA pool memberships, and Super Token balances", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "./element": { + "types": "./dist/element.d.ts", + "import": "./dist/element.js", + "require": "./dist/element.cjs" + }, + "./register": { + "types": "./dist/register.d.ts", + "import": "./dist/register.js", + "require": "./dist/register.cjs" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src/", + "clean": "rm -rf dist .turbo" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + }, + "dependencies": { + "@goodsdks/streaming-sdk": "1.0.0", + "@goodwidget/core": "workspace:*", + "@goodwidget/embed": "workspace:*", + "@goodwidget/ui": "workspace:*", + "viem": "^2.0.0" + }, + "devDependencies": { + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "tsup": "^8.4.0", + "typescript": "^5.7.0" + } +} diff --git a/packages/streaming-widget/src/StreamingWidget.tsx b/packages/streaming-widget/src/StreamingWidget.tsx new file mode 100644 index 0000000..f837009 --- /dev/null +++ b/packages/streaming-widget/src/StreamingWidget.tsx @@ -0,0 +1,723 @@ +import React, { useState } from 'react' +import { GoodWidgetProvider } from '@goodwidget/core' +import type { EIP1193Provider } from '@goodwidget/core' +import { + createComponent, + Card, + Heading, + Text, + Button, + ButtonText, + Spinner, + Separator, + ToastContainer, + XStack, + YStack, + Input, + Select, + Badge, + BadgeText, + AddressDisplay, + TokenAmount, + WidgetTabs, +} from '@goodwidget/ui' +import type { Address } from 'viem' +import { formatUnits } from 'viem' +import { useStreamingAdapter } from './adapter' +import type { + StreamingWidgetProps, + StreamingWidgetTab, + StreamDirection, + StreamListItem, + PoolMembershipItem, + StreamTimeUnit, + WriteStatus, +} from './widgetRuntimeContract' +import { STREAMING_CHAINS } from './widgetRuntimeContract' + +// --------------------------------------------------------------------------- +// Named styled sub-components — participate in the component sub-theme system. +// Integrators can override via themeOverrides. +// --------------------------------------------------------------------------- + +/** Outer shell for each tab's content area */ +const StreamingTabContent = createComponent(YStack, { + name: 'StreamingTabContent', + flex: 1, + gap: '$3', + paddingVertical: '$3', +}) + +/** Row card for a single stream entry */ +const StreamRow = createComponent(Card, { + name: 'StreamRow', + padding: '$3', + gap: '$2', +}) + +/** Row card for a single pool membership entry */ +const PoolRow = createComponent(Card, { + name: 'PoolRow', + padding: '$3', + gap: '$2', +}) + +/** Card displayed when a list is empty */ +const EmptyStateCard = createComponent(Card, { + name: 'EmptyStateCard', + padding: '$6', + alignItems: 'center' as const, + justifyContent: 'center' as const, + gap: '$3', +}) + +/** Card displayed for inline error states */ +const ErrorStateCard = createComponent(Card, { + name: 'ErrorStateCard', + padding: '$4', + gap: '$2', +}) + +/** Card for the create/update stream form */ +const SetStreamFormCard = createComponent(Card, { + name: 'SetStreamFormCard', + padding: '$4', + gap: '$3', +}) + +/** Card for balance display */ +const BalanceCard = createComponent(Card, { + name: 'BalanceCard', + padding: '$4', + gap: '$2', +}) + +// --------------------------------------------------------------------------- +// Utility helpers +// --------------------------------------------------------------------------- + +const TIME_UNIT_OPTIONS: Array<{ value: StreamTimeUnit; label: string }> = [ + { value: 'second', label: 'per second' }, + { value: 'minute', label: 'per minute' }, + { value: 'hour', label: 'per hour' }, + { value: 'day', label: 'per day' }, + { value: 'week', label: 'per week' }, + { value: 'month', label: 'per month' }, + { value: 'year', label: 'per year' }, +] + +/** Formats a flow rate bigint (wei/s) into a human-readable per-period amount */ +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) +} + +/** Formats a unix timestamp (seconds) to a short locale date string */ +function formatTimestamp(unixSeconds: number): string { + if (!unixSeconds) return '—' + return new Date(unixSeconds * 1000).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) +} + +/** Short-form chain name display */ +function chainName(chainId: number): string { + if (chainId === STREAMING_CHAINS.CELO) return 'Celo' + if (chainId === STREAMING_CHAINS.BASE) return 'Base' + return `Chain ${chainId}` +} + +// --------------------------------------------------------------------------- +// Write-status badge helper +// --------------------------------------------------------------------------- +function WriteStatusBadge({ status }: { status: WriteStatus }) { + if (status === 'idle') return null + if (status === 'pending') return + if (status === 'success') + return ( + + Done + + ) + return ( + + Failed + + ) +} + +// --------------------------------------------------------------------------- +// Not-connected / wrong-chain prompt +// --------------------------------------------------------------------------- +function WalletGate({ + isConnected, + isWrongChain, + onConnect, + onSwitchChain, +}: { + isConnected: boolean + isWrongChain: boolean + onConnect: () => void + onSwitchChain: (chainId: number) => void +}) { + if (!isConnected) { + return ( + + + Wallet not connected + + + Connect your wallet to view streams, pools, and balances. + + + + ) + } + + if (isWrongChain) { + return ( + + + Unsupported network + + + Switch to Celo or Base to use the streaming widget. + + + + + + + ) + } + + return null +} + +// --------------------------------------------------------------------------- +// Set-stream form — create or update an outgoing stream +// --------------------------------------------------------------------------- +function SetStreamForm({ + form, + status, + error, + txHash, + onUpdate, + onSubmit, + onReset, +}: { + form: ReturnType['state']['setStreamForm'] + status: WriteStatus + error: string | null + txHash: string | null + onUpdate: (partial: Partial) => void + onSubmit: () => void + onReset: () => void +}) { + const isSubmitting = status === 'pending' + + return ( + + {form.receiver ? 'Update Stream' : 'Create Stream'} + + {/* Recipient address */} + + Recipient address + onUpdate({ receiver: v })} + editable={!isSubmitting} + /> + + + {/* Amount + time unit */} + + + Amount + onUpdate({ amount: v })} + keyboardType="decimal-pad" + editable={!isSubmitting} + /> + + + Period +