diff --git a/icons/docs.svg b/icons/docs.svg index 13b4878076..71a628d93c 100644 --- a/icons/docs.svg +++ b/icons/docs.svg @@ -1,4 +1,4 @@ - + diff --git a/icons/error-pages/403.svg b/icons/error-pages/403.svg new file mode 100644 index 0000000000..ee8d9669f8 --- /dev/null +++ b/icons/error-pages/403.svg @@ -0,0 +1,3 @@ + + + diff --git a/icons/rocket.svg b/icons/rocket.svg index b0001568d7..46523e1b05 100644 --- a/icons/rocket.svg +++ b/icons/rocket.svg @@ -1,4 +1,4 @@ - + diff --git a/icons/social/coingecko.svg b/icons/social/coingecko.svg index cb4ccc128d..baf56eef3a 100644 --- a/icons/social/coingecko.svg +++ b/icons/social/coingecko.svg @@ -1,8 +1,8 @@ - + - + diff --git a/icons/social/defi_llama.svg b/icons/social/defi_llama.svg index 4d9aeb54b7..f7d2cc51a2 100644 --- a/icons/social/defi_llama.svg +++ b/icons/social/defi_llama.svg @@ -1,6 +1,6 @@ - - + + diff --git a/icons/social/twitter_filled.svg b/icons/social/twitter_filled.svg index 02951fde8a..5fc356a969 100644 --- a/icons/social/twitter_filled.svg +++ b/icons/social/twitter_filled.svg @@ -1,3 +1,3 @@ - + diff --git a/lib/api/resources.ts b/lib/api/resources.ts index a9615103a0..3134238ed1 100644 --- a/lib/api/resources.ts +++ b/lib/api/resources.ts @@ -75,6 +75,9 @@ export const RESOURCES = { user_info: { path: '/api/account/v1/user/info', }, + email_resend: { + path: '/api/account/v1/email/resend', + }, custom_abi: { path: '/api/account/v1/user/custom_abis/:id?', pathParams: [ 'id' as const ], diff --git a/lib/errors/getErrorCause.tsx b/lib/errors/getErrorCause.ts similarity index 100% rename from lib/errors/getErrorCause.tsx rename to lib/errors/getErrorCause.ts diff --git a/lib/errors/getErrorStatusCode.tsx b/lib/errors/getErrorCauseStatusCode.ts similarity index 65% rename from lib/errors/getErrorStatusCode.tsx rename to lib/errors/getErrorCauseStatusCode.ts index d662ac6e65..f9884f03cd 100644 --- a/lib/errors/getErrorStatusCode.tsx +++ b/lib/errors/getErrorCauseStatusCode.ts @@ -1,6 +1,6 @@ import getErrorCause from './getErrorCause'; -export default function getErrorStatusCode(error: Error | undefined): number | undefined { +export default function getErrorCauseStatusCode(error: Error | undefined): number | undefined { const cause = getErrorCause(error); return cause && 'status' in cause && typeof cause.status === 'number' ? cause.status : undefined; } diff --git a/lib/errors/getErrorObj.ts b/lib/errors/getErrorObj.ts new file mode 100644 index 0000000000..7a06fdc6cd --- /dev/null +++ b/lib/errors/getErrorObj.ts @@ -0,0 +1,15 @@ +export default function getErrorObj(error: unknown) { + if (typeof error !== 'object') { + return; + } + + if (Array.isArray(error)) { + return; + } + + if (error === null) { + return; + } + + return error; +} diff --git a/lib/errors/getErrorObjPayload.ts b/lib/errors/getErrorObjPayload.ts new file mode 100644 index 0000000000..524979fe41 --- /dev/null +++ b/lib/errors/getErrorObjPayload.ts @@ -0,0 +1,23 @@ +import getErrorObj from './getErrorObj'; + +export default function getErrorObjPayload(error: unknown): Payload | undefined { + const errorObj = getErrorObj(error); + + if (!errorObj || !('payload' in errorObj)) { + return; + } + + if (typeof errorObj.payload !== 'object') { + return; + } + + if (errorObj === null) { + return; + } + + if (Array.isArray(errorObj)) { + return; + } + + return errorObj.payload as Payload; +} diff --git a/lib/errors/getErrorObjStatusCode.ts b/lib/errors/getErrorObjStatusCode.ts new file mode 100644 index 0000000000..a672b9d7a4 --- /dev/null +++ b/lib/errors/getErrorObjStatusCode.ts @@ -0,0 +1,11 @@ +import getErrorObj from './getErrorObj'; + +export default function getErrorObjStatusCode(error: unknown) { + const errorObj = getErrorObj(error); + + if (!errorObj || !('status' in errorObj) || typeof errorObj.status !== 'number') { + return; + } + + return errorObj.status; +} diff --git a/pages/_app.tsx b/pages/_app.tsx index bc211501a6..be91906482 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -9,7 +9,7 @@ import type { ResourceError } from 'lib/api/resources'; import { AppContextProvider } from 'lib/appContext'; import { Chakra } from 'lib/Chakra'; import { ScrollDirectionProvider } from 'lib/contexts/scrollDirection'; -import getErrorStatusCode from 'lib/errors/getErrorStatusCode'; +import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode'; import useConfigSentry from 'lib/hooks/useConfigSentry'; import { SocketProvider } from 'lib/socket/context'; import theme from 'theme'; @@ -27,7 +27,7 @@ function MyApp({ Component, pageProps }: AppProps) { refetchOnWindowFocus: false, retry: (failureCount, _error) => { const error = _error as ResourceError<{ status: number }>; - const status = error?.status || error?.payload?.status; + const status = error?.payload?.status || error?.status; if (status && status >= 400 && status < 500) { // don't do retry for client error responses return false; @@ -40,7 +40,7 @@ function MyApp({ Component, pageProps }: AppProps) { })); const renderErrorScreen = React.useCallback((error?: Error) => { - const statusCode = getErrorStatusCode(error); + const statusCode = getErrorCauseStatusCode(error); return ( { const title = getNetworkTitle(); return ( <> { title } - + + + ); }; diff --git a/pages/account/custom_abi.tsx b/pages/account/custom_abi.tsx index 0721d68511..97b180267d 100644 --- a/pages/account/custom_abi.tsx +++ b/pages/account/custom_abi.tsx @@ -4,13 +4,16 @@ import React from 'react'; import getNetworkTitle from 'lib/networks/getNetworkTitle'; import CustomAbi from 'ui/pages/CustomAbi'; +import Page from 'ui/shared/Page/Page'; const CustomAbiPage: NextPage = () => { const title = getNetworkTitle(); return ( <> { title } - + + + ); }; diff --git a/pages/account/public_tags_request.tsx b/pages/account/public_tags_request.tsx index cb71893490..12877caf11 100644 --- a/pages/account/public_tags_request.tsx +++ b/pages/account/public_tags_request.tsx @@ -4,13 +4,16 @@ import React from 'react'; import getNetworkTitle from 'lib/networks/getNetworkTitle'; import PublicTags from 'ui/pages/PublicTags'; +import Page from 'ui/shared/Page/Page'; const PublicTagsPage: NextPage = () => { const title = getNetworkTitle(); return ( <> { title } - + + + ); }; diff --git a/pages/account/tag_address.tsx b/pages/account/tag_address.tsx index 6d607280de..7146724e19 100644 --- a/pages/account/tag_address.tsx +++ b/pages/account/tag_address.tsx @@ -4,13 +4,16 @@ import React from 'react'; import getNetworkTitle from 'lib/networks/getNetworkTitle'; import PrivateTags from 'ui/pages/PrivateTags'; +import Page from 'ui/shared/Page/Page'; const AddressTagsPage: NextPage = () => { const title = getNetworkTitle(); return ( <> { title } - + + + ); }; diff --git a/pages/account/watchlist.tsx b/pages/account/watchlist.tsx index 6b76934557..7344c339c3 100644 --- a/pages/account/watchlist.tsx +++ b/pages/account/watchlist.tsx @@ -4,6 +4,7 @@ import React from 'react'; import getNetworkTitle from 'lib/networks/getNetworkTitle'; import WatchList from 'ui/pages/Watchlist'; +import Page from 'ui/shared/Page/Page'; const WatchListPage: NextPage = () => { const title = getNetworkTitle(); @@ -12,7 +13,9 @@ const WatchListPage: NextPage = () => { { title } - + + + ); }; diff --git a/pages/auth/profile.tsx b/pages/auth/profile.tsx index 4ee5994360..54e0ad4c68 100644 --- a/pages/auth/profile.tsx +++ b/pages/auth/profile.tsx @@ -3,12 +3,15 @@ import Head from 'next/head'; import React from 'react'; import MyProfile from 'ui/pages/MyProfile'; +import Page from 'ui/shared/Page/Page'; const MyProfilePage: NextPage = () => { return ( <> My profile - + + + ); }; diff --git a/ui/address/details/AddressFavoriteButton.tsx b/ui/address/details/AddressFavoriteButton.tsx index 8642112023..393e73753b 100644 --- a/ui/address/details/AddressFavoriteButton.tsx +++ b/ui/address/details/AddressFavoriteButton.tsx @@ -5,9 +5,12 @@ import React from 'react'; import starFilledIcon from 'icons/star_filled.svg'; import starOutlineIcon from 'icons/star_outline.svg'; +import type { ResourceError } from 'lib/api/resources'; +import { resourceKey } from 'lib/api/resources'; import { getResourceKey } from 'lib/api/useApiQuery'; import usePreventFocusAfterModalClosing from 'lib/hooks/usePreventFocusAfterModalClosing'; import useRedirectIfNotAuth from 'lib/hooks/useRedirectIfNotAuth'; +import useToast from 'lib/hooks/useToast'; import WatchlistAddModal from 'ui/watchlist/AddressModal/AddressModal'; import DeleteAddressModal from 'ui/watchlist/DeleteAddressModal'; @@ -22,14 +25,33 @@ const AddressFavoriteButton = ({ className, hash, watchListId }: Props) => { const deleteModalProps = useDisclosure(); const queryClient = useQueryClient(); const router = useRouter(); + const toast = useToast(); + const redirectIfNotAuth = useRedirectIfNotAuth(); + const profileState = queryClient.getQueryState>([ resourceKey('user_info') ]); + const handleClick = React.useCallback(() => { + if (profileState?.error?.status === 403) { + const isUnverifiedEmail = profileState.error.payload?.message.includes('Unverified email'); + if (isUnverifiedEmail) { + toast({ + position: 'top-right', + title: 'Error', + description: 'Unable to add address to watch list. Please go to the watch list page instead.', + status: 'error', + variant: 'subtle', + isClosable: true, + }); + return; + } + } + if (redirectIfNotAuth()) { return; } watchListId ? deleteModalProps.onOpen() : addModalProps.onOpen(); - }, [ addModalProps, deleteModalProps, watchListId, redirectIfNotAuth ]); + }, [ profileState, redirectIfNotAuth, watchListId, deleteModalProps, addModalProps, toast ]); const handleAddOrDeleteSuccess = React.useCallback(async() => { const queryKey = getResourceKey('address', { pathParams: { hash: router.query.hash?.toString() } }); diff --git a/ui/pages/ApiKeys.tsx b/ui/pages/ApiKeys.tsx index c37957f146..5914fd6c74 100644 --- a/ui/pages/ApiKeys.tsx +++ b/ui/pages/ApiKeys.tsx @@ -13,7 +13,6 @@ import ApiKeyTable from 'ui/apiKey/ApiKeyTable/ApiKeyTable'; import DeleteApiKeyModal from 'ui/apiKey/DeleteApiKeyModal'; import AccountPageDescription from 'ui/shared/AccountPageDescription'; import DataFetchAlert from 'ui/shared/DataFetchAlert'; -import Page from 'ui/shared/Page/Page'; import PageTitle from 'ui/shared/Page/PageTitle'; import SkeletonListAccount from 'ui/shared/skeletons/SkeletonListAccount'; import SkeletonTable from 'ui/shared/skeletons/SkeletonTable'; @@ -29,7 +28,7 @@ const ApiKeysPage: React.FC = () => { const [ apiKeyModalData, setApiKeyModalData ] = useState(); const [ deleteModalData, setDeleteModalData ] = useState(); - const { data, isLoading, isError } = useApiQuery('api_keys'); + const { data, isLoading, isError, error } = useApiQuery('api_keys'); const onEditClick = useCallback((data: ApiKey) => { setApiKeyModalData(data); @@ -76,6 +75,9 @@ const ApiKeysPage: React.FC = () => { } if (isError) { + if (error.status === 403) { + throw new Error('Unverified email error', { cause: error }); + } return ; } @@ -130,12 +132,10 @@ const ApiKeysPage: React.FC = () => { })(); return ( - - - - { content } - - + <> + + { content } + ); }; diff --git a/ui/pages/CustomAbi.tsx b/ui/pages/CustomAbi.tsx index 2720de08dc..858279e16c 100644 --- a/ui/pages/CustomAbi.tsx +++ b/ui/pages/CustomAbi.tsx @@ -12,7 +12,6 @@ import CustomAbiTable from 'ui/customAbi/CustomAbiTable/CustomAbiTable'; import DeleteCustomAbiModal from 'ui/customAbi/DeleteCustomAbiModal'; import AccountPageDescription from 'ui/shared/AccountPageDescription'; import DataFetchAlert from 'ui/shared/DataFetchAlert'; -import Page from 'ui/shared/Page/Page'; import PageTitle from 'ui/shared/Page/PageTitle'; import SkeletonListAccount from 'ui/shared/skeletons/SkeletonListAccount'; import SkeletonTable from 'ui/shared/skeletons/SkeletonTable'; @@ -26,7 +25,7 @@ const CustomAbiPage: React.FC = () => { const [ customAbiModalData, setCustomAbiModalData ] = useState(); const [ deleteModalData, setDeleteModalData ] = useState(); - const { data, isLoading, isError } = useApiQuery('custom_abi'); + const { data, isLoading, isError, error } = useApiQuery('custom_abi'); const onEditClick = useCallback((data: CustomAbi) => { setCustomAbiModalData(data); @@ -72,6 +71,9 @@ const CustomAbiPage: React.FC = () => { } if (isError) { + if (error.status === 403) { + throw new Error('Unverified email error', { cause: error }); + } return ; } @@ -113,12 +115,10 @@ const CustomAbiPage: React.FC = () => { })(); return ( - - - - { content } - - + <> + + { content } + ); }; diff --git a/ui/pages/MyProfile.tsx b/ui/pages/MyProfile.tsx index b59acafbe6..905ec63391 100644 --- a/ui/pages/MyProfile.tsx +++ b/ui/pages/MyProfile.tsx @@ -5,12 +5,11 @@ import useFetchProfileInfo from 'lib/hooks/useFetchProfileInfo'; import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken'; import ContentLoader from 'ui/shared/ContentLoader'; import DataFetchAlert from 'ui/shared/DataFetchAlert'; -import Page from 'ui/shared/Page/Page'; import PageTitle from 'ui/shared/Page/PageTitle'; import UserAvatar from 'ui/shared/UserAvatar'; const MyProfile = () => { - const { data, isLoading, isError, isFetched } = useFetchProfileInfo(); + const { data, isLoading, isError, error, isFetched } = useFetchProfileInfo(); useRedirectForInvalidAuthToken(); const content = (() => { @@ -19,6 +18,9 @@ const MyProfile = () => { } if (isError) { + if (error.status === 403) { + throw new Error('Unverified email error', { cause: error }); + } return ; } @@ -54,10 +56,10 @@ const MyProfile = () => { })(); return ( - + <> { content } - + ); }; diff --git a/ui/pages/PrivateTags.tsx b/ui/pages/PrivateTags.tsx index 49f24feb1f..392ac419ea 100644 --- a/ui/pages/PrivateTags.tsx +++ b/ui/pages/PrivateTags.tsx @@ -5,7 +5,6 @@ import type { RoutedTab } from 'ui/shared/Tabs/types'; import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken'; import PrivateAddressTags from 'ui/privateTags/PrivateAddressTags'; import PrivateTransactionTags from 'ui/privateTags/PrivateTransactionTags'; -import Page from 'ui/shared/Page/Page'; import PageTitle from 'ui/shared/Page/PageTitle'; import RoutedTabs from 'ui/shared/Tabs/RoutedTabs'; @@ -18,10 +17,10 @@ const PrivateTags = () => { useRedirectForInvalidAuthToken(); return ( - + <> - + ); }; diff --git a/ui/pages/PublicTags.tsx b/ui/pages/PublicTags.tsx index 3ea7d9aa11..10e4001517 100644 --- a/ui/pages/PublicTags.tsx +++ b/ui/pages/PublicTags.tsx @@ -9,7 +9,6 @@ import useToast from 'lib/hooks/useToast'; import getQueryParamString from 'lib/router/getQueryParamString'; import PublicTagsData from 'ui/publicTags/PublicTagsData'; import PublicTagsForm from 'ui/publicTags/PublicTagsForm/PublicTagsForm'; -import Page from 'ui/shared/Page/Page'; import PageTitle from 'ui/shared/Page/PageTitle'; type TScreen = 'data' | 'form'; @@ -90,14 +89,14 @@ const PublicTagsComponent: React.FC = () => { }; return ( - + <> { content } - + ); }; diff --git a/ui/pages/Watchlist.tsx b/ui/pages/Watchlist.tsx index 8685f4896f..8eed842137 100644 --- a/ui/pages/Watchlist.tsx +++ b/ui/pages/Watchlist.tsx @@ -5,13 +5,13 @@ import React, { useCallback, useState } from 'react'; import type { WatchlistAddress, WatchlistTokensResponse } from 'types/api/account'; import type { TWatchlist, TWatchlistItem } from 'types/client/account'; +import type { ResourceError } from 'lib/api/resources'; import { resourceKey } from 'lib/api/resources'; import useApiFetch from 'lib/api/useApiFetch'; import useIsMobile from 'lib/hooks/useIsMobile'; import useRedirectForInvalidAuthToken from 'lib/hooks/useRedirectForInvalidAuthToken'; import AccountPageDescription from 'ui/shared/AccountPageDescription'; import DataFetchAlert from 'ui/shared/DataFetchAlert'; -import Page from 'ui/shared/Page/Page'; import PageTitle from 'ui/shared/Page/PageTitle'; import SkeletonListAccount from 'ui/shared/skeletons/SkeletonListAccount'; import SkeletonTable from 'ui/shared/skeletons/SkeletonTable'; @@ -22,31 +22,27 @@ import WatchlistTable from 'ui/watchlist/WatchlistTable/WatchlistTable'; const WatchList: React.FC = () => { const apiFetch = useApiFetch(); - const { data, isLoading, isError } = useQuery([ resourceKey('watchlist') ], async() => { - try { - const watchlistAddresses = await apiFetch<'watchlist', Array>('watchlist'); + const { data, isLoading, isError, error } = useQuery([ resourceKey('watchlist') ], async() => { + const watchlistAddresses = await apiFetch<'watchlist', Array>('watchlist'); - if (!Array.isArray(watchlistAddresses)) { - throw Error(); - } - - const watchlistTokens = await Promise.all(watchlistAddresses.map(({ address }) => { - if (!address?.hash) { - return Promise.resolve(0); - } - return apiFetch<'old_api', WatchlistTokensResponse>('old_api', { queryParams: { address: address.hash, module: 'account', action: 'tokenlist' } }) - .then((response) => { - if ('result' in response && Array.isArray(response.result)) { - return response.result.length; - } - return 0; - }); - })); - - return watchlistAddresses.map((item, index) => ({ ...item, tokens_count: watchlistTokens[index] })); - } catch (error) { - return error; + if (!Array.isArray(watchlistAddresses)) { + return; } + + const watchlistTokens = await Promise.all(watchlistAddresses.map(({ address }) => { + if (!address?.hash) { + return Promise.resolve(0); + } + return apiFetch<'old_api', WatchlistTokensResponse>('old_api', { queryParams: { address: address.hash, module: 'account', action: 'tokenlist' } }) + .then((response) => { + if ('result' in response && Array.isArray(response.result)) { + return response.result.length; + } + return 0; + }); + })); + + return watchlistAddresses.map((item, index) => ({ ...item, tokens_count: watchlistTokens[index] })); }); const queryClient = useQueryClient(); @@ -96,24 +92,30 @@ const WatchList: React.FC = () => { ); - let content; - if (isLoading && !data) { - const loader = isMobile ? : ( - <> - - - - ); + if (isError) { + if (error.status === 403) { + throw new Error('Unverified email error', { cause: error }); + } + return ; + } + + const content = (() => { + if (isLoading && !data) { + const loader = isMobile ? : ( + <> + + + + ); + + return ( + <> + { description } + { loader } + + ); + } - content = ( - <> - { description } - { loader } - - ); - } else if (isError) { - content = ; - } else { const list = isMobile ? ( { data.map((item) => ( @@ -133,7 +135,7 @@ const WatchList: React.FC = () => { /> ); - content = ( + return ( <> { description } { Boolean(data?.length) && list } @@ -142,7 +144,7 @@ const WatchList: React.FC = () => { size="lg" onClick={ addressModalProps.onOpen } > - Add address + Add address { ) } ); - } + })(); return ( - - - - { content } - - + <> + + { content } + ); }; diff --git a/ui/privateTags/PrivateAddressTags.tsx b/ui/privateTags/PrivateAddressTags.tsx index 7ea9b17a6e..5eb8af8e82 100644 --- a/ui/privateTags/PrivateAddressTags.tsx +++ b/ui/privateTags/PrivateAddressTags.tsx @@ -15,7 +15,7 @@ import AddressTagTable from './AddressTagTable/AddressTagTable'; import DeletePrivateTagModal from './DeletePrivateTagModal'; const PrivateAddressTags = () => { - const { data: addressTagsData, isError, isPlaceholderData, refetch } = useApiQuery('private_tags_address', { + const { data: addressTagsData, isError, error, isPlaceholderData, refetch } = useApiQuery('private_tags_address', { queryOptions: { refetchOnMount: false, placeholderData: Array(3).fill(PRIVATE_TAG_ADDRESS), @@ -54,6 +54,9 @@ const PrivateAddressTags = () => { }, [ deleteModalProps ]); if (isError) { + if (error.status === 403) { + throw new Error('Unverified email error', { cause: error }); + } return ; } diff --git a/ui/privateTags/PrivateTransactionTags.tsx b/ui/privateTags/PrivateTransactionTags.tsx index f8ff7bc065..e6da5c6037 100644 --- a/ui/privateTags/PrivateTransactionTags.tsx +++ b/ui/privateTags/PrivateTransactionTags.tsx @@ -16,7 +16,7 @@ import TransactionTagListItem from './TransactionTagTable/TransactionTagListItem import TransactionTagTable from './TransactionTagTable/TransactionTagTable'; const PrivateTransactionTags = () => { - const { data: transactionTagsData, isLoading, isError } = useApiQuery('private_tags_tx', { queryOptions: { refetchOnMount: false } }); + const { data: transactionTagsData, isLoading, isError, error } = useApiQuery('private_tags_tx', { queryOptions: { refetchOnMount: false } }); const transactionModalProps = useDisclosure(); const deleteModalProps = useDisclosure(); @@ -69,6 +69,9 @@ const PrivateTransactionTags = () => { } if (isError) { + if (error.status === 403) { + throw new Error('Unverified email error', { cause: error }); + } return ; } diff --git a/ui/publicTags/PublicTagsData.tsx b/ui/publicTags/PublicTagsData.tsx index 3a623469e4..416105a0fe 100644 --- a/ui/publicTags/PublicTagsData.tsx +++ b/ui/publicTags/PublicTagsData.tsx @@ -24,7 +24,7 @@ const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => { const [ deleteModalData, setDeleteModalData ] = useState(); const isMobile = useIsMobile(); - const { data, isLoading, isError } = useApiQuery('public_tags'); + const { data, isLoading, isError, error } = useApiQuery('public_tags'); const onDeleteModalClose = useCallback(() => { setDeleteModalData(undefined); @@ -70,6 +70,9 @@ const PublicTagsData = ({ changeToFormScreen, onTagDelete }: Props) => { } if (isError) { + if (error.status === 403) { + throw new Error('Unverified email error', { cause: error }); + } return ; } diff --git a/ui/shared/ErrorInvalidTxHash.tsx b/ui/shared/AppError/AppErrorInvalidTxHash.tsx similarity index 97% rename from ui/shared/ErrorInvalidTxHash.tsx rename to ui/shared/AppError/AppErrorInvalidTxHash.tsx index 9621c114e5..7c0240b02b 100644 --- a/ui/shared/ErrorInvalidTxHash.tsx +++ b/ui/shared/AppError/AppErrorInvalidTxHash.tsx @@ -4,7 +4,7 @@ import React from 'react'; import txIcon from 'icons/transactions.svg'; -const ErrorInvalidTxHash = () => { +const AppErrorInvalidTxHash = () => { const textColor = useColorModeValue('gray.500', 'gray.400'); const snippet = { borderColor: useColorModeValue('blackAlpha.300', 'whiteAlpha.300'), @@ -54,4 +54,4 @@ const ErrorInvalidTxHash = () => { ); }; -export default ErrorInvalidTxHash; +export default AppErrorInvalidTxHash; diff --git a/ui/shared/AppError/AppErrorUnverifiedEmail.tsx b/ui/shared/AppError/AppErrorUnverifiedEmail.tsx new file mode 100644 index 0000000000..02cc77e8f7 --- /dev/null +++ b/ui/shared/AppError/AppErrorUnverifiedEmail.tsx @@ -0,0 +1,84 @@ +import { Box, Text, Button, Heading, Icon, chakra } from '@chakra-ui/react'; +import React from 'react'; + +import icon403 from 'icons/error-pages/403.svg'; +import useApiFetch from 'lib/api/useApiFetch'; +import dayjs from 'lib/date/dayjs'; +import getErrorObjPayload from 'lib/errors/getErrorObjPayload'; +import getErrorObjStatusCode from 'lib/errors/getErrorObjStatusCode'; +import useToast from 'lib/hooks/useToast'; + +interface Props { + className?: string; + email?: string; +} + +const AppErrorUnverifiedEmail = ({ className, email }: Props) => { + const apiFetch = useApiFetch(); + const toast = useToast(); + + const handleButtonClick = React.useCallback(async() => { + const toastId = 'resend-email-error'; + + try { + await apiFetch('email_resend'); + toast({ + id: toastId, + position: 'top-right', + title: 'Success', + description: 'Email successfully resent.', + status: 'success', + variant: 'subtle', + isClosable: true, + }); + } catch (error) { + const statusCode = getErrorObjStatusCode(error); + + const message = (() => { + if (statusCode !== 429) { + return; + } + + const payload = getErrorObjPayload<{ seconds_before_next_resend: number }>(error); + if (!payload) { + return; + } + + const timeUntilNextResend = dayjs().add(payload.seconds_before_next_resend, 'seconds').fromNow(); + return `Email resend is available ${ timeUntilNextResend }.`; + })(); + + !toast.isActive(toastId) && toast({ + id: toastId, + position: 'top-right', + title: 'Error', + description: message || 'Something went wrong. Try again later.', + status: 'error', + variant: 'subtle', + isClosable: true, + }); + } + }, [ apiFetch, toast ]); + + return ( + + + Email is not verified + + Please confirm your email address to use the My Account feature. A confirmation email was sent to + { email || 'your email address' } + on signup. { `Didn't receive?` } + + + + ); +}; + +export default chakra(AppErrorUnverifiedEmail); diff --git a/ui/shared/Page/Page.tsx b/ui/shared/Page/Page.tsx index 4d82f380b3..347b017e13 100644 --- a/ui/shared/Page/Page.tsx +++ b/ui/shared/Page/Page.tsx @@ -1,14 +1,15 @@ import { Flex } from '@chakra-ui/react'; import React from 'react'; -import getErrorStatusCode from 'lib/errors/getErrorStatusCode'; +import getErrorCauseStatusCode from 'lib/errors/getErrorCauseStatusCode'; import getResourceErrorPayload from 'lib/errors/getResourceErrorPayload'; import useAdblockDetect from 'lib/hooks/useAdblockDetect'; import useGetCsrfToken from 'lib/hooks/useGetCsrfToken'; import AppError from 'ui/shared/AppError/AppError'; import AppErrorBlockConsensus from 'ui/shared/AppError/AppErrorBlockConsensus'; +import AppErrorInvalidTxHash from 'ui/shared/AppError/AppErrorInvalidTxHash'; +import AppErrorUnverifiedEmail from 'ui/shared/AppError/AppErrorUnverifiedEmail'; import ErrorBoundary from 'ui/shared/ErrorBoundary'; -import ErrorInvalidTxHash from 'ui/shared/ErrorInvalidTxHash'; import PageContent from 'ui/shared/Page/PageContent'; import Header from 'ui/snippets/header/Header'; import NavigationDesktop from 'ui/snippets/navigation/NavigationDesktop'; @@ -32,7 +33,7 @@ const Page = ({ useAdblockDetect(); const renderErrorScreen = React.useCallback((error?: Error) => { - const statusCode = getErrorStatusCode(error) || 500; + const statusCode = getErrorCauseStatusCode(error) || 500; const resourceErrorPayload = getResourceErrorPayload(error); const messageInPayload = resourceErrorPayload && 'message' in resourceErrorPayload && typeof resourceErrorPayload.message === 'string' ? resourceErrorPayload.message : @@ -40,9 +41,17 @@ const Page = ({ const isInvalidTxHash = error?.message.includes('Invalid tx hash'); const isBlockConsensus = messageInPayload?.includes('Block lost consensus'); + const isUnverifiedEmail = statusCode === 403 && messageInPayload?.includes('Unverified email'); if (isInvalidTxHash) { - return ; + return ; + } + + if (isUnverifiedEmail) { + const email = resourceErrorPayload && 'email' in resourceErrorPayload && typeof resourceErrorPayload.email === 'string' ? + resourceErrorPayload.email : + undefined; + return ; } if (isBlockConsensus) {