Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add WalletSend component #78

Merged
merged 6 commits into from
Jan 7, 2025
Merged
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
55 changes: 28 additions & 27 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,28 +1,29 @@
{
"name": "web3-circle-libs",
"version": "0.1.0",
"private": true,
"workspaces": [
"packages/*"
],
"description": "Web3.js circle plugin and web3 circle UI components lib",
"homepage": "https://github.com/ChainSafe/web3-circle-libs#readme",
"bugs": {
"url": "https://github.com/ChainSafe/web3-circle-libs/issues"
},
"contributors": [
"ChainSafe <[email protected]>"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "[email protected]:ChainSafe/web3-circle-libs.git"
},
"scripts": {
"lint": "yarn workspace circle-demo-webapp lint",
"build": "yarn workspace circle-demo-webapp build",
"dev": "yarn workspace circle-demo-webapp dev",
"start": "yarn workspace circle-demo-webapp start"
},
"packageManager": "[email protected]"
}
"name": "web3-circle-libs",
"version": "0.1.0",
"private": true,
"workspaces": [
"packages/*"
],
"description": "Web3.js circle plugin and web3 circle UI components lib",
"homepage": "https://github.com/ChainSafe/web3-circle-libs#readme",
"bugs": {
"url": "https://github.com/ChainSafe/web3-circle-libs/issues"
},
"contributors": [
"ChainSafe <[email protected]>"
],
"license": "MIT",
"repository": {
"type": "git",
"url": "[email protected]:ChainSafe/web3-circle-libs.git"
},
"scripts": {
"lint": "yarn workspace circle-demo-webapp lint",
"build": "yarn workspace circle-demo-webapp build",
"dev": "yarn workspace circle-demo-webapp dev",
"start": "yarn workspace circle-demo-webapp start"
},
"packageManager": "[email protected]"
}

Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,15 @@ const BLOCKCHAIN_LABELS: Record<string, string> = {
[Blockchain.Sol]: 'Solana',
};

export type ChainSelectProps = Omit<SelectProps, 'children'>;
export type ChainSelectProps = Omit<SelectProps, 'children'> & { placeholder?: string };

/** A dropdown select menu to choose a mainnet blockchain network */
export function ChainSelect({ ...props }: ChainSelectProps) {
const { placeholder = 'Select Network', ...other } = props;
return (
<Select {...props}>
<Select {...other}>
<SelectTrigger className="w-full max-w-md">
<SelectValue placeholder="Select Network" />
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{Object.keys(BLOCKCHAIN_LABELS).map((blockchain) => (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/react';

import { TokenSelect } from './TokenSelect';

const meta = {
title: 'TokenSelect',
component: TokenSelect,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
} satisfies Meta<typeof TokenSelect>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
balances: [],
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { SelectProps } from '@radix-ui/react-select';

import { TokenSelectItem } from '~/components/TokenSelect/TokenSelectItem';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '~/components/ui/select';
import { WalletTokenBalance } from '~/lib/types';

export type TokenSelectProps = Omit<SelectProps, 'children'> & {
placeholder?: string;
balances: WalletTokenBalance[];
};

/** A dropdown select menu to choose a token */
export function TokenSelect({ ...props }: TokenSelectProps) {
const { placeholder = 'Select Token', balances = [], ...other } = props;
return (
<Select {...other}>
<SelectTrigger className="w-full">
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{balances.map((balance) => (
<SelectItem key={balance.token.id} value={balance.token.id}>
<TokenSelectItem balance={balance} />
</SelectItem>
))}
</SelectContent>
</Select>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { TokenIcon } from '@web3icons/react';

import { WalletTokenBalance } from '~/lib/types';

export interface TokenSelectItemProps {
/** The balance details */
balance: WalletTokenBalance;
}

/** A token balance for an on-chain account */
export function TokenSelectItem({ balance }: TokenSelectItemProps) {
return (
<div className="flex items-center space-x-4">
<TokenIcon
symbol={balance.token.symbol.split('-')[0]}
size={40}
variant="branded"
className="flex-shrink-0"
/>

<div>
<p className="text-base font-medium text-gray-900">
{balance.amount} {balance.token.symbol}
</p>
</div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './TokenSelect';
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import {
CreateTransactionInput,
GetTransactionInput,
WalletState,
} from '@circle-fin/developer-controlled-wallets';
import type { Meta, StoryObj } from '@storybook/react';

import { Blockchain } from '~/lib/constants';
import { Transaction } from '~/lib/types';

import { WalletSend } from './WalletSend';

const meta = {
title: 'WalletSend',
component: WalletSend,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
} satisfies Meta<typeof WalletSend>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
balances: [],
wallet: {
id: 'f5576d55-4432-5dcc-8b3c-582bd530b46b',
state: WalletState.Live,
walletSetId: '2adf744c-2d31-58ca-85eb-d432ecc7611c',
custodyType: 'DEVELOPER',
refId: '',
name: 'My Wallet',
address: '0xc9758de68b17837dadf51616ac77d634bca848d5',
blockchain: Blockchain.MaticAmoy,
accountType: 'EOA',
updateDate: '2024-12-09T14:38:51Z',
createDate: '2024-12-09T14:38:51Z',
},
onSendTransaction: (data: CreateTransactionInput) =>
Promise.resolve(data as unknown as Transaction),
onGetTransaction: (data: GetTransactionInput) =>
Promise.resolve({ transaction: data as unknown as Transaction }),
},
};
141 changes: 141 additions & 0 deletions packages/circle-demo-webapp/app/components/WalletSend/WalletSend.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import {
CreateTransactionInput,
GetTransactionInput,
} from '@circle-fin/developer-controlled-wallets';
import { Form } from '@remix-run/react';
import { LoaderCircle } from 'lucide-react';
import { useState } from 'react';

import { TokenSelect } from '~/components/TokenSelect';
import { Button } from '~/components/ui/button';
import { Input } from '~/components/ui/input';
import { Textarea } from '~/components/ui/textarea';
import { FeeLevel } from '~/lib/constants';
import { Transaction, Wallet, WalletTokenBalance } from '~/lib/types';
import { isValidString } from '~/lib/utils';

export interface WalletSendProps {
/** The wallet */
wallet: Wallet;
balances: WalletTokenBalance[];
onSendTransaction: (data: CreateTransactionInput) => Promise<Transaction>;
onGetTransaction: (data: GetTransactionInput) => Promise<{ transaction: Transaction }>;
onConfirmed?: (data: Transaction) => Promise<void>;
}

/**
* Helpers for obtaining a wallet's on-chain address:
* a QR code that encodes the address and elements for viewing the address and copying it to the clipboard
*/
export function WalletSend({
wallet,
balances,
onSendTransaction,
onGetTransaction,
onConfirmed,
}: WalletSendProps) {
const [transactionData, setTransactionData] = useState({} as Transaction);

// @todo: use constant exported from sdk
const isTransactionPending = (tx: Transaction) =>
Boolean(tx?.state && !['CONFIRMED', 'CONFIRMED'].includes(tx?.state));

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();

const form = e.currentTarget;
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
if (!isValidString(data.recipientAddress)) {
throw new Error('Invalid recipient address');
}
if (!isValidString(data.amount) || !(Number(data.amount) > 0)) {
throw new Error('Invalid amount');
}
if (!isValidString(data.tokenId)) {
throw new Error('Invalid token');
}
if (!isValidString(data.walletId)) {
throw new Error('Invalid wallet');
}

if (data.note && !isValidString(data.note)) {
throw new Error('Invalid note');
}

const res = await onSendTransaction({
destinationAddress: data.recipientAddress,
amounts: [data.amount],
tokenId: data.tokenId,
walletId: data.walletId,
refId: data.note,
fee: {
type: 'level',
config: {
feeLevel: FeeLevel.Medium,
},
},
});
setTransactionData({ state: res.state } as Transaction);
if (res.id) {
const interval = setInterval(() => {
const run = async () => {
const { transaction } = await onGetTransaction({ id: res.id });
setTransactionData(transaction);
if (transaction && !isTransactionPending(transaction)) {
clearInterval(interval);
if (typeof onConfirmed === 'function') {
await onConfirmed(transaction);
}
}
};
run().catch(console.error);
}, 1000);
}
};
return (
<div className="items-center w-full">
<Form
method="post"
className="w-full"
onSubmit={(e) => {
handleSubmit(e).catch(console.error);
}}
>
<div className="w-ful mt-6">
<Input
type="text"
name="recipientAddress"
placeholder="Recipient Address"
className="col-span-3"
/>
</div>
<div className="w-full mt-6">
<TokenSelect name="tokenId" balances={balances} />
</div>
<div className="w-full mt-6">
<Input type="text" name="amount" placeholder="Amount" className="col-span-3" />
</div>
<div className="w-full mt-6">
<Textarea
type="text"
name="note"
placeholder="Note(optional)"
className="col-span-3 min-h-[100px]"
/>
</div>
<Input type="hidden" name="walletId" value={wallet.id} />
<Button
type="submit"
className="mt-6 w-full"
disabled={isTransactionPending(transactionData)}
>
{isTransactionPending(transactionData) && (
<LoaderCircle className="animate-spin" />
)}
Send
</Button>
</Form>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './WalletSend';
26 changes: 26 additions & 0 deletions packages/circle-demo-webapp/app/components/ui/textarea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as React from 'react';

import { cn } from '~/lib/utils';

interface TextareaProps extends React.ComponentProps<'textarea'> {
className?: string;
type?: string;
}

const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = 'Textarea';

export { Textarea };
5 changes: 5 additions & 0 deletions packages/circle-demo-webapp/app/lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,8 @@ export const TransactionType = {
Inbound: 'INBOUND',
Outbound: 'OUTBOUND',
};
export const FeeLevel = {
High: 'HIGH',
Medium: 'MEDIUM',
Low: 'LOW',
} as const;
Loading
Loading