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(staking): improve feedback #1999

Merged
merged 1 commit into from
Oct 7, 2024
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
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
Loading