Skip to content

Commit

Permalink
Merge pull request #1999 from cprussin/improve-errors
Browse files Browse the repository at this point in the history
feat(staking): improve feedback
  • Loading branch information
cprussin authored Oct 7, 2024
2 parents a1b40bf + 2a2773a commit 8ebdcf2
Show file tree
Hide file tree
Showing 15 changed files with 1,007 additions and 169 deletions.
2 changes: 2 additions & 0 deletions apps/staking/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
164 changes: 110 additions & 54 deletions apps/staking/src/components/AccountSummary/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type ComponentProps,
type ReactNode,
useCallback,
useState,
useMemo,
} from "react";
import {
Expand All @@ -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";
Expand Down Expand Up @@ -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 ? (
Expand Down Expand Up @@ -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 : (
Expand Down Expand Up @@ -344,7 +355,7 @@ const OisUnstake = ({

type WithdrawButtonProps = Omit<
ComponentProps<typeof TransferButton>,
"variant" | "actionDescription" | "actionName" | "transfer"
"variant" | "actionDescription" | "actionName" | "transfer" | "successMessage"
> & {
api: States[ApiStateType.Loaded] | States[ApiStateType.LoadedNoStakeAccount];
};
Expand All @@ -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,
})}
Expand Down Expand Up @@ -419,58 +431,96 @@ const ClaimDialog = ({
expiringRewards,
availableRewards,
}: ClaimDialogProps) => {
const [closeDisabled, setCloseDisabled] = useState(false);

return (
<ModalDialog title="Claim" closeDisabled={closeDisabled}>
{({ close }) => (
<ClaimDialogContents
expiringRewards={expiringRewards}
availableRewards={availableRewards}
api={api}
close={close}
setCloseDisabled={setCloseDisabled}
/>
)}
</ModalDialog>
);
};

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 (
<ModalDialog title="Claim">
{({ close }) => (
<>
<p className="mb-4">
Claim your <Tokens>{availableRewards}</Tokens> rewards
</p>
{expiringRewards && (
<div className="mb-4 flex max-w-96 flex-row gap-2 border border-neutral-600/50 bg-pythpurple-400/20 p-4">
<InformationCircleIcon className="size-8 flex-none" />
<div className="text-sm">
Rewards expire one year from the epoch in which they were
earned. You have rewards expiring on{" "}
<Date>{expiringRewards}</Date>.
</div>
</div>
)}
{state.type === StateType.Error && (
<p className="mt-8 text-red-600">
Uh oh, an error occurred! Please try again
</p>
)}
<div className="mt-14 flex flex-col gap-8 sm:flex-row sm:justify-between">
<Button
variant="secondary"
className="w-full sm:w-auto"
size="noshrink"
onPress={close}
>
Cancel
</Button>
<Button
className="w-full sm:w-auto"
size="noshrink"
isDisabled={state.type === StateType.Complete}
isLoading={state.type === StateType.Running}
onPress={doClaim}
>
Claim
</Button>
<>
<p className="mb-4">
Claim your <Tokens>{availableRewards}</Tokens> rewards
</p>
{expiringRewards && (
<div className="mb-4 flex max-w-96 flex-row gap-2 border border-neutral-600/50 bg-pythpurple-400/20 p-4">
<InformationCircleIcon className="size-8 flex-none" />
<div className="text-sm">
Rewards expire one year from the epoch in which they were earned.
You have rewards expiring on <Date>{expiringRewards}</Date>.
</div>
</>
</div>
)}
</ModalDialog>
{state.type === StateType.Error && (
<div className="mt-4 max-w-sm">
<ErrorMessage error={state.error} />
</div>
)}
<div className="mt-14 flex flex-col gap-8 sm:flex-row sm:justify-between">
<Button
variant="secondary"
className="w-full sm:w-auto"
size="noshrink"
onPress={close}
>
Cancel
</Button>
<Button
className="w-full sm:w-auto"
size="noshrink"
isDisabled={state.type === StateType.Complete}
isLoading={state.type === StateType.Running}
onPress={doClaim}
>
Claim
</Button>
</div>
</>
);
};

Expand All @@ -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 (
<Button
Expand Down
27 changes: 25 additions & 2 deletions apps/staking/src/components/Button/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { ArrowPathIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import type { ComponentProps } from "react";
import { Button as ReactAriaButton } from "react-aria-components";
Expand All @@ -23,18 +24,40 @@ export const Button = ({
size,
isDisabled,
className,
children,
...props
}: ButtonProps) => (
<ReactAriaButton
isDisabled={isLoading === true || isDisabled === true}
className={clsx(
"disabled:border-neutral-50/10 disabled:bg-neutral-50/10 disabled:text-white/60",
"relative text-center disabled:border-neutral-50/10 disabled:bg-neutral-50/10 disabled:text-white/60",
isLoading ? "cursor-wait" : "disabled:cursor-not-allowed",
baseClassName({ variant, size }),
className,
)}
{...props}
/>
>
{(values) => (
<>
<div
className={clsx(
"flex flex-row items-center justify-center gap-[0.5em] transition",
{ "opacity-0": isLoading },
)}
>
{typeof children === "function" ? children(values) : children}
</div>
<div
className={clsx(
"absolute inset-0 grid place-content-center transition",
{ "opacity-0": !isLoading },
)}
>
<ArrowPathIcon className="inline-block size-[1em] animate-spin" />
</div>
</>
)}
</ReactAriaButton>
);

type LinkButtonProps = ComponentProps<typeof Link> & VariantProps;
Expand Down
60 changes: 60 additions & 0 deletions apps/staking/src/components/ErrorMessage/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { ChevronRightIcon } from "@heroicons/react/24/outline";
import { WalletError } from "@solana/wallet-adapter-base";
import clsx from "clsx";
import { LazyMotion, m, domAnimation } from "framer-motion";
import { useCallback, useMemo, useState } from "react";
import { Button } from "react-aria-components";

export const ErrorMessage = ({ error }: { error: unknown }) => {
return error instanceof WalletError ? (
<p className="text-red-600">
The transaction was rejected by your wallet. Please check your wallet and
try again.
</p>
) : (
<UnknownError error={error} />
);
};

const UnknownError = ({ error }: { error: unknown }) => {
const [detailsOpen, setDetailsOpen] = useState(false);

const toggleDetailsOpen = useCallback(() => {
setDetailsOpen((cur) => !cur);
}, [setDetailsOpen]);

const message = useMemo(() => {
if (error instanceof Error) {
return error.toString();
} else if (typeof error === "string") {
return error;
} else {
return "An unknown error occurred";
}
}, [error]);

return (
<LazyMotion features={domAnimation}>
<Button onPress={toggleDetailsOpen} className="text-left">
<div className="text-red-600">
Uh oh, an error occurred! Please try again
</div>
<div className="flex flex-row items-center gap-[0.25em] text-xs opacity-60">
<div>Details</div>
<ChevronRightIcon
className={clsx("inline-block size-[1em] transition-transform", {
"rotate-90": detailsOpen,
})}
/>
</div>
</Button>
<m.div
className="overflow-hidden pt-1 opacity-60"
initial={{ height: 0 }}
animate={{ height: detailsOpen ? "auto" : 0 }}
>
{message}
</m.div>
</LazyMotion>
);
};
18 changes: 10 additions & 8 deletions apps/staking/src/components/ModalDialog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,16 @@ export const ModalDialog = ({
{(options) => (
<>
{!noClose && (
<Button
onPress={options.close}
className="absolute right-3 top-3 grid size-10 place-content-center"
size="nopad"
isDisabled={closeDisabled ?? false}
>
<XMarkIcon className="size-6" />
</Button>
<div className="absolute right-3 top-3">
<Button
onPress={options.close}
className="size-10"
size="nopad"
isDisabled={closeDisabled ?? false}
>
<XMarkIcon className="size-6" />
</Button>
</div>
)}
<Heading
className={clsx("mr-10 text-3xl font-light", {
Expand Down
Loading

0 comments on commit 8ebdcf2

Please sign in to comment.