diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 641f00b..64c0006 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - - uses: actions/setup-node@v2 + - uses: actions/setup-node@v3 with: node-version: 18 - run: yarn install diff --git a/package.json b/package.json index 2d99038..78e04c1 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "private": true, "dependencies": { "@reduxjs/toolkit": "^1.9.5", - "@stellar/design-system": "^1.0.0-beta.14", + "@stellar/design-system": "^1.0.0", "@stellar/tsconfig": "^1.0.2", "@svgr/webpack": "8.0.1", "@tanstack/react-query": "^4.29.25", diff --git a/src/apiQueries/useStellarAccountInfo.ts b/src/apiQueries/useStellarAccountInfo.ts new file mode 100644 index 0000000..c31997b --- /dev/null +++ b/src/apiQueries/useStellarAccountInfo.ts @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; +import { HORIZON_URL } from "constants/settings"; +import { fetchStellarApi } from "helpers/fetchStellarApi"; +import { shortenAccountKey } from "helpers/shortenAccountKey"; +import { ApiStellarAccount, AppError } from "types"; + +export const useStellarAccountInfo = (stellarAddress: string | undefined) => { + const query = useQuery({ + queryKey: ["stellar", "accounts", stellarAddress], + queryFn: async () => { + if (!stellarAddress) { + return {}; + } + + return await fetchStellarApi( + `${HORIZON_URL}/accounts/${stellarAddress}`, + undefined, + { + notFoundMessage: `${shortenAccountKey( + stellarAddress, + )} address was not found.`, + }, + ); + }, + enabled: Boolean(stellarAddress), + }); + + return query; +}; diff --git a/src/components/ReceiverWalletBalance.tsx b/src/components/ReceiverWalletBalance.tsx index efed869..2d28d63 100644 --- a/src/components/ReceiverWalletBalance.tsx +++ b/src/components/ReceiverWalletBalance.tsx @@ -1,7 +1,6 @@ -import { Fragment, useEffect } from "react"; -import { Notification } from "@stellar/design-system"; -import { useQuery } from "@tanstack/react-query"; -import { getStellarAccountInfo } from "api/getStellarAccountInfo"; +import { Fragment } from "react"; +import { Loader, Notification } from "@stellar/design-system"; +import { useStellarAccountInfo } from "apiQueries/useStellarAccountInfo"; import { AssetAmount } from "components/AssetAmount"; interface ReceiverWalletBalanceProps { @@ -11,47 +10,34 @@ interface ReceiverWalletBalanceProps { export const ReceiverWalletBalance = ({ stellarAddress, }: ReceiverWalletBalanceProps) => { - const getBalance = async () => { - if (!stellarAddress) { - return []; - } + const { isLoading, isFetching, data, error } = + useStellarAccountInfo(stellarAddress); - const response = await getStellarAccountInfo(stellarAddress); - // We don't want to show XLM (native) balance - return response.balances.filter((b) => b.asset_issuer && b.asset_code); - }; + const balances = + data?.balances.filter((b) => b.asset_issuer && b.asset_code) || []; - const { isLoading, data, isError, error, refetch } = useQuery({ - queryKey: ["ReceiverWalletBalance"], - queryFn: getBalance, - }); - - useEffect(() => { - refetch(); - }, [stellarAddress, refetch]); - - if (isLoading) { - return
Loading…
; + if (isLoading || isFetching) { + return ; } - if (isError) { + if (error) { return ( - {error as string} + {error.message} ); } - if (data?.length === 0) { + if (balances?.length === 0) { return <>{"-"}; } return ( <> - {data?.map((b, index) => ( + {balances?.map((b, index) => ( - {index < data.length - 1 ? ", " : null} + {index < balances.length - 1 ? ", " : null} ))} diff --git a/src/components/ReceiverWalletHistory.tsx b/src/components/ReceiverWalletHistory.tsx index d09a7f0..5b24ce7 100644 --- a/src/components/ReceiverWalletHistory.tsx +++ b/src/components/ReceiverWalletHistory.tsx @@ -1,4 +1,3 @@ -import { useEffect } from "react"; import { Card, Link, Profile, Notification } from "@stellar/design-system"; import { useQuery } from "@tanstack/react-query"; @@ -60,15 +59,11 @@ export const ReceiverWalletHistory = ({ return payments; }; - const { isLoading, isError, data, error, refetch } = useQuery({ - queryKey: ["ReceiverWalletHistory"], + const { isLoading, isError, data, error } = useQuery({ + queryKey: ["stellar", "accounts", stellarAddress], queryFn: getPayments, }); - useEffect(() => { - refetch(); - }, [stellarAddress, refetch]); - if (isLoading) { return
Loading…
; } diff --git a/src/helpers/fetchStellarApi.ts b/src/helpers/fetchStellarApi.ts new file mode 100644 index 0000000..d5f6d82 --- /dev/null +++ b/src/helpers/fetchStellarApi.ts @@ -0,0 +1,25 @@ +import { normalizeStellarApiError } from "helpers/normalizeStellarApiError"; + +type FetchStellarApiOptions = { + notFoundMessage?: string; +}; + +export const fetchStellarApi = async ( + fetchUrl: string, + fetchOptions?: RequestInit, + options?: FetchStellarApiOptions, +) => { + try { + const request = await fetch(fetchUrl, fetchOptions); + + if (request.status === 404) { + throw new Error(options?.notFoundMessage || "Not found"); + } + + const response = await request.json(); + + return response; + } catch (e) { + throw normalizeStellarApiError(e as Error); + } +}; diff --git a/src/helpers/normalizeStellarApiError.ts b/src/helpers/normalizeStellarApiError.ts new file mode 100644 index 0000000..0c90a0e --- /dev/null +++ b/src/helpers/normalizeStellarApiError.ts @@ -0,0 +1,109 @@ +import { GENERIC_ERROR_MESSAGE } from "constants/settings"; +import { AnyObject, AppError } from "types"; + +export const normalizeStellarApiError = ( + error: Error, + defaultMessage = GENERIC_ERROR_MESSAGE, +): AppError => { + let message = ""; + + // Make sure error is not an empty object + if (JSON.stringify(error) === "{}") { + message = defaultMessage; + } else { + message = getErrorString(error); + } + + return { + message: message, + }; +}; + +// User friendly error messages +const TX_ERROR_TEXT: AnyObject = { + buy_not_authorized: + "The issuer must authorize you to trade this token. Visit the issuer’s site more info.", + op_malformed: "The input is incorrect and would result in an invalid offer.", + op_sell_no_trust: "You are not authorized to sell this asset.", + op_line_full: "You have reached the limit allowed for buying that asset.", + op_no_destination: "The destination account doesn't exist.", + op_no_trust: + "One or more accounts in this transaction doesn't have a trustline with the desired asset.", + op_underfunded: "You don’t have enough to cover that transaction.", + op_under_dest_min: + "We couldn’t complete your transaction at this time because the exchange rate offered is no longer available. Please try again.", + op_over_source_max: + "We couldn’t complete your transaction at this time because the exchange rate offered is no longer available. Please try again.", + op_cross_self: + "You already have an offer out that would immediately cross this one.", + op_sell_no_issuer: "The issuer of that token doesn’t exist.", + buy_no_issuer: "The issuer of that token doesn’t exist.", + op_offer_not_found: "We couldn’t find that offer.", + op_low_reserve: "That offer would take you below the minimum XLM reserve.", + op_not_authorized: + "This operation was not authorized, please make sure the asset you used complies with the Regulated Assets protocol (SEP-8).", + tx_bad_auth: "Something went wrong while signing a transaction.", + tx_bad_seq: + "The app has gotten out of sync with the network. Please try again later.", + tx_too_late: "This transaction has expired.", +}; + +// Given a Horizon error object, return a human-readable string that summarizes it. +export function getErrorString(err: any): string { + const e = err && err.response ? err.response : err; + + // timeout errors return timeout + if (e && e.status === 504) { + return "Sorry, the request timed out! Please try again later."; + } + + // first, try to parse the errors in extras + if (e && e.data && e.data.extras && e.data.extras.result_codes) { + const resultCodes = e.data.extras.result_codes; + + if (resultCodes.operations) { + // Map all errors into a single message string. + const codes = resultCodes.operations; + // Transactions with multiple operations might have mixed successes and + // errors. Ignore some codes to only handle codes we have messages for. + const ignoredCodes = ["op_success"]; + const message = codes + .filter((code: string) => !ignoredCodes.includes(code)) + .map((code: string) => TX_ERROR_TEXT[code] || `Error code '${code}'.`) + .join(" "); + + if (message) { + return message; + } + } + + if (resultCodes.transaction) { + return ( + TX_ERROR_TEXT[resultCodes.transaction] || + `Error code '${resultCodes.transaction}'` + ); + } + } + + if (e && e.data && e.data.detail) { + return e.data.detail; + } + + if (e && e.detail) { + return e.detail; + } + + if (e && e.message) { + return e.message; + } + + if (e && e.errors) { + return e.errors[0].message; + } + + if (e && e.error) { + return e.error; + } + + return e.toString(); +} diff --git a/yarn.lock b/yarn.lock index 2ae310f..2b79231 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2151,14 +2151,15 @@ resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.25.24.tgz#8c7688559979f7079aacaf31aa881c3aa410b718" integrity sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ== -"@stellar/design-system@^1.0.0-beta.14": - version "1.0.0-beta.14" - resolved "https://registry.yarnpkg.com/@stellar/design-system/-/design-system-1.0.0-beta.14.tgz#56914f4d575eb93b65129ec280f077e845fb6cce" - integrity sha512-51MpT/bg1ShJmbYnB17bphd8jSHCgqfTqudNInXGEM2BcXwH8PQWFDIgQ07jD6wIvOGhRAktyYNE7wQv1X2O2w== +"@stellar/design-system@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@stellar/design-system/-/design-system-1.0.0.tgz#8e1d3be5b20baa475ae373bef5cb7bb92bec9087" + integrity sha512-EKEIY2Ze80DgN6N0TuRxnag6FN9UWkKrM6Uo0Hows/3oSX13c+4DUJdVpuuBsd0J7rgGXZUJ1HE841kLI8dv5Q== dependencies: "@floating-ui/dom" "^1.2.5" bignumber.js "^9.1.1" configurable-date-input-polyfill "^3.1.5" + lodash "^4.17.21" react-copy-to-clipboard "^5.1.0" tslib "^2.5.0"