Skip to content

Commit 2a2773a

Browse files
committed
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
1 parent a1b40bf commit 2a2773a

File tree

15 files changed

+1007
-169
lines changed

15 files changed

+1007
-169
lines changed

apps/staking/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@
2828
"@next/third-parties": "^14.2.5",
2929
"@pythnetwork/hermes-client": "workspace:*",
3030
"@pythnetwork/staking-sdk": "workspace:*",
31+
"@react-aria/toast": "3.0.0-beta.16",
3132
"@react-hookz/web": "^24.0.4",
33+
"@react-stately/toast": "3.0.0-beta.6",
3234
"@solana/wallet-adapter-base": "^0.9.20",
3335
"@solana/wallet-adapter-react": "^0.15.28",
3436
"@solana/wallet-adapter-react-ui": "^0.9.27",

apps/staking/src/components/AccountSummary/index.tsx

Lines changed: 110 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
type ComponentProps,
77
type ReactNode,
88
useCallback,
9+
useState,
910
useMemo,
1011
} from "react";
1112
import {
@@ -16,8 +17,10 @@ import {
1617
import background from "./background.png";
1718
import { type States, StateType as ApiStateType } from "../../hooks/use-api";
1819
import { StateType, useAsync } from "../../hooks/use-async";
20+
import { useToast } from "../../hooks/use-toast";
1921
import { Button } from "../Button";
2022
import { Date } from "../Date";
23+
import { ErrorMessage } from "../ErrorMessage";
2124
import { ModalDialog } from "../ModalDialog";
2225
import { Tokens } from "../Tokens";
2326
import { TransferButton } from "../TransferButton";
@@ -135,6 +138,7 @@ export const AccountSummary = ({
135138
max={walletAmount}
136139
transfer={api.deposit}
137140
submitButtonText="Add tokens"
141+
successMessage="Your tokens have been added to your stake account"
138142
/>
139143
)}
140144
{availableToWithdraw === 0n ? (
@@ -278,13 +282,20 @@ const OisUnstake = ({
278282
() => staked + warmup + cooldown + cooldown2,
279283
[staked, warmup, cooldown, cooldown2],
280284
);
285+
const toast = useToast();
281286
const { state, execute } = useAsync(api.unstakeAllIntegrityStaking);
282287

283288
const doUnstakeAll = useCallback(() => {
284-
execute().catch(() => {
285-
/* TODO figure out a better UI treatment for when claim fails */
286-
});
287-
}, [execute]);
289+
execute()
290+
.then(() => {
291+
toast.success(
292+
"Your tokens are now cooling down and will be available to withdraw at the end of the next epoch",
293+
);
294+
})
295+
.catch((error: unknown) => {
296+
toast.error(error);
297+
});
298+
}, [execute, toast]);
288299

289300
// eslint-disable-next-line unicorn/no-null
290301
return total === 0n ? null : (
@@ -344,7 +355,7 @@ const OisUnstake = ({
344355

345356
type WithdrawButtonProps = Omit<
346357
ComponentProps<typeof TransferButton>,
347-
"variant" | "actionDescription" | "actionName" | "transfer"
358+
"variant" | "actionDescription" | "actionName" | "transfer" | "successMessage"
348359
> & {
349360
api: States[ApiStateType.Loaded] | States[ApiStateType.LoadedNoStakeAccount];
350361
};
@@ -354,6 +365,7 @@ const WithdrawButton = ({ api, ...props }: WithdrawButtonProps) => (
354365
variant="secondary"
355366
actionDescription="Move funds from your account back to your wallet"
356367
actionName="Withdraw"
368+
successMessage="You have withdrawn tokens from your stake account to your wallet"
357369
{...(api.type === ApiStateType.Loaded && {
358370
transfer: api.withdraw,
359371
})}
@@ -419,58 +431,96 @@ const ClaimDialog = ({
419431
expiringRewards,
420432
availableRewards,
421433
}: ClaimDialogProps) => {
434+
const [closeDisabled, setCloseDisabled] = useState(false);
435+
436+
return (
437+
<ModalDialog title="Claim" closeDisabled={closeDisabled}>
438+
{({ close }) => (
439+
<ClaimDialogContents
440+
expiringRewards={expiringRewards}
441+
availableRewards={availableRewards}
442+
api={api}
443+
close={close}
444+
setCloseDisabled={setCloseDisabled}
445+
/>
446+
)}
447+
</ModalDialog>
448+
);
449+
};
450+
451+
type ClaimDialogContentsProps = {
452+
availableRewards: bigint;
453+
expiringRewards: Date | undefined;
454+
api: States[ApiStateType.Loaded];
455+
close: () => void;
456+
setCloseDisabled: (value: boolean) => void;
457+
};
458+
459+
const ClaimDialogContents = ({
460+
api,
461+
expiringRewards,
462+
availableRewards,
463+
close,
464+
setCloseDisabled,
465+
}: ClaimDialogContentsProps) => {
422466
const { state, execute } = useAsync(api.claim);
423467

468+
const toast = useToast();
469+
424470
const doClaim = useCallback(() => {
425-
execute().catch(() => {
426-
/* TODO figure out a better UI treatment for when claim fails */
427-
});
428-
}, [execute]);
471+
setCloseDisabled(true);
472+
execute()
473+
.then(() => {
474+
close();
475+
toast.success("You have claimed your rewards");
476+
})
477+
.catch(() => {
478+
/* no-op since this is already handled in the UI using `state` and is logged in useAsync */
479+
})
480+
.finally(() => {
481+
setCloseDisabled(false);
482+
});
483+
}, [execute, toast]);
429484

430485
return (
431-
<ModalDialog title="Claim">
432-
{({ close }) => (
433-
<>
434-
<p className="mb-4">
435-
Claim your <Tokens>{availableRewards}</Tokens> rewards
436-
</p>
437-
{expiringRewards && (
438-
<div className="mb-4 flex max-w-96 flex-row gap-2 border border-neutral-600/50 bg-pythpurple-400/20 p-4">
439-
<InformationCircleIcon className="size-8 flex-none" />
440-
<div className="text-sm">
441-
Rewards expire one year from the epoch in which they were
442-
earned. You have rewards expiring on{" "}
443-
<Date>{expiringRewards}</Date>.
444-
</div>
445-
</div>
446-
)}
447-
{state.type === StateType.Error && (
448-
<p className="mt-8 text-red-600">
449-
Uh oh, an error occurred! Please try again
450-
</p>
451-
)}
452-
<div className="mt-14 flex flex-col gap-8 sm:flex-row sm:justify-between">
453-
<Button
454-
variant="secondary"
455-
className="w-full sm:w-auto"
456-
size="noshrink"
457-
onPress={close}
458-
>
459-
Cancel
460-
</Button>
461-
<Button
462-
className="w-full sm:w-auto"
463-
size="noshrink"
464-
isDisabled={state.type === StateType.Complete}
465-
isLoading={state.type === StateType.Running}
466-
onPress={doClaim}
467-
>
468-
Claim
469-
</Button>
486+
<>
487+
<p className="mb-4">
488+
Claim your <Tokens>{availableRewards}</Tokens> rewards
489+
</p>
490+
{expiringRewards && (
491+
<div className="mb-4 flex max-w-96 flex-row gap-2 border border-neutral-600/50 bg-pythpurple-400/20 p-4">
492+
<InformationCircleIcon className="size-8 flex-none" />
493+
<div className="text-sm">
494+
Rewards expire one year from the epoch in which they were earned.
495+
You have rewards expiring on <Date>{expiringRewards}</Date>.
470496
</div>
471-
</>
497+
</div>
472498
)}
473-
</ModalDialog>
499+
{state.type === StateType.Error && (
500+
<div className="mt-4 max-w-sm">
501+
<ErrorMessage error={state.error} />
502+
</div>
503+
)}
504+
<div className="mt-14 flex flex-col gap-8 sm:flex-row sm:justify-between">
505+
<Button
506+
variant="secondary"
507+
className="w-full sm:w-auto"
508+
size="noshrink"
509+
onPress={close}
510+
>
511+
Cancel
512+
</Button>
513+
<Button
514+
className="w-full sm:w-auto"
515+
size="noshrink"
516+
isDisabled={state.type === StateType.Complete}
517+
isLoading={state.type === StateType.Running}
518+
onPress={doClaim}
519+
>
520+
Claim
521+
</Button>
522+
</div>
523+
</>
474524
);
475525
};
476526

@@ -484,11 +534,17 @@ type ClaimButtonProps = Omit<
484534
const ClaimButton = ({ api, ...props }: ClaimButtonProps) => {
485535
const { state, execute } = useAsync(api.claim);
486536

537+
const toast = useToast();
538+
487539
const doClaim = useCallback(() => {
488-
execute().catch(() => {
489-
/* TODO figure out a better UI treatment for when claim fails */
490-
});
491-
}, [execute]);
540+
execute()
541+
.then(() => {
542+
toast.success("You have claimed your rewards");
543+
})
544+
.catch((error: unknown) => {
545+
toast.error(error);
546+
});
547+
}, [execute, toast]);
492548

493549
return (
494550
<Button

apps/staking/src/components/Button/index.tsx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import { ArrowPathIcon } from "@heroicons/react/24/outline";
34
import clsx from "clsx";
45
import type { ComponentProps } from "react";
56
import { Button as ReactAriaButton } from "react-aria-components";
@@ -23,18 +24,40 @@ export const Button = ({
2324
size,
2425
isDisabled,
2526
className,
27+
children,
2628
...props
2729
}: ButtonProps) => (
2830
<ReactAriaButton
2931
isDisabled={isLoading === true || isDisabled === true}
3032
className={clsx(
31-
"disabled:border-neutral-50/10 disabled:bg-neutral-50/10 disabled:text-white/60",
33+
"relative text-center disabled:border-neutral-50/10 disabled:bg-neutral-50/10 disabled:text-white/60",
3234
isLoading ? "cursor-wait" : "disabled:cursor-not-allowed",
3335
baseClassName({ variant, size }),
3436
className,
3537
)}
3638
{...props}
37-
/>
39+
>
40+
{(values) => (
41+
<>
42+
<div
43+
className={clsx(
44+
"flex flex-row items-center justify-center gap-[0.5em] transition",
45+
{ "opacity-0": isLoading },
46+
)}
47+
>
48+
{typeof children === "function" ? children(values) : children}
49+
</div>
50+
<div
51+
className={clsx(
52+
"absolute inset-0 grid place-content-center transition",
53+
{ "opacity-0": !isLoading },
54+
)}
55+
>
56+
<ArrowPathIcon className="inline-block size-[1em] animate-spin" />
57+
</div>
58+
</>
59+
)}
60+
</ReactAriaButton>
3861
);
3962

4063
type LinkButtonProps = ComponentProps<typeof Link> & VariantProps;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { ChevronRightIcon } from "@heroicons/react/24/outline";
2+
import { WalletError } from "@solana/wallet-adapter-base";
3+
import clsx from "clsx";
4+
import { LazyMotion, m, domAnimation } from "framer-motion";
5+
import { useCallback, useMemo, useState } from "react";
6+
import { Button } from "react-aria-components";
7+
8+
export const ErrorMessage = ({ error }: { error: unknown }) => {
9+
return error instanceof WalletError ? (
10+
<p className="text-red-600">
11+
The transaction was rejected by your wallet. Please check your wallet and
12+
try again.
13+
</p>
14+
) : (
15+
<UnknownError error={error} />
16+
);
17+
};
18+
19+
const UnknownError = ({ error }: { error: unknown }) => {
20+
const [detailsOpen, setDetailsOpen] = useState(false);
21+
22+
const toggleDetailsOpen = useCallback(() => {
23+
setDetailsOpen((cur) => !cur);
24+
}, [setDetailsOpen]);
25+
26+
const message = useMemo(() => {
27+
if (error instanceof Error) {
28+
return error.toString();
29+
} else if (typeof error === "string") {
30+
return error;
31+
} else {
32+
return "An unknown error occurred";
33+
}
34+
}, [error]);
35+
36+
return (
37+
<LazyMotion features={domAnimation}>
38+
<Button onPress={toggleDetailsOpen} className="text-left">
39+
<div className="text-red-600">
40+
Uh oh, an error occurred! Please try again
41+
</div>
42+
<div className="flex flex-row items-center gap-[0.25em] text-xs opacity-60">
43+
<div>Details</div>
44+
<ChevronRightIcon
45+
className={clsx("inline-block size-[1em] transition-transform", {
46+
"rotate-90": detailsOpen,
47+
})}
48+
/>
49+
</div>
50+
</Button>
51+
<m.div
52+
className="overflow-hidden pt-1 opacity-60"
53+
initial={{ height: 0 }}
54+
animate={{ height: detailsOpen ? "auto" : 0 }}
55+
>
56+
{message}
57+
</m.div>
58+
</LazyMotion>
59+
);
60+
};

apps/staking/src/components/ModalDialog/index.tsx

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,16 @@ export const ModalDialog = ({
4646
{(options) => (
4747
<>
4848
{!noClose && (
49-
<Button
50-
onPress={options.close}
51-
className="absolute right-3 top-3 grid size-10 place-content-center"
52-
size="nopad"
53-
isDisabled={closeDisabled ?? false}
54-
>
55-
<XMarkIcon className="size-6" />
56-
</Button>
49+
<div className="absolute right-3 top-3">
50+
<Button
51+
onPress={options.close}
52+
className="size-10"
53+
size="nopad"
54+
isDisabled={closeDisabled ?? false}
55+
>
56+
<XMarkIcon className="size-6" />
57+
</Button>
58+
</div>
5759
)}
5860
<Heading
5961
className={clsx("mr-10 text-3xl font-light", {

0 commit comments

Comments
 (0)