From d4b52d70fc870a34ed86845b2c147d08f3376d53 Mon Sep 17 00:00:00 2001 From: Alduin Date: Mon, 9 Sep 2024 12:28:54 -0300 Subject: [PATCH] Add notifications --- frontend/package.json | 1 + frontend/pnpm-lock.yaml | 34 +++++++ frontend/src/api/mutations/PlaceBet.ts | 13 ++- frontend/src/app.tsx | 8 +- .../src/assets/icons/ErrorNotification.tsx | 21 ++++ .../src/assets/icons/SuccessNotification.tsx | 19 ++++ frontend/src/assets/icons/Tick.tsx | 15 +++ .../src/assets/icons/WarningNotification.tsx | 18 ++++ .../common/ConnectionModal/index.tsx | 5 +- .../common/Navbar/WalletButton/Menu/index.tsx | 4 +- .../lib/Copyable/ErrorButton/index.tsx | 36 +++++++ .../components/lib/Copyable/Text/index.tsx | 34 +++++++ .../lib/Notification/Content/index.tsx | 98 +++++++++++++++++++ frontend/src/config/notifications.tsx | 69 +++++++++++++ .../components/MarketOutcomes/index.tsx | 2 +- frontend/src/main.tsx | 23 +++-- frontend/src/utils/hooks.ts | 55 ++++++++++- frontend/vite.config.ts | 10 +- 18 files changed, 437 insertions(+), 28 deletions(-) create mode 100644 frontend/src/assets/icons/ErrorNotification.tsx create mode 100644 frontend/src/assets/icons/SuccessNotification.tsx create mode 100644 frontend/src/assets/icons/Tick.tsx create mode 100644 frontend/src/assets/icons/WarningNotification.tsx create mode 100644 frontend/src/components/lib/Copyable/ErrorButton/index.tsx create mode 100644 frontend/src/components/lib/Copyable/Text/index.tsx create mode 100644 frontend/src/components/lib/Notification/Content/index.tsx create mode 100644 frontend/src/config/notifications.tsx diff --git a/frontend/package.json b/frontend/package.json index fe7e62b..1bf549b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,7 @@ "bignumber.js": "^9.1.2", "graz": "^0.1.19", "jdenticon": "^3.3.0", + "notistack": "^3.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-error-boundary": "^4.0.13", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 70e7a27..f79ba1b 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: jdenticon: specifier: ^3.3.0 version: 3.3.0 + notistack: + specifier: ^3.0.1 + version: 3.0.1(csstype@3.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: specifier: ^18.3.1 version: 18.3.1 @@ -1582,6 +1585,10 @@ packages: cliui@6.0.0: resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} + clsx@2.1.1: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} @@ -1946,6 +1953,11 @@ packages: globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + goober@2.1.14: + resolution: {integrity: sha512-4UpC0NdGyAFqLNPnhCT2iHpza2q+RAY3GV85a/mRPdzyPQMsj0KmMMuetdIkzWRbJ+Hgau1EZztq8ImmiMGhsg==} + peerDependencies: + csstype: ^3.0.10 + gopd@1.0.1: resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} @@ -2335,6 +2347,13 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + notistack@3.0.1: + resolution: {integrity: sha512-ntVZXXgSQH5WYfyU+3HfcXuKaapzAJ8fBLQ/G618rn3yvSzEbnOB8ZSOwhX+dAORy/lw+GC2N061JA0+gYWTVA==} + engines: {node: '>=12.0.0', npm: '>=6.0.0'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + npm-run-path@5.3.0: resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -5103,6 +5122,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 6.2.0 + clsx@1.2.1: {} + clsx@2.1.1: {} color-convert@1.9.3: @@ -5516,6 +5537,10 @@ snapshots: globrex@0.1.2: {} + goober@2.1.14(csstype@3.1.3): + dependencies: + csstype: 3.1.3 + gopd@1.0.1: dependencies: get-intrinsic: 1.2.4 @@ -5951,6 +5976,15 @@ snapshots: normalize-path@3.0.0: {} + notistack@3.0.1(csstype@3.1.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + clsx: 1.2.1 + goober: 2.1.14(csstype@3.1.3) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - csstype + npm-run-path@5.3.0: dependencies: path-key: 4.0.0 diff --git a/frontend/src/api/mutations/PlaceBet.ts b/frontend/src/api/mutations/PlaceBet.ts index e0424f0..8a1e8b4 100644 --- a/frontend/src/api/mutations/PlaceBet.ts +++ b/frontend/src/api/mutations/PlaceBet.ts @@ -4,11 +4,12 @@ import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate' import { useCurrentAccount } from '@config/chain' import { NTRN_DENOM } from '@config/environment' +import { useNotifications } from '@config/notifications' import { querierAwaitCacheAnd, querierBroadcastAndWait } from '@api/querier' import { MarketId, OutcomeId } from '@api/queries/Market' import { POSITIONS_KEYS } from '@api/queries/Positions' import { NTRN } from '@utils/tokens' -import { errorsMiddleware } from '@utils/errors' +import { AppError, errorsMiddleware } from '@utils/errors' interface PlaceBetRequest { deposit: { @@ -50,6 +51,7 @@ const usePlaceBet = (marketId: MarketId) => { const account = useCurrentAccount() const signer = useCosmWasmSigningClient() const queryClient = useQueryClient() + const notifications = useNotifications() const mutation = useMutation({ mutationKey: PLACE_BET_KEYS.market(account.bech32Address, marketId), @@ -60,11 +62,18 @@ const usePlaceBet = (marketId: MarketId) => { return Promise.reject() } }, - onSuccess: () => { + onSuccess: (_, args) => { + notifications.notifySuccess(`Successfully bet ${args.ntrnAmount.toFormat(true)}.`) + return querierAwaitCacheAnd( () => queryClient.invalidateQueries({ queryKey: POSITIONS_KEYS.market(account.bech32Address, marketId)}), ) }, + onError: (err, args) => { + notifications.notifyError( + AppError.withCause(`Failed to bet ${args.ntrnAmount.toFormat(true)}.`, err) + ) + }, }) return mutation diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 97bb018..938ff36 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -1,8 +1,8 @@ -import { Stack } from "@mui/joy" -import { Outlet, ScrollRestoration } from "react-router-dom" +import { Stack } from '@mui/joy' +import { Outlet, ScrollRestoration } from 'react-router-dom' -import { Navbar } from "@common/Navbar" -import { Footer } from "@common/Footer" +import { Navbar } from '@common/Navbar' +import { Footer } from '@common/Footer' const App = () => { return ( diff --git a/frontend/src/assets/icons/ErrorNotification.tsx b/frontend/src/assets/icons/ErrorNotification.tsx new file mode 100644 index 0000000..fea3b08 --- /dev/null +++ b/frontend/src/assets/icons/ErrorNotification.tsx @@ -0,0 +1,21 @@ +import { SvgIcon, SvgIconProps } from '@mui/joy' + +const ErrorNotificationIcon = (props: SvgIconProps) => { + const Svg = () => ( + + + + + + + + ) + + return ( + + + + ) +} + +export { ErrorNotificationIcon } diff --git a/frontend/src/assets/icons/SuccessNotification.tsx b/frontend/src/assets/icons/SuccessNotification.tsx new file mode 100644 index 0000000..2638f2a --- /dev/null +++ b/frontend/src/assets/icons/SuccessNotification.tsx @@ -0,0 +1,19 @@ +import { SvgIcon, SvgIconProps } from '@mui/joy' + +const SuccessNotificationIcon = (props: SvgIconProps) => { + const Svg = () => ( + + + + + + ) + + return ( + + + + ) +} + +export { SuccessNotificationIcon } diff --git a/frontend/src/assets/icons/Tick.tsx b/frontend/src/assets/icons/Tick.tsx new file mode 100644 index 0000000..7723ce0 --- /dev/null +++ b/frontend/src/assets/icons/Tick.tsx @@ -0,0 +1,15 @@ +import { SvgIcon, SvgIconProps } from '@mui/joy' + +const TickIcon = (props: SvgIconProps) => { + const Svg = () => ( + + ) + + return ( + + + + ) +} + +export { TickIcon } diff --git a/frontend/src/assets/icons/WarningNotification.tsx b/frontend/src/assets/icons/WarningNotification.tsx new file mode 100644 index 0000000..ad54657 --- /dev/null +++ b/frontend/src/assets/icons/WarningNotification.tsx @@ -0,0 +1,18 @@ +import { SvgIcon, SvgIconProps } from '@mui/joy' + +const WarningNotificationIcon = (props: SvgIconProps) => { + const Svg = () => ( + + + + + ) + + return ( + + + + ) +} + +export { WarningNotificationIcon } diff --git a/frontend/src/components/common/ConnectionModal/index.tsx b/frontend/src/components/common/ConnectionModal/index.tsx index b5b486c..6d444f8 100644 --- a/frontend/src/components/common/ConnectionModal/index.tsx +++ b/frontend/src/components/common/ConnectionModal/index.tsx @@ -3,7 +3,9 @@ import { WalletType, checkWallet, useConnect } from 'graz' import { Button, DialogContent, DialogTitle, ModalClose, ModalDialog, Sheet } from '@mui/joy' import { CHAIN_INFO } from '@config/chain' +import { useNotifications } from '@config/notifications' import { dismiss, present } from '@state/modals' +import { AppError } from '@utils/errors' const CONNECTION_MODAL_KEY = "connection_modal" @@ -41,6 +43,7 @@ const supportedWallets: WalletOption[] = [ const ConnectionModal = () => { const { connectAsync } = useConnect() + const notifications = useNotifications() return ( @@ -64,7 +67,7 @@ const ConnectionModal = () => { .then(() => { dismissConnectionModal() }) .catch(err => { if (!(err instanceof Error && err.message === "Request rejected") && !(err instanceof Error && err.message === "User closed wallet connect")) { - // notifications.notifyError(AppError.withCause(`Failed to connect with ${wallet.name}`, err)) + notifications.notifyError(AppError.withCause(`Failed to connect with ${wallet.name}`, err)) } if (wallet.type === WalletType.WALLETCONNECT) { diff --git a/frontend/src/components/common/Navbar/WalletButton/Menu/index.tsx b/frontend/src/components/common/Navbar/WalletButton/Menu/index.tsx index bc9a80b..1ae8893 100644 --- a/frontend/src/components/common/Navbar/WalletButton/Menu/index.tsx +++ b/frontend/src/components/common/Navbar/WalletButton/Menu/index.tsx @@ -14,7 +14,7 @@ const NavbarWalletMenu = (props: NavbarWalletMenuProps) => { const { ...menuProps } = props const account = useCurrentAccount() const { disconnect } = useDisconnect() - const copy = useCopyToClipboard() + const [, copy] = useCopyToClipboard() return ( { > { copy(account.bech32Address) }} + onClick={() => { copy(account.bech32Address, abbreviateWalletAddress(account.bech32Address)) }} > diff --git a/frontend/src/components/lib/Copyable/ErrorButton/index.tsx b/frontend/src/components/lib/Copyable/ErrorButton/index.tsx new file mode 100644 index 0000000..9aa422b --- /dev/null +++ b/frontend/src/components/lib/Copyable/ErrorButton/index.tsx @@ -0,0 +1,36 @@ +import { Button, ButtonProps } from '@mui/joy' + +import { CopyIcon } from '@assets/icons/Copy' +import { TickIcon } from '@assets/icons/Tick' +import { getErrorReport } from '@utils/errors' +import { mergeSx } from '@utils/styles' +import { useCopyToClipboard } from '@utils/hooks' + +interface CopyableErrorButtonProps extends Omit { + error: T, +} + +const CopyableErrorButton = (props: CopyableErrorButtonProps) => { + const { error, ...buttonProps } = props + const [copied, copy] = useCopyToClipboard() + + return ( + + ) +} + +export { CopyableErrorButton, type CopyableErrorButtonProps } diff --git a/frontend/src/components/lib/Copyable/Text/index.tsx b/frontend/src/components/lib/Copyable/Text/index.tsx new file mode 100644 index 0000000..fbd46d7 --- /dev/null +++ b/frontend/src/components/lib/Copyable/Text/index.tsx @@ -0,0 +1,34 @@ +import { Typography, TypographyProps } from '@mui/joy' + +import { mergeSx } from '@utils/styles' +import { useCopyToClipboard } from '@utils/hooks' + +interface CopyableTextProps extends Omit { + text: string, + notificationText?: string, +} + +const CopyableText = (props: CopyableTextProps) => { + const { text, notificationText, ...typographyProps } = props + const [, copy] = useCopyToClipboard() + + return ( + { + copy(text, notificationText ?? text) + }} + {...typographyProps} + sx={mergeSx( + { + cursor: "pointer", + py: 1, + }, + typographyProps.sx, + )} + > + {text} + + ) +} + +export { CopyableText, type CopyableTextProps} diff --git a/frontend/src/components/lib/Notification/Content/index.tsx b/frontend/src/components/lib/Notification/Content/index.tsx new file mode 100644 index 0000000..3ac1623 --- /dev/null +++ b/frontend/src/components/lib/Notification/Content/index.tsx @@ -0,0 +1,98 @@ +import { forwardRef } from 'react' +import { match } from 'ts-pattern' +import { Box, IconButton, Sheet, Stack, Typography } from '@mui/joy' +import { CustomContentProps, SnackbarContent } from 'notistack' + +import { CloseIcon } from '@assets/icons/Close' +import { SuccessNotificationIcon } from '@assets/icons/SuccessNotification' +import { WarningNotificationIcon } from '@assets/icons/WarningNotification' +import { ErrorNotificationIcon } from '@assets/icons/ErrorNotification' +import { useNotifications } from '@config/notifications' +import { useScreenSmallerThan } from '@utils/styles' + +interface NotificationContentProps extends CustomContentProps { + extraInfo?: string, +} + +const NotificationContent = forwardRef((props, ref) => { + const { id, message, variant, action, extraInfo } = props + const notifications = useNotifications() + const isSmallScreen = useScreenSmallerThan("md") + + return ( + + theme.palette.background.level3, + width: "100%", + }} + > + + {match(variant) + .with("success", () => ) + .with("warning", () => ) + .with("error", () => ) + .exhaustive() + } + + + + {message} + + + {extraInfo && + + {extraInfo} + + } + + {typeof action === "function" ? action(id) : action} + + + + + + notifications.dismiss(id)} + > + + + + + ) +}) + +export { NotificationContent } diff --git a/frontend/src/config/notifications.tsx b/frontend/src/config/notifications.tsx new file mode 100644 index 0000000..4cfaa50 --- /dev/null +++ b/frontend/src/config/notifications.tsx @@ -0,0 +1,69 @@ +import { PropsWithChildren, useCallback } from 'react' +import { OptionsObject, SnackbarKey, SnackbarMessage, SnackbarProvider, useSnackbar } from 'notistack' + +import { AppError, displayError } from '@utils/errors' +import { NotificationContent } from '@lib/Notification/Content' +import { CopyableErrorButton } from '@lib/Copyable/ErrorButton' + +declare module "notistack" { + interface VariantOverrides { + default: false, + info: false, + + success: true, + warning: true, + error: { + extraInfo: string | undefined, + }, + } +} + +const NotificationsProvider = (props: PropsWithChildren) => { + return ( + + {props.children} + + ) +} + +const useNotifications = () => { + const { enqueueSnackbar, closeSnackbar } = useSnackbar() + + const notifySuccess = useCallback((message: SnackbarMessage, options?: OptionsObject<"success">) => { + return enqueueSnackbar({ + variant: "success", + message: message, + ...options, + }) + }, [enqueueSnackbar]) + + const notifyError = useCallback((error: Error, options?: OptionsObject<"error">) => { + const shouldSupress = error instanceof AppError && error.level === "suppress" + + if (!shouldSupress) { + const { title, description } = displayError(error) + return enqueueSnackbar({ + variant: "error", + message: title, + extraInfo: description, + action: , + ...options, + }) + } + }, [enqueueSnackbar]) + + const dismiss = useCallback((key?: SnackbarKey) => { + return closeSnackbar(key) + }, [closeSnackbar]) + + return { notifySuccess, notifyError, dismiss } +} + +export { NotificationsProvider, useNotifications } diff --git a/frontend/src/features/MarketDetail/components/MarketOutcomes/index.tsx b/frontend/src/features/MarketDetail/components/MarketOutcomes/index.tsx index 48b54bd..943a507 100644 --- a/frontend/src/features/MarketDetail/components/MarketOutcomes/index.tsx +++ b/frontend/src/features/MarketDetail/components/MarketOutcomes/index.tsx @@ -36,7 +36,7 @@ const MarketOutcomesContent = (props: { market: Market }) => { {getPercentage(outcome.wallets, market.totalWallets)}% {outcome.label} - {outcome.poolTokens.toFixed(0)} {market.denom} + {outcome.totalTokens.toFixed(0)} {market.denom} )} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 0972f67..f80d606 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -4,22 +4,25 @@ import ReactDOM from 'react-dom/client' import '@fontsource/inter' import './main.css' -import { ThemeProvider } from '@config/theme' -import { RouterProvider } from '@config/router' import { ChainProvider } from '@config/chain' -import { ModalsProvider } from '@state/modals' +import { NotificationsProvider } from '@config/notifications' import { QueryClientProvider } from '@config/queries' +import { RouterProvider } from '@config/router' +import { ThemeProvider } from '@config/theme' +import { ModalsProvider } from '@state/modals' ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - - - + + + + + + + + + , ) diff --git a/frontend/src/utils/hooks.ts b/frontend/src/utils/hooks.ts index 2bca4d1..f065f8f 100644 --- a/frontend/src/utils/hooks.ts +++ b/frontend/src/utils/hooks.ts @@ -1,12 +1,61 @@ -import { useCallback } from 'react' +import { useCallback, useRef, useState } from 'react' +import { useNotifications } from '@config/notifications' +import { MS_IN_SECOND } from './time' + +/** + * A wrapper around `useState` that alternates between two values. + * The state is updated to a "new" value when the setter is called, and it's reset to the original value after a given delay. + * + * @param initialValue the default value that the state is reset to. + * @param newValue the value the state is switched to. + * @param delayMs the delay after which the state is reset to its default value. + */ +const useResettingState = (initialValue: T, newValue: T | (() => T), delayMs: number) => { + const [value, setValue] = useState(initialValue) + const timeoutRef = useRef>() + + const changeState = useCallback(() => { + clearTimeout(timeoutRef.current) + setValue(newValue) + timeoutRef.current = setTimeout( + () => setValue(initialValue), + delayMs, + ) + }, [initialValue, newValue, delayMs]) + + return [value, changeState] as const +} + +const COPIED_NOTIFICATION_KEY = (content: string) => `copied_${content}` + +/** + * Provides a callback that copies text to the clipboard, and optionally displays a notification. + * Also returns a "copied" state that is reset automatically, for displaying indicators. + */ const useCopyToClipboard = () => { - const copy = useCallback((content: string) => { + const [copied, setCopied] = useResettingState(false, true, 1.5 * MS_IN_SECOND) + const notifications = useNotifications() + + const copy = useCallback((content: string, notification?: string) => { return navigator.clipboard .writeText(content) + .then(() => { + setCopied() + if (!!notification) { + notifications.notifySuccess( + `Copied ${notification}.`, + { + // https://notistack.com/features/basic#prevent-duplicate + key: COPIED_NOTIFICATION_KEY(content), + preventDuplicate: true, + }, + ) + } + }) }, []) - return copy + return [copied, copy] as const } export { useCopyToClipboard } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 49ae0e3..d94213b 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,7 @@ -import { defineConfig } from "vite" -import react from "@vitejs/plugin-react" -import tsconfigPaths from "vite-tsconfig-paths" -import { nodePolyfills } from "vite-plugin-node-polyfills" +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tsconfigPaths from 'vite-tsconfig-paths' +import { nodePolyfills } from 'vite-plugin-node-polyfills' // https://vitejs.dev/config/ export default defineConfig({ @@ -9,7 +9,7 @@ export default defineConfig({ react(), tsconfigPaths(), nodePolyfills({ - include: [], + include: ["buffer", "crypto"], }), ], server: { port: 3000 },