index.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import { Field, Input, Label } from "@headlessui/react";
  2. import {
  3. type ChangeEvent,
  4. type ComponentProps,
  5. type ReactNode,
  6. useCallback,
  7. useMemo,
  8. useState,
  9. } from "react";
  10. import type { Context } from "../../api";
  11. import { useLogger } from "../../hooks/use-logger";
  12. import { StateType, useTransfer } from "../../hooks/use-transfer";
  13. import { stringToTokens, tokensToString } from "../../tokens";
  14. import { Button } from "../Button";
  15. import { Modal, ModalButton, ModalPanel } from "../Modal";
  16. import { Tokens } from "../Tokens";
  17. import PythTokensIcon from "../Tokens/pyth.svg";
  18. type Props = {
  19. actionName: string;
  20. actionDescription: string;
  21. title?: string | undefined;
  22. submitButtonText?: string | undefined;
  23. max: bigint;
  24. children?:
  25. | ((amount: Amount) => ReactNode | ReactNode[])
  26. | ReactNode
  27. | ReactNode[]
  28. | undefined;
  29. transfer: (context: Context, amount: bigint) => Promise<void>;
  30. className?: string | undefined;
  31. secondary?: boolean | undefined;
  32. small?: boolean | undefined;
  33. disabled?: boolean | undefined;
  34. };
  35. export const TransferButton = ({
  36. actionName,
  37. submitButtonText,
  38. actionDescription,
  39. title,
  40. max,
  41. transfer,
  42. children,
  43. className,
  44. secondary,
  45. small,
  46. disabled,
  47. }: Props) => {
  48. const { amountInput, setAmount, updateAmount, resetAmount, amount } =
  49. useAmountInput(max);
  50. const doTransfer = useCallback(
  51. (context: Context) =>
  52. amount.type === AmountType.Valid
  53. ? transfer(context, amount.amount)
  54. : Promise.reject(new InvalidAmountError()),
  55. [amount, transfer],
  56. );
  57. const setMax = useCallback(() => {
  58. setAmount(tokensToString(max));
  59. }, [setAmount, max]);
  60. const { state, execute } = useTransfer(doTransfer);
  61. const isSubmitting = state.type === StateType.Submitting;
  62. return (
  63. <Modal>
  64. <ModalButton
  65. className={className}
  66. secondary={secondary}
  67. small={small}
  68. disabled={disabled}
  69. >
  70. {actionName}
  71. </ModalButton>
  72. <ModalPanel
  73. title={title ?? actionName}
  74. closeDisabled={isSubmitting}
  75. description={actionDescription}
  76. afterLeave={resetAmount}
  77. >
  78. {(close) => (
  79. <>
  80. <Field className="mb-8 flex w-full min-w-96 flex-col gap-1">
  81. <div className="flex flex-row items-center justify-between">
  82. <Label className="text-sm">Amount</Label>
  83. <div className="flex flex-row items-center gap-2">
  84. <Tokens>{max}</Tokens>
  85. <span className="text-xs opacity-60">Max</span>
  86. </div>
  87. </div>
  88. <div className="relative w-full">
  89. <Input
  90. name="amount"
  91. className="w-full truncate border border-neutral-600/50 bg-transparent py-3 pl-12 pr-24 focus:outline-none focus-visible:ring-1 focus-visible:ring-pythpurple-400"
  92. value={amountInput}
  93. onChange={updateAmount}
  94. placeholder="0.00"
  95. />
  96. <div className="pointer-events-none absolute inset-y-0 flex w-full items-center justify-between px-4">
  97. <PythTokensIcon className="size-6" />
  98. <Button
  99. small
  100. secondary
  101. className="pointer-events-auto"
  102. onClick={setMax}
  103. disabled={isSubmitting}
  104. >
  105. max
  106. </Button>
  107. </div>
  108. </div>
  109. {state.type === StateType.Error && (
  110. <p>Uh oh, an error occurred!</p>
  111. )}
  112. </Field>
  113. {children && (
  114. <>
  115. {typeof children === "function" ? children(amount) : children}
  116. </>
  117. )}
  118. <ExecuteButton
  119. amount={amount}
  120. execute={execute}
  121. loading={isSubmitting}
  122. close={close}
  123. className="mt-6 w-full"
  124. >
  125. {submitButtonText ?? actionName}
  126. </ExecuteButton>
  127. </>
  128. )}
  129. </ModalPanel>
  130. </Modal>
  131. );
  132. };
  133. type ExecuteButtonProps = Omit<
  134. ComponentProps<typeof Button>,
  135. "onClick" | "disabled" | "children"
  136. > & {
  137. children: ReactNode | ReactNode[];
  138. amount: Amount;
  139. execute: () => Promise<void>;
  140. close: () => void;
  141. };
  142. const ExecuteButton = ({
  143. amount,
  144. execute,
  145. close,
  146. children,
  147. ...props
  148. }: ExecuteButtonProps) => {
  149. const logger = useLogger();
  150. const handleClick = useCallback(async () => {
  151. try {
  152. await execute();
  153. close();
  154. } catch (error: unknown) {
  155. logger.error(error);
  156. }
  157. }, [execute, close, logger]);
  158. const contents = useMemo(() => {
  159. switch (amount.type) {
  160. case AmountType.Empty: {
  161. return "Enter an amount";
  162. }
  163. case AmountType.AboveMax: {
  164. return "Amount exceeds maximum";
  165. }
  166. case AmountType.NotPositive: {
  167. return "Amount must be greater than zero";
  168. }
  169. case AmountType.Invalid: {
  170. return "Enter a valid amount";
  171. }
  172. case AmountType.Valid: {
  173. return children;
  174. }
  175. }
  176. }, [amount, children]);
  177. return (
  178. <Button
  179. disabled={amount.type !== AmountType.Valid}
  180. onClick={handleClick}
  181. {...props}
  182. >
  183. {contents}
  184. </Button>
  185. );
  186. };
  187. const useAmountInput = (max: bigint) => {
  188. const [amountInput, setAmountInput] = useState<string>("");
  189. return {
  190. amountInput,
  191. setAmount: setAmountInput,
  192. updateAmount: useCallback(
  193. (event: ChangeEvent<HTMLInputElement>) => {
  194. setAmountInput(event.target.value);
  195. },
  196. [setAmountInput],
  197. ),
  198. resetAmount: useCallback(() => {
  199. setAmountInput("");
  200. }, [setAmountInput]),
  201. amount: useMemo((): Amount => {
  202. if (amountInput === "") {
  203. return Amount.Empty();
  204. } else {
  205. const amountAsTokens = stringToTokens(amountInput);
  206. if (amountAsTokens === undefined) {
  207. return Amount.Invalid();
  208. } else if (amountAsTokens > max) {
  209. return Amount.AboveMax(amountAsTokens);
  210. } else if (amountAsTokens <= 0) {
  211. return Amount.NotPositive(amountAsTokens);
  212. } else {
  213. return Amount.Valid(amountAsTokens);
  214. }
  215. }
  216. }, [amountInput, max]),
  217. };
  218. };
  219. export enum AmountType {
  220. Empty,
  221. NotPositive,
  222. Valid,
  223. Invalid,
  224. AboveMax,
  225. }
  226. const Amount = {
  227. Empty: () => ({ type: AmountType.Empty as const }),
  228. NotPositive: (amount: bigint) => ({
  229. type: AmountType.NotPositive as const,
  230. amount,
  231. }),
  232. Valid: (amount: bigint) => ({ type: AmountType.Valid as const, amount }),
  233. Invalid: () => ({ type: AmountType.Invalid as const }),
  234. AboveMax: (amount: bigint) => ({
  235. type: AmountType.AboveMax as const,
  236. amount,
  237. }),
  238. };
  239. type Amount = ReturnType<(typeof Amount)[keyof typeof Amount]>;
  240. class InvalidAmountError extends Error {
  241. constructor() {
  242. super("Invalid amount");
  243. }
  244. }