Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ GoodWidget/
ui/ # Tamagui tokens, preset, themes, config assembly, manifest, primitives
embed/ # Web Component wrapper + CSS custom property bridge
claim-widget/ # Example widget package using core + ui + embed
goodreserve-widget/ # Reserve swap widget package using core + ui + embed

examples/
react-web/ # React web override and theming demo
Expand Down Expand Up @@ -69,6 +70,7 @@ GoodWidget/
@goodwidget/embed

@goodwidget/claim-widget -> depends on core + ui + embed
@goodwidget/goodreserve-widget -> depends on core + ui + embed
```

`@goodwidget/ui` is the leaf design-system package and must not depend on `@goodwidget/core`.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ A cross-platform mini app framework for building web3 widgets that run inside wa
| `@goodwidget/ui` | Tamagui-based themeable component library (React + React Native Web) |
| `@goodwidget/embed` | Web Component wrapper for embedding mini apps in any HTML page |
| `@goodwidget/claim-widget` | Sample publishable widget — React component + Web Component |
| `@goodwidget/goodreserve-widget` | Reserve swap widget package (buy/sell flow on Celo/XDC) |

## Quick Start

Expand Down Expand Up @@ -170,6 +171,7 @@ GoodWidget/
ui/ → @goodwidget/ui (component library, theme system)
embed/ → @goodwidget/embed (Web Component wrapper)
claim-widget/ → @goodwidget/claim-widget (sample publishable widget)
goodreserve-widget/ → @goodwidget/goodreserve-widget (reserve swap widget)
examples/
react-web/ → React demo with style override showcase
html/ → Plain HTML consuming a web component widget
Expand Down
1 change: 1 addition & 0 deletions examples/storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"@goodwidget/ui": "workspace:*",
"@goodwidget/claim-widget-theme-demo": "workspace:*",
"@goodwidget/citizen-claim-widget": "workspace:*",
"@goodwidget/goodreserve-widget": "workspace:*",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-native-web": "^0.19.13",
Expand Down
132 changes: 132 additions & 0 deletions examples/storybook/src/fixtures/goodReserveWidgetMock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import type { ReserveSwapWidgetAdapterState } from '@goodwidget/goodreserve-widget'

// Deterministic reserve widget state fixtures used by Storybook and CI tests.
export const reserveWidgetMockStates: Record<string, Partial<ReserveSwapWidgetAdapterState>> = {
noProvider: {
status: 'no_provider',
hasProvider: false,
chainId: null,
address: null,
},
unsupportedChain: {
status: 'unsupported_chain',
hasProvider: true,
chainId: 8453,
address: '0x1111111111111111111111111111111111111111',
},
idleBuy: {
status: 'idle_buy',
chainId: 42220,
address: '0x1111111111111111111111111111111111111111',
hasProvider: true,
tokenInBalance: '120.00',
tokenOutBalance: '10340.22',
inputAmount: '',
direction: 'buy',
},
amountEditing: {
status: 'amount_editing',
chainId: 42220,
hasProvider: true,
inputAmount: '25',
tokenInBalance: '120.00',
},
quoteLoading: {
status: 'quote_loading',
chainId: 42220,
hasProvider: true,
inputAmount: '25',
tokenInBalance: '120.00',
},
quoteReady: {
status: 'quote_ready',
chainId: 42220,
hasProvider: true,
inputAmount: '25',
tokenInBalance: '120.00',
quote: {
outputAmount: '108.2500',
price: '0.2310',
minimumReceived: '108.1417',
priceImpactPercent: '~0.01%',
exitContributionPercent: '0%',
},
},
quoteError: {
status: 'quote_error',
chainId: 42220,
hasProvider: true,
inputAmount: '25',
error: 'Reserve quote failed. Try again in a moment.',
},
insufficientBalance: {
status: 'insufficient_balance',
chainId: 42220,
hasProvider: true,
inputAmount: '9999',
tokenInBalance: '120.00',
warning: 'Input exceeds your available token balance.',
},
slippageSelection: {
status: 'slippage_selection',
chainId: 42220,
hasProvider: true,
slippagePercent: 0.5,
},
confirmDialog: {
status: 'confirm_dialog',
chainId: 42220,
hasProvider: true,
inputAmount: '25',
quote: {
outputAmount: '108.2500',
price: '0.2310',
minimumReceived: '108.1417',
priceImpactPercent: '~0.01%',
exitContributionPercent: '0%',
},
},
swapPending: {
status: 'swap_pending',
chainId: 42220,
hasProvider: true,
inputAmount: '25',
quote: {
outputAmount: '108.2500',
price: '0.2310',
minimumReceived: '108.1417',
priceImpactPercent: '~0.01%',
exitContributionPercent: '0%',
},
},
swapSuccess: {
status: 'swap_success',
chainId: 42220,
hasProvider: true,
txHash: '0xabc123',
},
swapError: {
status: 'swap_error',
chainId: 42220,
hasProvider: true,
error: 'Swap reverted due to reserve limits.',
},
sellQuoteReady: {
status: 'quote_ready',
chainId: 42220,
hasProvider: true,
direction: 'sell',
tokenInSymbol: 'G$',
tokenOutSymbol: 'USDm',
tokenInBalance: '300.00',
tokenOutBalance: '84.00',
inputAmount: '40',
quote: {
outputAmount: '8.9231',
price: '4.4820',
minimumReceived: '8.9142',
priceImpactPercent: '~0.02%',
exitContributionPercent: '0%',
},
},
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import React from 'react'
import type { Meta, StoryObj } from '@storybook/react'
import { GoodReserveWidget } from '@goodwidget/goodreserve-widget'
import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193'
import { reserveWidgetMockStates } from '../../fixtures/goodReserveWidgetMock'

const provider = createCustodialEip1193Provider()

const meta: Meta<typeof GoodReserveWidget> = {
title: 'Widgets/GoodReserveWidget',
component: GoodReserveWidget,
tags: ['autodocs'],
parameters: { layout: 'padded' },
}

export default meta
type Story = StoryObj<typeof meta>

// Renders one deterministic reserve state per story for CI-safe widget coverage.
const renderStory = (mockState: Story['args']['mockState'], dataTestId: string) => (
<div data-testid={dataTestId} style={{ width: 380 }}>
<GoodReserveWidget provider={provider} mockState={mockState} />
</div>
)

export const NoProvider: Story = {
render: () => renderStory(reserveWidgetMockStates.noProvider, 'GoodReserveWidget-no-provider'),
}

export const UnsupportedChain: Story = {
render: () =>
renderStory(reserveWidgetMockStates.unsupportedChain, 'GoodReserveWidget-unsupported-chain'),
}

export const IdleBuy: Story = {
render: () => renderStory(reserveWidgetMockStates.idleBuy, 'GoodReserveWidget-idle-buy'),
}

export const AmountEditing: Story = {
render: () => renderStory(reserveWidgetMockStates.amountEditing, 'GoodReserveWidget-amount-editing'),
}

export const QuoteLoading: Story = {
render: () => renderStory(reserveWidgetMockStates.quoteLoading, 'GoodReserveWidget-quote-loading'),
}

export const QuoteReadyBuy: Story = {
render: () => renderStory(reserveWidgetMockStates.quoteReady, 'GoodReserveWidget-quote-ready-buy'),
}

export const QuoteReadySell: Story = {
render: () =>
renderStory(reserveWidgetMockStates.sellQuoteReady, 'GoodReserveWidget-quote-ready-sell'),
}

export const QuoteError: Story = {
render: () => renderStory(reserveWidgetMockStates.quoteError, 'GoodReserveWidget-quote-error'),
}

export const InsufficientBalance: Story = {
render: () =>
renderStory(reserveWidgetMockStates.insufficientBalance, 'GoodReserveWidget-insufficient-balance'),
}

export const SlippageSelection: Story = {
render: () =>
renderStory(reserveWidgetMockStates.slippageSelection, 'GoodReserveWidget-slippage-selection'),
}

export const ConfirmDialog: Story = {
render: () => renderStory(reserveWidgetMockStates.confirmDialog, 'GoodReserveWidget-confirm-dialog'),
}

export const SwapPending: Story = {
render: () => renderStory(reserveWidgetMockStates.swapPending, 'GoodReserveWidget-swap-pending'),
}

export const SwapSuccess: Story = {
render: () => renderStory(reserveWidgetMockStates.swapSuccess, 'GoodReserveWidget-swap-success'),
}

export const SwapError: Story = {
render: () => renderStory(reserveWidgetMockStates.swapError, 'GoodReserveWidget-swap-error'),
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
50 changes: 50 additions & 0 deletions packages/goodreserve-widget/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"name": "@goodwidget/goodreserve-widget",
"version": "0.1.0",
"description": "GoodReserve swap widget for GoodWidget",
"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": {
"@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"
}
}
62 changes: 62 additions & 0 deletions packages/goodreserve-widget/src/GoodReserveWidget.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React, { useEffect } from 'react'
import { GoodWidgetProvider } from '@goodwidget/core'
import type { EIP1193Provider } from '@goodwidget/core'
import { ReserveSwapView } from './ReserveSwapView'
import { useGoodReserveAdapter } from './useGoodReserveAdapter'
import type { ReserveSwapWidgetProps } from './widgetRuntimeContract'

function GoodReserveWidgetInner({
onSwapSuccess,
onSwapError,
mockState,
}: Pick<ReserveSwapWidgetProps, 'onSwapSuccess' | 'onSwapError' | 'mockState'>) {
const adapter = useGoodReserveAdapter(mockState)

// Emits swap lifecycle callbacks for host integrations.
useEffect(() => {
if (adapter.state.status === 'swap_success' && adapter.state.txHash) {
onSwapSuccess?.({
address: adapter.state.address,
chainId: adapter.state.chainId,
transactionHash: adapter.state.txHash,
})
return
}

if (adapter.state.status === 'swap_error' && adapter.state.error) {
onSwapError?.({
address: adapter.state.address,
chainId: adapter.state.chainId,
message: adapter.state.error,
})
}
}, [adapter.state, onSwapError, onSwapSuccess])

return <ReserveSwapView adapter={adapter} />
}

// Public widget entry wired to GoodWidget runtime context + theming contract.
export function GoodReserveWidget({
provider,
config,
themeOverrides,
defaultTheme = 'dark',
onSwapSuccess,
onSwapError,
mockState,
}: ReserveSwapWidgetProps) {
return (
<GoodWidgetProvider
provider={provider as EIP1193Provider | undefined}
config={config}
themeOverrides={themeOverrides}
defaultTheme={defaultTheme}
>
<GoodReserveWidgetInner
onSwapSuccess={onSwapSuccess}
onSwapError={onSwapError}
mockState={mockState}
/>
</GoodWidgetProvider>
)
}
Loading