From 2a2773a0a678e9c5654292b92864c87ac9017067 Mon Sep 17 00:00:00 2001 From: Connor Prussin Date: Sun, 6 Oct 2024 18:12:20 -0700 Subject: [PATCH] feat(staking): improve feedback This PR adds better feedback to the staking app. Included are: - Better error messages - Toasts on success / failure (failure toasts only show where there isn't a modal to display the error message) - Buttons go into a spinner state when isLoading is true --- apps/staking/package.json | 2 + .../src/components/AccountSummary/index.tsx | 164 ++++-- apps/staking/src/components/Button/index.tsx | 27 +- .../src/components/ErrorMessage/index.tsx | 60 ++ .../src/components/ModalDialog/index.tsx | 18 +- .../src/components/NoWalletHome/index.tsx | 12 +- .../OracleIntegrityStaking/index.tsx | 139 +++-- .../src/components/ProgramSection/index.tsx | 3 + apps/staking/src/components/Root/index.tsx | 7 +- .../src/components/Root/toast-region.tsx | 108 ++++ apps/staking/src/components/Select/index.tsx | 5 +- .../src/components/TransferButton/index.tsx | 24 +- .../src/components/WalletButton/index.tsx | 5 +- apps/staking/src/hooks/use-toast.tsx | 73 +++ pnpm-lock.yaml | 529 +++++++++++++++++- 15 files changed, 1007 insertions(+), 169 deletions(-) create mode 100644 apps/staking/src/components/ErrorMessage/index.tsx create mode 100644 apps/staking/src/components/Root/toast-region.tsx create mode 100644 apps/staking/src/hooks/use-toast.tsx diff --git a/apps/staking/package.json b/apps/staking/package.json index ee722a591..7bf4dcf86 100644 --- a/apps/staking/package.json +++ b/apps/staking/package.json @@ -28,7 +28,9 @@ "@next/third-parties": "^14.2.5", "@pythnetwork/hermes-client": "workspace:*", "@pythnetwork/staking-sdk": "workspace:*", + "@react-aria/toast": "3.0.0-beta.16", "@react-hookz/web": "^24.0.4", + "@react-stately/toast": "3.0.0-beta.6", "@solana/wallet-adapter-base": "^0.9.20", "@solana/wallet-adapter-react": "^0.15.28", "@solana/wallet-adapter-react-ui": "^0.9.27", diff --git a/apps/staking/src/components/AccountSummary/index.tsx b/apps/staking/src/components/AccountSummary/index.tsx index 6f4605265..8a55a1e47 100644 --- a/apps/staking/src/components/AccountSummary/index.tsx +++ b/apps/staking/src/components/AccountSummary/index.tsx @@ -6,6 +6,7 @@ import { type ComponentProps, type ReactNode, useCallback, + useState, useMemo, } from "react"; import { @@ -16,8 +17,10 @@ import { import background from "./background.png"; import { type States, StateType as ApiStateType } from "../../hooks/use-api"; import { StateType, useAsync } from "../../hooks/use-async"; +import { useToast } from "../../hooks/use-toast"; import { Button } from "../Button"; import { Date } from "../Date"; +import { ErrorMessage } from "../ErrorMessage"; import { ModalDialog } from "../ModalDialog"; import { Tokens } from "../Tokens"; import { TransferButton } from "../TransferButton"; @@ -135,6 +138,7 @@ export const AccountSummary = ({ max={walletAmount} transfer={api.deposit} submitButtonText="Add tokens" + successMessage="Your tokens have been added to your stake account" /> )} {availableToWithdraw === 0n ? ( @@ -278,13 +282,20 @@ const OisUnstake = ({ () => staked + warmup + cooldown + cooldown2, [staked, warmup, cooldown, cooldown2], ); + const toast = useToast(); const { state, execute } = useAsync(api.unstakeAllIntegrityStaking); const doUnstakeAll = useCallback(() => { - execute().catch(() => { - /* TODO figure out a better UI treatment for when claim fails */ - }); - }, [execute]); + execute() + .then(() => { + toast.success( + "Your tokens are now cooling down and will be available to withdraw at the end of the next epoch", + ); + }) + .catch((error: unknown) => { + toast.error(error); + }); + }, [execute, toast]); // eslint-disable-next-line unicorn/no-null return total === 0n ? null : ( @@ -344,7 +355,7 @@ const OisUnstake = ({ type WithdrawButtonProps = Omit< ComponentProps, - "variant" | "actionDescription" | "actionName" | "transfer" + "variant" | "actionDescription" | "actionName" | "transfer" | "successMessage" > & { api: States[ApiStateType.Loaded] | States[ApiStateType.LoadedNoStakeAccount]; }; @@ -354,6 +365,7 @@ const WithdrawButton = ({ api, ...props }: WithdrawButtonProps) => ( variant="secondary" actionDescription="Move funds from your account back to your wallet" actionName="Withdraw" + successMessage="You have withdrawn tokens from your stake account to your wallet" {...(api.type === ApiStateType.Loaded && { transfer: api.withdraw, })} @@ -419,58 +431,96 @@ const ClaimDialog = ({ expiringRewards, availableRewards, }: ClaimDialogProps) => { + const [closeDisabled, setCloseDisabled] = useState(false); + + return ( + + {({ close }) => ( + + )} + + ); +}; + +type ClaimDialogContentsProps = { + availableRewards: bigint; + expiringRewards: Date | undefined; + api: States[ApiStateType.Loaded]; + close: () => void; + setCloseDisabled: (value: boolean) => void; +}; + +const ClaimDialogContents = ({ + api, + expiringRewards, + availableRewards, + close, + setCloseDisabled, +}: ClaimDialogContentsProps) => { const { state, execute } = useAsync(api.claim); + const toast = useToast(); + const doClaim = useCallback(() => { - execute().catch(() => { - /* TODO figure out a better UI treatment for when claim fails */ - }); - }, [execute]); + setCloseDisabled(true); + execute() + .then(() => { + close(); + toast.success("You have claimed your rewards"); + }) + .catch(() => { + /* no-op since this is already handled in the UI using `state` and is logged in useAsync */ + }) + .finally(() => { + setCloseDisabled(false); + }); + }, [execute, toast]); return ( - - {({ close }) => ( - <> -

- Claim your {availableRewards} rewards -

- {expiringRewards && ( -
- -
- Rewards expire one year from the epoch in which they were - earned. You have rewards expiring on{" "} - {expiringRewards}. -
-
- )} - {state.type === StateType.Error && ( -

- Uh oh, an error occurred! Please try again -

- )} -
- - + <> +

+ Claim your {availableRewards} rewards +

+ {expiringRewards && ( +
+ +
+ Rewards expire one year from the epoch in which they were earned. + You have rewards expiring on {expiringRewards}.
- +
)} - + {state.type === StateType.Error && ( +
+ +
+ )} +
+ + +
+ ); }; @@ -484,11 +534,17 @@ type ClaimButtonProps = Omit< const ClaimButton = ({ api, ...props }: ClaimButtonProps) => { const { state, execute } = useAsync(api.claim); + const toast = useToast(); + const doClaim = useCallback(() => { - execute().catch(() => { - /* TODO figure out a better UI treatment for when claim fails */ - }); - }, [execute]); + execute() + .then(() => { + toast.success("You have claimed your rewards"); + }) + .catch((error: unknown) => { + toast.error(error); + }); + }, [execute, toast]); return ( + + {message} + + + ); +}; diff --git a/apps/staking/src/components/ModalDialog/index.tsx b/apps/staking/src/components/ModalDialog/index.tsx index 4455064e3..7997cfbc0 100644 --- a/apps/staking/src/components/ModalDialog/index.tsx +++ b/apps/staking/src/components/ModalDialog/index.tsx @@ -46,14 +46,16 @@ export const ModalDialog = ({ {(options) => ( <> {!noClose && ( - +
+ +
)} { Choose Your Journey

You can participate in both programs.

- @@ -183,11 +179,7 @@ export const NoWalletHome = () => { Staking or Pyth Governance.

- diff --git a/apps/staking/src/components/OracleIntegrityStaking/index.tsx b/apps/staking/src/components/OracleIntegrityStaking/index.tsx index f99a413d8..3ce8c33cc 100644 --- a/apps/staking/src/components/OracleIntegrityStaking/index.tsx +++ b/apps/staking/src/components/OracleIntegrityStaking/index.tsx @@ -38,8 +38,10 @@ import { StateType as UseAsyncStateType, useAsync, } from "../../hooks/use-async"; +import { useToast } from "../../hooks/use-toast"; import { Button, LinkButton } from "../Button"; import { CopyButton } from "../CopyButton"; +import { ErrorMessage } from "../ErrorMessage"; import { Menu, MenuItem, Section, Separator } from "../Menu"; import { ModalDialog } from "../ModalDialog"; import { OracleIntegrityStakingGuide } from "../OracleIntegrityStakingGuide"; @@ -198,10 +200,7 @@ const SelfStaking = ({
- - -
- + )}
); }; +type OptOutModalContentsProps = { + api: States[ApiStateType.Loaded]; + self: PublisherProps["publisher"]; + close: () => void; +}; + +const OptOutModalContents = ({ + api, + self, + close, +}: OptOutModalContentsProps) => { + const { state, execute } = useAsync(() => + api.optPublisherOut(self.publicKey), + ); + + const toast = useToast(); + + const doOptOut = useCallback(() => { + execute() + .then(() => { + toast.success("You have opted out of rewards"); + }) + .catch(() => { + /* no-op since this is already handled in the UI using `state` and is logged in useAsync */ + }); + }, [execute, toast]); + + return ( + <> +
+

+ Are you sure you want to opt out of rewards? +

+

+ Opting out of rewards will prevent you from earning the publisher + yield rate and delegation fees from your delegators. You will still be + able to participate in OIS after opting out of rewards. +

+
+ {state.type === UseAsyncStateType.Error && ( +
+ +
+ )} +
+ + +
+ + ); +}; + type PublisherListProps = { api: States[ApiStateType.Loaded] | States[ApiStateType.LoadedNoStakeAccount]; currentEpoch: bigint; @@ -851,7 +871,7 @@ const Paginator = ({ currentPage, numPages, onPageChange }: PaginatorProps) => { }} size="nopad" variant="secondary" - className="grid size-8 place-content-center" + className="size-8" > @@ -874,7 +894,7 @@ const Paginator = ({ currentPage, numPages, onPageChange }: PaginatorProps) => { }} size="nopad" variant="secondary" - className="grid size-8 place-content-center" + className="size-8" > {page} @@ -889,7 +909,7 @@ const Paginator = ({ currentPage, numPages, onPageChange }: PaginatorProps) => { }} size="nopad" variant="secondary" - className="grid size-8 place-content-center" + className="size-8" > @@ -1422,6 +1442,7 @@ const YourPositionsTable = ({ actionName="Cancel" submitButtonText="Cancel Warmup" title="Cancel Warmup" + successMessage="Your tokens are no longer in warmup for staking" max={warmup} transfer={cancelWarmup} /> @@ -1456,6 +1477,7 @@ const YourPositionsTable = ({ } actionName="Unstake" + successMessage="Your tokens are now cooling down and will be available to withdraw at the end of the next epoch" max={staked} transfer={unstake} > @@ -1505,6 +1527,7 @@ const StakeToPublisherButton = ({ actionName="Stake" max={availableToStake} transfer={delegate} + successMessage="Your tokens are now in warm up and will be staked at the start of the next epoch" > {(amount) => ( <> diff --git a/apps/staking/src/components/ProgramSection/index.tsx b/apps/staking/src/components/ProgramSection/index.tsx index c5febaa20..e3c2acf0c 100644 --- a/apps/staking/src/components/ProgramSection/index.tsx +++ b/apps/staking/src/components/ProgramSection/index.tsx @@ -145,6 +145,7 @@ const TokenOverview = ({ actionName="Stake" max={available} transfer={stake} + successMessage="Your tokens are now in warm up and will be staked at the start of the next epoch" > @@ -175,6 +176,7 @@ const TokenOverview = ({ title="Cancel Warmup" max={warmup} transfer={cancelWarmup} + successMessage="Your tokens are no longer in warmup for staking" /> ), })} @@ -194,6 +196,7 @@ const TokenOverview = ({ actionName="Unstake" max={staked} transfer={unstake} + successMessage="Your tokens are now cooling down and will be available to withdraw at the end of the next epoch" > diff --git a/apps/staking/src/components/Root/index.tsx b/apps/staking/src/components/Root/index.tsx index cc261b642..0216f9f31 100644 --- a/apps/staking/src/components/Root/index.tsx +++ b/apps/staking/src/components/Root/index.tsx @@ -5,6 +5,7 @@ import type { ReactNode, CSSProperties, HTMLProps } from "react"; import { I18nProvider } from "./i18n-provider"; import { RestrictedRegionBanner } from "./restricted-region-banner"; +import { ToastRegion } from "./toast-region"; import { IS_PRODUCTION_SERVER, GOOGLE_ANALYTICS_ID, @@ -16,6 +17,7 @@ import { import { ApiProvider } from "../../hooks/use-api"; import { LoggerProvider } from "../../hooks/use-logger"; import { NetworkProvider } from "../../hooks/use-network"; +import { ToastProvider } from "../../hooks/use-toast"; import { Amplitude } from "../Amplitude"; import { Footer } from "../Footer"; import { Header } from "../Header"; @@ -60,6 +62,7 @@ export const Root = ({ children }: Props) => ( {children}