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
3 changes: 2 additions & 1 deletion examples/storybook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react-native-web": "^0.19.13",
"viem": "^2.0.0"
"viem": "^2.0.0",
"@goodwidget/buy-gd-widget": "workspace:*"
},
"devDependencies": {
"@storybook/addon-essentials": "^8.6.17",
Expand Down
54 changes: 54 additions & 0 deletions examples/storybook/src/fixtures/buyGdWidgetMock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { BuyGdWidgetState } from '@goodwidget/buy-gd-widget'

export const buyGdWidgetMockStates: Record<string, Partial<BuyGdWidgetState>> = {
noWallet: {
status: 'no_wallet',
hasProvider: false,
chainId: null,
address: null,
},
idle: {
status: 'idle',
hasProvider: true,
chainId: 42220,
address: '0x1111111111111111111111111111111111111111',
fiatAmount: '100',
stableMinAmount: '0',
currency: 'USD',
},
loading: {
status: 'loading',
hasProvider: true,
chainId: 42220,
address: '0x1111111111111111111111111111111111111111',
},
onramper: {
status: 'onramper',
hasProvider: true,
chainId: 42220,
address: '0x1111111111111111111111111111111111111111',
fiatAmount: '120',
currency: 'EUR',
},
transactionPending: {
status: 'transaction_pending',
hasProvider: true,
chainId: 42220,
address: '0x1111111111111111111111111111111111111111',
txHash: '0xabc1230000000000000000000000000000000000000000000000000000000000',
},
success: {
status: 'success',
hasProvider: true,
chainId: 42220,
address: '0x1111111111111111111111111111111111111111',
txHash: '0xabc1230000000000000000000000000000000000000000000000000000000000',
},
error: {
status: 'error',
hasProvider: true,
chainId: 42220,
address: '0x1111111111111111111111111111111111111111',
error: 'Onramper payout failed. Retry the buy flow.',
},
}
113 changes: 113 additions & 0 deletions examples/storybook/src/stories/buy-gd-widget/BuyGdWidget.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React from 'react'
import type { Meta, StoryObj } from '@storybook/react'
import {
BuyGdWidget,
type BuyGdWidgetAdapterFactory,
type BuyGdWidgetAdapterResult,
type BuyGdWidgetState,
} from '@goodwidget/buy-gd-widget'
import { createCustodialEip1193Provider } from '../../fixtures/custodialEip1193'
import { buyGdWidgetMockStates } from '../../fixtures/buyGdWidgetMock'
import {
getInjectedEip1193Provider,
isInjectedProviderUsable,
} from '../../fixtures/injectedEip1193'

const provider = createCustodialEip1193Provider()

const meta: Meta<typeof BuyGdWidget> = {
title: 'QA/BuyGdWidget/Runtime Fixtures',
component: BuyGdWidget,
tags: ['autodocs', 'qa'],
parameters: {
layout: 'padded',
},
}

export default meta
type Story = StoryObj<typeof meta>

function createAdapterFactory(mockState: Partial<BuyGdWidgetState>): BuyGdWidgetAdapterFactory {
return () => {
const state: BuyGdWidgetState = {
status: 'idle',
chainId: 42220,
address: '0x1111111111111111111111111111111111111111',
hasProvider: true,
fiatAmount: '100',
stableMinAmount: '0',
currency: 'USD',
error: null,
txHash: null,
...mockState,
}

const actions: BuyGdWidgetAdapterResult['actions'] = {
connect: async () => {},
openOnramper: () => {},
setFiatAmount: () => {},
setStableMinAmount: () => {},
setCurrency: () => {},
startBuy: async () => {},
retry: () => {},
refresh: async () => {},
}

return { state, actions }
}
}

function renderStory(state: Partial<BuyGdWidgetState>) {
return <BuyGdWidget provider={provider} adapterFactory={createAdapterFactory(state)} />
}

export const NoWallet: Story = {
render: () => renderStory(buyGdWidgetMockStates.noWallet),
}

export const Idle: Story = {
render: () => renderStory(buyGdWidgetMockStates.idle),
}

export const Loading: Story = {
render: () => renderStory(buyGdWidgetMockStates.loading),
}

export const Onramper: Story = {
render: () => renderStory(buyGdWidgetMockStates.onramper),
}

export const TransactionPending: Story = {
render: () => renderStory(buyGdWidgetMockStates.transactionPending),
}

export const Success: Story = {
render: () => renderStory(buyGdWidgetMockStates.success),
}

export const Error: Story = {
render: () => renderStory(buyGdWidgetMockStates.error),
}

function InjectedWalletStory() {
const injectedProvider = getInjectedEip1193Provider()

if (!isInjectedProviderUsable(injectedProvider)) {
return (
<div data-testid="BuyGdWidget-no-wallet" style={{ padding: 16, maxWidth: 420 }}>
<strong>No injected wallet found</strong>
<p>Install/enable MetaMask (or another EIP-1193 wallet), then refresh Storybook.</p>
</div>
)
}

return (
<div data-testid="BuyGdWidget-injected-wallet" style={{ width: 420 }}>
<BuyGdWidget provider={injectedProvider} onramperOnlyCryptos="USDC,cUSD" />
</div>
)
}

export const InjectedWallet: Story = {
render: () => <InjectedWalletStory />,
}
51 changes: 51 additions & 0 deletions packages/buy-gd-widget/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"name": "@goodwidget/buy-gd-widget",
"version": "0.1.0",
"description": "Buy G$ widget for fiat onramp flow",
"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": {
"@gooddollar/web3sdk-v2": "0.4.44",
"@goodwidget/core": "workspace:*",
"@goodwidget/embed": "workspace:*",
"@goodwidget/ui": "workspace:*",
"@usedapp/core": "1.2.16"
},
"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"
}
}
112 changes: 112 additions & 0 deletions packages/buy-gd-widget/src/BuyGdView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import React from 'react'
import { Button, ButtonText, Card, Input, Spinner, Text, XStack, YStack } from '@goodwidget/ui'
import type { BuyGdWidgetAdapterResult } from './widgetRuntimeContract'

function OnramperFrame({ url }: { url: string }) {
return (
<Card bordered data-testid="BuyGdWidget-onramper-frame">
<iframe
data-testid="BuyGdWidget-onramper-iframe"
src={url}
title="Onramper fiat onramp"
style={{ width: '100%', minHeight: 420, border: 0, borderRadius: 12 }}
/>
</Card>
)
}

export function BuyGdView({
adapter,
onramperUrl,
}: {
adapter: BuyGdWidgetAdapterResult
onramperUrl: string
}) {
const { state, actions } = adapter

return (
<YStack data-testid="BuyGdWidget-root" width="100%" maxWidth={420} gap="$4">
<Card bordered data-testid="BuyGdWidget-shell">
<YStack gap="$3">
<Text variant="title">Buy G$</Text>
<Text secondary>Buy G$ with fiat using Onramper and finalize conversion on Celo.</Text>

<XStack gap="$2" alignItems="center">
<Input
value={state.fiatAmount}
onChangeText={actions.setFiatAmount}
aria-label="Fiat amount"
placeholder="100"
/>
<Input
value={state.currency}
onChangeText={actions.setCurrency}
aria-label="Fiat currency"
placeholder="USD"
/>
</XStack>

<Input
value={state.stableMinAmount}
onChangeText={actions.setStableMinAmount}
aria-label="Minimum stable amount"
placeholder="0"
/>

{state.status === 'no_wallet' && (
<>
<Text color="$warning">Connect a wallet to continue.</Text>
<Button onPress={() => void actions.connect()} data-testid="BuyGdWidget-connect-cta">
<ButtonText>Connect Wallet</ButtonText>
</Button>
</>
)}

{state.status !== 'no_wallet' && (
<Button onPress={actions.openOnramper} data-testid="BuyGdWidget-open-onramper-cta">
<ButtonText>Open Onramper</ButtonText>
</Button>
)}

{state.status === 'onramper' && (
<Button onPress={() => void actions.startBuy()} data-testid="BuyGdWidget-start-buy-cta">
<ButtonText>I completed Onramper - convert to G$</ButtonText>
</Button>
)}

{(state.status === 'loading' || state.status === 'transaction_pending') && (
<XStack gap="$2" alignItems="center">
<Spinner />
<Text>
{state.status === 'loading' ? 'Preparing transaction…' : 'Transaction pending…'}
</Text>
</XStack>
)}

{state.status === 'success' && (
<YStack gap="$2" data-testid="BuyGdWidget-success-state">
<Text color="$success">Success! G$ was sent to your wallet.</Text>
{state.txHash && <Text secondary>Transaction: {state.txHash}</Text>}
</YStack>
)}

{state.status === 'error' && (
<YStack gap="$2" data-testid="BuyGdWidget-error-state">
<Text color="$error">{state.error ?? 'Buy flow failed.'}</Text>
<XStack gap="$2">
<Button onPress={actions.retry} data-testid="BuyGdWidget-retry-cta">
<ButtonText>Retry</ButtonText>
</Button>
<Button onPress={() => void actions.refresh()} data-testid="BuyGdWidget-refresh-cta">
<ButtonText>Refresh</ButtonText>
</Button>
</XStack>
</YStack>
)}
</YStack>
</Card>

{state.status === 'onramper' && <OnramperFrame url={onramperUrl} />}
</YStack>
)
}
Loading