| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299 |
- import {
- type ComponentProps,
- type ReactNode,
- type FormEvent,
- useCallback,
- useMemo,
- useState,
- } from "react";
- import {
- DialogTrigger,
- TextField,
- Label,
- Input,
- Form,
- Group,
- } from "react-aria-components";
- import { StateType, useAsync } from "../../hooks/use-async";
- import { stringToTokens, tokensToString } from "../../tokens";
- import { Button } from "../Button";
- import { ModalDialog } from "../ModalDialog";
- import { Tokens } from "../Tokens";
- import PythTokensIcon from "../Tokens/pyth.svg";
- type Props = Omit<ComponentProps<typeof Button>, "children"> & {
- enableWithZeroMax?: boolean | undefined;
- actionName: ReactNode;
- actionDescription: ReactNode;
- title?: ReactNode | undefined;
- submitButtonText?: ReactNode | undefined;
- max: bigint;
- children?:
- | ((amount: Amount) => ReactNode | ReactNode[])
- | ReactNode
- | ReactNode[]
- | undefined;
- transfer?: ((amount: bigint) => Promise<void>) | undefined;
- };
- export const TransferButton = ({
- enableWithZeroMax,
- actionName,
- submitButtonText,
- actionDescription,
- title,
- max,
- transfer,
- children,
- isDisabled,
- ...props
- }: Props) => {
- return transfer === undefined ||
- isDisabled === true ||
- (max === 0n && !enableWithZeroMax) ? (
- <Button isDisabled={true} {...props}>
- {actionName}
- </Button>
- ) : (
- <DialogTrigger>
- <Button {...props}>{actionName}</Button>
- <TransferDialog
- title={title ?? actionName}
- description={actionDescription}
- max={max}
- transfer={transfer}
- submitButtonText={submitButtonText ?? actionName}
- >
- {children}
- </TransferDialog>
- </DialogTrigger>
- );
- };
- type TransferDialogProps = Omit<
- ComponentProps<typeof ModalDialog>,
- "children"
- > & {
- max: bigint;
- transfer: (amount: bigint) => Promise<void>;
- submitButtonText: ReactNode;
- children?:
- | ((amount: Amount) => ReactNode | ReactNode[])
- | ReactNode
- | ReactNode[]
- | undefined;
- };
- export const TransferDialog = ({
- max,
- transfer,
- submitButtonText,
- children,
- ...props
- }: TransferDialogProps) => {
- const [closeDisabled, setCloseDisabled] = useState(false);
- return (
- <ModalDialog closeDisabled={closeDisabled} {...props}>
- {({ close }) => (
- <DialogContents
- max={max}
- transfer={transfer}
- setCloseDisabled={setCloseDisabled}
- submitButtonText={submitButtonText}
- close={close}
- >
- {children}
- </DialogContents>
- )}
- </ModalDialog>
- );
- };
- type DialogContentsProps = {
- max: bigint;
- children: Props["children"];
- transfer: (amount: bigint) => Promise<void>;
- setCloseDisabled: (value: boolean) => void;
- submitButtonText: ReactNode;
- close: () => void;
- };
- const DialogContents = ({
- max,
- transfer,
- children,
- submitButtonText,
- setCloseDisabled,
- close,
- }: DialogContentsProps) => {
- const { amount, setAmount, setMax, stringValue } = useAmountInput(max);
- const validationError = useMemo(() => {
- switch (amount.type) {
- case AmountType.Empty: {
- return "Enter an amount";
- }
- case AmountType.AboveMax: {
- return "Amount exceeds maximum";
- }
- case AmountType.NotPositive: {
- return "Amount must be greater than zero";
- }
- case AmountType.Invalid: {
- return "Enter a valid amount";
- }
- case AmountType.Valid: {
- return;
- }
- }
- }, [amount]);
- const doTransfer = useCallback(
- () =>
- amount.type === AmountType.Valid
- ? transfer(amount.amount)
- : Promise.reject(new InvalidAmountError()),
- [amount, transfer],
- );
- const { execute, state } = useAsync(doTransfer);
- const handleSubmit = useCallback(
- (e: FormEvent<HTMLFormElement>) => {
- e.preventDefault();
- setCloseDisabled(true);
- execute()
- .then(() => {
- close();
- })
- .catch(() => {
- /* no-op since this is already handled in the UI using `state` and is logged in useTransfer */
- })
- .finally(() => {
- setCloseDisabled(false);
- });
- },
- [execute, close, setCloseDisabled],
- );
- return (
- <Form onSubmit={handleSubmit}>
- <TextField
- // eslint-disable-next-line jsx-a11y/no-autofocus
- autoFocus
- isInvalid={validationError !== undefined}
- value={stringValue}
- onChange={setAmount}
- validationBehavior="aria"
- name="amount"
- className="mb-8 flex w-full flex-col gap-1 sm:min-w-96"
- >
- <div className="flex flex-row items-center justify-between">
- <Label className="text-sm">Amount</Label>
- <div className="flex flex-row items-center gap-2">
- <Tokens>{max}</Tokens>
- <span className="text-xs opacity-60">Max</span>
- </div>
- </div>
- <Group className="relative w-full">
- <Input
- required
- className="focused:outline-none focused:ring-0 focused:border-pythpurple-400 w-full truncate border border-neutral-600/50 bg-transparent py-3 pl-12 pr-24 focus:border-pythpurple-400 focus:outline-none focus:ring-0 focus-visible:border-pythpurple-400 focus-visible:outline-none focus-visible:ring-0"
- placeholder="0.00"
- />
- <div className="pointer-events-none absolute inset-y-0 flex w-full items-center justify-between px-4">
- <PythTokensIcon className="size-6" />
- <Button
- size="small"
- variant="secondary"
- className="pointer-events-auto"
- onPress={setMax}
- isDisabled={state.type === StateType.Running}
- >
- max
- </Button>
- </div>
- </Group>
- {state.type === StateType.Error && (
- <p className="mt-1 text-red-600">
- Uh oh, an error occurred! Please try again
- </p>
- )}
- </TextField>
- {children && (
- <>{typeof children === "function" ? children(amount) : children}</>
- )}
- <Button
- className="mt-6 w-full"
- type="submit"
- isLoading={state.type === StateType.Running}
- isDisabled={amount.type !== AmountType.Valid}
- >
- {validationError ?? submitButtonText}
- </Button>
- </Form>
- );
- };
- const useAmountInput = (max: bigint) => {
- const [stringValue, setAmount] = useState<string>("");
- return {
- stringValue,
- setAmount,
- setMax: useCallback(() => {
- setAmount(tokensToString(max));
- }, [setAmount, max]),
- amount: useMemo((): Amount => {
- if (stringValue === "") {
- return Amount.Empty();
- } else {
- const amountAsTokens = stringToTokens(stringValue);
- if (amountAsTokens === undefined) {
- return Amount.Invalid();
- } else if (amountAsTokens > max) {
- return Amount.AboveMax(amountAsTokens);
- } else if (amountAsTokens <= 0) {
- return Amount.NotPositive(amountAsTokens);
- } else {
- return Amount.Valid(amountAsTokens);
- }
- }
- }, [stringValue, max]),
- };
- };
- export enum AmountType {
- Empty,
- NotPositive,
- Valid,
- Invalid,
- AboveMax,
- }
- const Amount = {
- Empty: () => ({ type: AmountType.Empty as const }),
- NotPositive: (amount: bigint) => ({
- type: AmountType.NotPositive as const,
- amount,
- }),
- Valid: (amount: bigint) => ({ type: AmountType.Valid as const, amount }),
- Invalid: () => ({ type: AmountType.Invalid as const }),
- AboveMax: (amount: bigint) => ({
- type: AmountType.AboveMax as const,
- amount,
- }),
- };
- type Amount = ReturnType<(typeof Amount)[keyof typeof Amount]>;
- class InvalidAmountError extends Error {
- constructor() {
- super("Invalid amount");
- }
- }
|