diff --git a/contract/src/api.rs b/contract/src/api.rs index f3ba1f5..3aa4680 100644 --- a/contract/src/api.rs +++ b/contract/src/api.rs @@ -96,11 +96,7 @@ pub struct OutcomeInfo { pub wallet_count: u32, } -#[derive(Clone, Serialize, Deserialize, JsonSchema, Debug)] -#[serde(rename_all = "snake_case")] -pub struct PositionsResp { - pub outcomes: Vec, -} +pub type PositionsResp = ShareInfo; #[derive(Serialize, Deserialize, JsonSchema, Debug)] pub struct MigrateMsg {} diff --git a/contract/src/migrate.rs b/contract/src/migrate.rs index cb4f367..b9b4959 100644 --- a/contract/src/migrate.rs +++ b/contract/src/migrate.rs @@ -20,9 +20,9 @@ pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { .into()); } - if current >= new { + if current > new { return Err(StdError::generic_err(format!( - "Current contract version is older or equivalent to the new one. Current: {}, New: {}", + "Current contract version is newer than the new one. Current: {}, New: {}", current, new )) .into()); diff --git a/contract/src/query.rs b/contract/src/query.rs index 9e7e744..ee9b707 100644 --- a/contract/src/query.rs +++ b/contract/src/query.rs @@ -26,9 +26,6 @@ fn market(deps: Deps, id: MarketId) -> Result { fn positions(deps: Deps, id: MarketId, addr: String) -> Result { let addr = deps.api.addr_validate(&addr)?; let market = StoredMarket::load(deps.storage, id)?; - let outcomes = ShareInfo::load(deps.storage, &market, &addr)? - .unwrap_or_else(|| ShareInfo::new(market.outcomes.len())) - .outcomes; - - Ok(PositionsResp { outcomes }) + Ok(ShareInfo::load(deps.storage, &market, &addr)? + .unwrap_or_else(|| ShareInfo::new(market.outcomes.len()))) } diff --git a/contract/src/tests.rs b/contract/src/tests.rs index ee7c89f..019c857 100644 --- a/contract/src/tests.rs +++ b/contract/src/tests.rs @@ -184,7 +184,10 @@ impl Predict { } fn query_tokens(&self, better: &Addr, outcome: u8) -> StdResult { - let PositionsResp { outcomes } = self.query(&QueryMsg::Positions { + let PositionsResp { + outcomes, + claimed_winnings: _, + } = self.query(&QueryMsg::Positions { id: self.id, addr: better.to_string(), })?; diff --git a/frontend/index.html b/frontend/index.html index bcefcc6..c9f2ada 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - + Levana Predict diff --git a/frontend/src/api/mutations/CancelBet.ts b/frontend/src/api/mutations/CancelBet.ts new file mode 100644 index 0000000..bed8ac6 --- /dev/null +++ b/frontend/src/api/mutations/CancelBet.ts @@ -0,0 +1,83 @@ +import { useCosmWasmSigningClient } from 'graz' +import BigNumber from 'bignumber.js' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate' + +import { useCurrentAccount } from '@config/chain' +import { useNotifications } from '@config/notifications' +import { querierAwaitCacheAnd, querierBroadcastAndWait } from '@api/querier' +import { MARKET_KEYS, MarketId, OutcomeId } from '@api/queries/Market' +import { POSITIONS_KEYS } from '@api/queries/Positions' +import { BALANCE_KEYS } from '@api/queries/NtrnBalance' +import { AppError, errorsMiddleware } from '@utils/errors' + +interface CancelBetRequest { + withdraw: { + id: number, + outcome: number, + tokens: string, + }, +} + +interface CancelBetArgs { + outcomeId: OutcomeId, + tokensAmount: BigNumber, +} + +const putCancelBet = (address: string, signer: SigningCosmWasmClient, marketId: MarketId, args: CancelBetArgs) => { + const msg: CancelBetRequest = { + withdraw: { + id: Number(marketId), + outcome: Number(args.outcomeId), + tokens: args.tokensAmount.toFixed(), + }, + } + + return querierBroadcastAndWait( + address, + signer, + { payload: msg }, + ) +} + +const CANCEL_BET_KEYS = { + all: ["cancel_bet"] as const, + address: (address: string) => [...CANCEL_BET_KEYS.all, address] as const, + market: (address: string, marketId: MarketId) => [...CANCEL_BET_KEYS.address(address), marketId] as const, +} + +const useCancelBet = (marketId: MarketId) => { + const account = useCurrentAccount() + const signer = useCosmWasmSigningClient() + const queryClient = useQueryClient() + const notifications = useNotifications() + + const mutation = useMutation({ + mutationKey: CANCEL_BET_KEYS.market(account.bech32Address, marketId), + mutationFn: (args: CancelBetArgs) => { + if (signer.data) { + return errorsMiddleware("sell", putCancelBet(account.bech32Address, signer.data, marketId, args)) + } else { + return Promise.reject() + } + }, + onSuccess: (_, args) => { + notifications.notifySuccess(`Successfully cancelled bet of ${args.tokensAmount.toFixed(3)} tokens.`) + + return querierAwaitCacheAnd( + () => queryClient.invalidateQueries({ queryKey: MARKET_KEYS.market(marketId)}), + () => queryClient.invalidateQueries({ queryKey: POSITIONS_KEYS.market(account.bech32Address, marketId)}), + () => queryClient.invalidateQueries({ queryKey: BALANCE_KEYS.address(account.bech32Address)}), + ) + }, + onError: (err, args) => { + notifications.notifyError( + AppError.withCause(`Failed to cancel bet of ${args.tokensAmount.toFixed(3)} tokens.`, err) + ) + }, + }) + + return mutation +} + +export { useCancelBet } diff --git a/frontend/src/api/mutations/ClaimEarnings.ts b/frontend/src/api/mutations/ClaimEarnings.ts new file mode 100644 index 0000000..c4c0d17 --- /dev/null +++ b/frontend/src/api/mutations/ClaimEarnings.ts @@ -0,0 +1,72 @@ +import { useCosmWasmSigningClient } from 'graz' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { SigningCosmWasmClient } from '@cosmjs/cosmwasm-stargate' + +import { useCurrentAccount } from '@config/chain' +import { useNotifications } from '@config/notifications' +import { querierAwaitCacheAnd, querierBroadcastAndWait } from '@api/querier' +import { MarketId } from '@api/queries/Market' +import { POSITIONS_KEYS } from '@api/queries/Positions' +import { AppError, errorsMiddleware } from '@utils/errors' +import { BALANCE_KEYS } from '@api/queries/NtrnBalance' + +interface ClaimEarningsRequest { + collect: { + id: number, + }, +} + +const putClaimEarnings = (address: string, signer: SigningCosmWasmClient, marketId: MarketId) => { + const msg: ClaimEarningsRequest = { + collect: { + id: Number(marketId), + }, + } + + return querierBroadcastAndWait( + address, + signer, + { payload: msg }, + ) +} + +const CLAIM_EARNINGS_KEYS = { + all: ["claim_earnings"] as const, + address: (address: string) => [...CLAIM_EARNINGS_KEYS.all, address] as const, + market: (address: string, marketId: MarketId) => [...CLAIM_EARNINGS_KEYS.address(address), marketId] as const, +} + +const useClaimEarnings = (marketId: MarketId) => { + const account = useCurrentAccount() + const signer = useCosmWasmSigningClient() + const queryClient = useQueryClient() + const notifications = useNotifications() + + const mutation = useMutation({ + mutationKey: CLAIM_EARNINGS_KEYS.market(account.bech32Address, marketId), + mutationFn: () => { + if (signer.data) { + return errorsMiddleware("claim", putClaimEarnings(account.bech32Address, signer.data, marketId)) + } else { + return Promise.reject() + } + }, + onSuccess: () => { + notifications.notifySuccess("Successfully claimed earnings.") + + return querierAwaitCacheAnd( + () => queryClient.invalidateQueries({ queryKey: POSITIONS_KEYS.market(account.bech32Address, marketId)}), + () => queryClient.invalidateQueries({ queryKey: BALANCE_KEYS.address(account.bech32Address)}), + ) + }, + onError: (err) => { + notifications.notifyError( + AppError.withCause("Failed to claim earnings.", err) + ) + }, + }) + + return mutation +} + +export { useClaimEarnings } diff --git a/frontend/src/api/mutations/PlaceBet.ts b/frontend/src/api/mutations/PlaceBet.ts index b51541a..b0e867c 100644 --- a/frontend/src/api/mutations/PlaceBet.ts +++ b/frontend/src/api/mutations/PlaceBet.ts @@ -6,11 +6,11 @@ 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 { MARKET_KEYS, MarketId, OutcomeId } from '@api/queries/Market' import { POSITIONS_KEYS } from '@api/queries/Positions' +import { BALANCE_KEYS } from '@api/queries/NtrnBalance' import { NTRN } from '@utils/tokens' import { AppError, errorsMiddleware } from '@utils/errors' -import { BALANCE_KEYS } from '@api/queries/NtrnBalance' interface PlaceBetRequest { deposit: { @@ -38,7 +38,7 @@ const putPlaceBet = (address: string, signer: SigningCosmWasmClient, marketId: M { payload: msg, funds: [{ denom: NTRN_DENOM, amount: args.ntrnAmount.units.toFixed(0) }], - } + }, ) } @@ -67,6 +67,7 @@ const usePlaceBet = (marketId: MarketId) => { notifications.notifySuccess(`Successfully bet ${args.ntrnAmount.toFormat(true)}.`) return querierAwaitCacheAnd( + () => queryClient.invalidateQueries({ queryKey: MARKET_KEYS.market(marketId)}), () => queryClient.invalidateQueries({ queryKey: POSITIONS_KEYS.market(account.bech32Address, marketId)}), () => queryClient.invalidateQueries({ queryKey: BALANCE_KEYS.address(account.bech32Address)}), ) diff --git a/frontend/src/api/queries/Market.ts b/frontend/src/api/queries/Market.ts index 2c32af0..9bebc2d 100644 --- a/frontend/src/api/queries/Market.ts +++ b/frontend/src/api/queries/Market.ts @@ -1,10 +1,11 @@ import { BigNumber } from 'bignumber.js' import { queryOptions } from '@tanstack/react-query' +import lvnLogo from '@assets/brand/logo.png' import { NETWORK_ID } from '@config/chain' import { CONTRACT_ADDRESS } from '@config/environment' import { fetchQuerier } from '@api/querier' -import { Nanoseconds } from '@utils/time' +import { Nanoseconds, sleep } from '@utils/time' interface ResponseMarket { id: number, @@ -16,8 +17,9 @@ interface ResponseMarket { withdrawal_fee: string, deposit_stop_date: string, withdrawal_stop_date: string, - winner: string | null, + winner: number | null, total_wallets: number, + pool_size: string, } interface ResponseMarketOutcome { @@ -32,55 +34,71 @@ interface Market { id: MarketId, title: string, description: string, + image: string, possibleOutcomes: MarketOutcome[], denom: string, depositFee: BigNumber, withdrawalFee: BigNumber, depositStopDate: Nanoseconds, withdrawalStopDate: Nanoseconds, - winnerOutcome: string | undefined, + winnerOutcome: MarketOutcome | undefined, totalWallets: number, + poolSize: BigNumber, } interface MarketOutcome { id: OutcomeId, label: string, - poolTokens: BigNumber, totalTokens: BigNumber, wallets: number, + /// This is the amount of collateral you'd have to bet on an outcome + /// to receive 1 collateral of winnings. + price: BigNumber, + /// What percentage of the vote this outcome received + percentage: BigNumber, } type MarketId = string type OutcomeId = string const marketFromResponse = (response: ResponseMarket): Market => { + const outcomes = response.outcomes.map((outcome) => outcomeFromResponse(response, outcome)) return { id: `${response.id}`, title: response.title, + image: lvnLogo, // ToDo: use real image from each market description: response.description, - possibleOutcomes: response.outcomes.map(outcomeFromResponse), + possibleOutcomes: outcomes, denom: response.denom, depositFee: BigNumber(response.deposit_fee), withdrawalFee: BigNumber(response.withdrawal_fee), depositStopDate: new Nanoseconds(response.deposit_stop_date), withdrawalStopDate: new Nanoseconds(response.withdrawal_stop_date), - winnerOutcome: response.winner ?? undefined, + winnerOutcome: response.winner ? outcomes.find(outcome => outcome.id === `${response.winner}`) : undefined, totalWallets: response.total_wallets, + poolSize: BigNumber(response.pool_size), } } -const outcomeFromResponse = (response: ResponseMarketOutcome): MarketOutcome => { +const outcomeFromResponse = (market: ResponseMarket, response: ResponseMarketOutcome): MarketOutcome => { + let totalTokens = BigNumber(0); + for (const outcome of market.outcomes) { + totalTokens = totalTokens.plus(outcome.total_tokens) + } + const outcomeTotalTokens = BigNumber(response.total_tokens); return { id: `${response.id}`, label: response.label, - poolTokens: BigNumber(response.pool_tokens), - totalTokens: BigNumber(response.total_tokens), + totalTokens: outcomeTotalTokens, wallets: response.wallets, + price: totalTokens.isZero() ? BigNumber(1) : outcomeTotalTokens.div(totalTokens), + percentage: totalTokens.isZero() ? BigNumber(0) : outcomeTotalTokens.times(100).div(totalTokens) } } -const fetchMarket = (marketId: MarketId): Promise => { - return fetchQuerier( +const fetchMarket = async (marketId: MarketId): Promise => { + await sleep(3000) + return await fetchQuerier( "/v1/predict/market", marketFromResponse, { @@ -101,4 +119,4 @@ const marketQuery = (marketId: string) => queryOptions({ queryFn: () => fetchMarket(marketId), }) -export { marketQuery, type Market, type MarketOutcome, type MarketId, type OutcomeId } +export { marketQuery, MARKET_KEYS, type Market, type MarketOutcome, type MarketId, type OutcomeId } diff --git a/frontend/src/api/queries/Positions.ts b/frontend/src/api/queries/Positions.ts index d6c2c5b..e67cab3 100644 --- a/frontend/src/api/queries/Positions.ts +++ b/frontend/src/api/queries/Positions.ts @@ -1,20 +1,27 @@ import BigNumber from 'bignumber.js' +import { queryOptions } from '@tanstack/react-query' import { NETWORK_ID } from '@config/chain' import { CONTRACT_ADDRESS } from '@config/environment' import { fetchQuerier } from '@api/querier' import { MarketId, OutcomeId } from './Market' -import { queryOptions } from '@tanstack/react-query' interface PositionsResponse { outcomes: string[], + claimed_winnings: boolean, } -type Positions = Map +interface Positions { + outcomes: Map, + claimed: boolean, +} const positionsFromResponse = (response: PositionsResponse): Positions => { const entries = response.outcomes.map((amount, index) => [`${index}`, BigNumber(amount)] as const) - return new Map(entries) + return { + outcomes: new Map(entries), + claimed: response.claimed_winnings, + } } const fetchPositions = (address: string, marketId: MarketId): Promise => { diff --git a/frontend/src/assets/brand/logo.png b/frontend/src/assets/brand/logo.png new file mode 100644 index 0000000..ef5469b Binary files /dev/null and b/frontend/src/assets/brand/logo.png differ diff --git a/frontend/src/assets/logos/keplr.svg b/frontend/src/assets/logos/keplr.svg new file mode 100644 index 0000000..e56c525 --- /dev/null +++ b/frontend/src/assets/logos/keplr.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/logos/leap.svg b/frontend/src/assets/logos/leap.svg new file mode 100644 index 0000000..348bc00 --- /dev/null +++ b/frontend/src/assets/logos/leap.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/assets/logos/walletconnect.svg b/frontend/src/assets/logos/walletconnect.svg new file mode 100644 index 0000000..a03a5d6 --- /dev/null +++ b/frontend/src/assets/logos/walletconnect.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/common/ConnectionModal/index.tsx b/frontend/src/components/common/ConnectionModal/index.tsx index 6d444f8..8a6a93d 100644 --- a/frontend/src/components/common/ConnectionModal/index.tsx +++ b/frontend/src/components/common/ConnectionModal/index.tsx @@ -1,8 +1,10 @@ -import { ReactNode } from 'react' -import { WalletType, checkWallet, useConnect } from 'graz' -import { Button, DialogContent, DialogTitle, ModalClose, ModalDialog, Sheet } from '@mui/joy' +import { WalletType, checkWallet, useAccount } from 'graz' +import { Box, Button, DialogContent, DialogTitle, ModalClose, ModalDialog, Sheet } from '@mui/joy' -import { CHAIN_INFO } from '@config/chain' +import keplrLogo from '@assets/logos/keplr.svg' +import leapLogo from '@assets/logos/leap.svg' +import walletconnectLogo from '@assets/logos/walletconnect.svg' +import { useConnectWallet } from '@config/chain' import { useNotifications } from '@config/notifications' import { dismiss, present } from '@state/modals' import { AppError } from '@utils/errors' @@ -15,34 +17,40 @@ const dismissConnectionModal = () => { dismiss(CONNECTION_MODAL_KEY) } interface WalletOption { type: WalletType, name: string, - icon?: ReactNode, + logo: string, } const supportedWallets: WalletOption[] = [ { type: WalletType.KEPLR, name: "Keplr Extension", + logo: keplrLogo, }, { type: WalletType.LEAP, name: "Leap Extension", + logo: leapLogo, }, { type: WalletType.WC_KEPLR_MOBILE, name: "Keplr App", + logo: keplrLogo, }, { type: WalletType.WC_LEAP_MOBILE, name: "Leap App", + logo: leapLogo, }, { type: WalletType.WALLETCONNECT, name: "WalletConnect", + logo: walletconnectLogo, }, ] const ConnectionModal = () => { - const { connectAsync } = useConnect() + const account = useAccount() + const connectWallet = useConnectWallet() const notifications = useNotifications() return ( @@ -55,20 +63,30 @@ const ConnectionModal = () => { {supportedWallets.filter(wallet => checkWallet(wallet.type)).map((wallet) => - + + + + { + onChange(e.currentTarget.value) + }} + /> + + + updateValueFromPercentage(value as number)} + slotProps={{ + thumb: { + "aria-label": "Percentage of max tokens", + } + }} + /> + + theme.spacing(3), + "--ButtonGroup-radius": theme => theme.vars.radius.xl, + flexWrap: "wrap", + }} + > + {[25, 50, 100].map(buttonPercentage => + + )} + + + + + {fieldState.error?.message || "\u00A0"} + + + + + } + /> + ) +} + +export { BetTokensAmountField, type BetTokensAmountFieldProps } diff --git a/frontend/src/features/MarketDetail/components/MarketBetting/form.ts b/frontend/src/features/MarketDetail/components/MarketBetting/Buy/form.ts similarity index 77% rename from frontend/src/features/MarketDetail/components/MarketBetting/form.ts rename to frontend/src/features/MarketDetail/components/MarketBetting/Buy/form.ts index 2acbbfd..29f3f5c 100644 --- a/frontend/src/features/MarketDetail/components/MarketBetting/form.ts +++ b/frontend/src/features/MarketDetail/components/MarketBetting/Buy/form.ts @@ -1,12 +1,12 @@ import { useForm } from 'react-hook-form' import { useQuery } from '@tanstack/react-query' -import { MarketId } from '@api/queries/Market' +import { Market } from '@api/queries/Market' import { ntrnPriceQuery } from '@api/queries/NtrnPrice' import { usePlaceBet } from '@api/mutations/PlaceBet' import { NTRN, USD } from '@utils/tokens' -interface BetFormValues { +interface BuyFormValues { betAmount: { value: string, toggled: boolean, @@ -14,8 +14,8 @@ interface BetFormValues { betOutcome: string | null, } -const useMarketBettingForm = (marketId: MarketId) => { - const form = useForm({ +const useMarketBuyForm = (market: Market) => { + const form = useForm({ defaultValues: { betAmount: { value: "", @@ -25,11 +25,11 @@ const useMarketBettingForm = (marketId: MarketId) => { }, }) - const placeBet = usePlaceBet(marketId) + const placeBet = usePlaceBet(market.id) const ntrnPrice = useQuery(ntrnPriceQuery) - const onSubmit = (formValues: BetFormValues) => { + const onSubmit = (formValues: BuyFormValues) => { const isToggled = formValues.betAmount.toggled const betAmount = formValues.betAmount.value const betOutcome = formValues.betOutcome @@ -43,9 +43,9 @@ const useMarketBettingForm = (marketId: MarketId) => { } } - const canSubmit = form.formState.isValid && !form.formState.isSubmitting + const canSubmit = form.formState.isValid && !form.formState.isSubmitting && ntrnPrice.data?.price return { form, canSubmit, onSubmit } } -export { useMarketBettingForm } +export { useMarketBuyForm } diff --git a/frontend/src/features/MarketDetail/components/MarketBetting/Buy/index.tsx b/frontend/src/features/MarketDetail/components/MarketBetting/Buy/index.tsx new file mode 100644 index 0000000..cce0722 --- /dev/null +++ b/frontend/src/features/MarketDetail/components/MarketBetting/Buy/index.tsx @@ -0,0 +1,54 @@ +import { Button, Stack } from '@mui/joy' +import { FormProvider } from 'react-hook-form' +import { useQuery } from '@tanstack/react-query' + +import { useCurrentAccount } from '@config/chain' +import { Market } from '@api/queries/Market' +import { ntrnBalanceQuery } from '@api/queries/NtrnBalance' +import { ntrnPriceQuery } from '@api/queries/NtrnPrice' +import { OutcomeField } from '../OutcomeField' +import { NtrnAmountField } from '../NtrnAmountField' +import { useMarketBuyForm } from './form' + +const MarketBuyForm = (props: { market: Market }) => { + const { market } = props + const account = useCurrentAccount() + const balance = useQuery(ntrnBalanceQuery(account.bech32Address)) + const price = useQuery(ntrnPriceQuery) + + const { form, canSubmit, onSubmit } = useMarketBuyForm(market) + + return ( + + + + + + + + + + ) +} + +export { MarketBuyForm } diff --git a/frontend/src/features/MarketDetail/components/MarketBetting/Claim/form.ts b/frontend/src/features/MarketDetail/components/MarketBetting/Claim/form.ts new file mode 100644 index 0000000..7835b3e --- /dev/null +++ b/frontend/src/features/MarketDetail/components/MarketBetting/Claim/form.ts @@ -0,0 +1,32 @@ +import { useForm } from 'react-hook-form' +import { useQuery } from '@tanstack/react-query' + +import { useCurrentAccount } from '@config/chain' +import { Market } from '@api/queries/Market' +import { positionsQuery } from '@api/queries/Positions' +import { useClaimEarnings } from '@api/mutations/ClaimEarnings' + +const useMarketClaimForm = (market: Market) => { + const form = useForm() + + const claimEarnings = useClaimEarnings(market.id) + + const account = useCurrentAccount() + const positions = useQuery(positionsQuery(account.bech32Address, market.id)) + + const hasEarnings = + market.winnerOutcome !== undefined + && !!positions.data + && !positions.data.claimed + && !!positions.data.outcomes.get(market.winnerOutcome.id)?.gt(0) + + const onSubmit = () => { + return claimEarnings.mutateAsync() + } + + const canSubmit = form.formState.isValid && !form.formState.isSubmitting && hasEarnings + + return { form, canSubmit, onSubmit } +} + +export { useMarketClaimForm } diff --git a/frontend/src/features/MarketDetail/components/MarketBetting/Claim/index.tsx b/frontend/src/features/MarketDetail/components/MarketBetting/Claim/index.tsx new file mode 100644 index 0000000..631ffba --- /dev/null +++ b/frontend/src/features/MarketDetail/components/MarketBetting/Claim/index.tsx @@ -0,0 +1,110 @@ +import { Box, Button, Sheet, Skeleton, Stack, Typography } from '@mui/joy' +import { FormProvider } from 'react-hook-form' +import { useSuspenseQuery } from '@tanstack/react-query' + +import { useCurrentAccount } from '@config/chain' +import { Market } from '@api/queries/Market' +import { Positions, positionsQuery } from '@api/queries/Positions' +import { LoadableComponent } from '@lib/Loadable' +import { ErrorSkeleton } from '@lib/Error/Skeleton' +import { useMarketClaimForm } from './form' + +const MarketClaimForm = (props: { market: Market }) => { + const { market } = props + const { form, canSubmit, onSubmit } = useMarketClaimForm(market) + + return ( + + + Earnings + + + + + + + ) +} + +const Earnings = (props: { market: Market }) => { + const { market } = props + const account = useCurrentAccount() + + return ( + useSuspenseQuery(positionsQuery(account.bech32Address, market.id)).data} + renderContent={(positions) => } + loadingFallback={ + + + + } + errorFallback={ + } + /> + } + /> + ) +} + +const EarningsContent = (props: { market: Market, positions: Positions }) => { + const { market, positions } = props + const earnings = market.winnerOutcome + ? positions.outcomes.get(market.winnerOutcome.id) + : undefined + + return ( + + {positions.claimed + ? You have claimed your earnings for this market. + : (earnings?.gt(0) + ? + + + {market.winnerOutcome?.label} + + + {earnings.toFixed(3)} tokens + + + : You have no earnings for this market. + ) + } + + ) +} + +const EarningsPlaceholder = () => { + return ( + + + + Yes + + + 0.000 tokens + + + + ) +} + +export { MarketClaimForm } diff --git a/frontend/src/features/MarketDetail/components/MarketBetting/Sell/form.ts b/frontend/src/features/MarketDetail/components/MarketBetting/Sell/form.ts new file mode 100644 index 0000000..fba95e0 --- /dev/null +++ b/frontend/src/features/MarketDetail/components/MarketBetting/Sell/form.ts @@ -0,0 +1,43 @@ +import BigNumber from 'bignumber.js' +import { useForm } from 'react-hook-form' + +import { Market } from '@api/queries/Market' +import { useCancelBet } from '@api/mutations/CancelBet' +import { useLatestFormValues } from '@utils/forms' + +interface SellFormValues { + sellAmount: string, + sellOutcome: string | null, +} + +const useMarketSellForm = (market: Market) => { + const form = useForm({ + defaultValues: { + sellAmount: "", + sellOutcome: null, + }, + }) + + const formValues = useLatestFormValues(form) + const outcome = formValues.sellOutcome + + const cancelBet = useCancelBet(market.id) + + const onSubmit = (formValues: SellFormValues) => { + const sellAmount = formValues.sellAmount + const sellOutcome = formValues.sellOutcome + + if (sellAmount && sellOutcome) { + const tokensAmount = BigNumber(sellAmount) + return cancelBet.mutateAsync({ outcomeId: sellOutcome, tokensAmount: tokensAmount }) + } else { + return Promise.reject() + } + } + + const canSubmit = form.formState.isValid && !form.formState.isSubmitting + + return { form, canSubmit, onSubmit, outcome } +} + +export { useMarketSellForm } diff --git a/frontend/src/features/MarketDetail/components/MarketBetting/Sell/index.tsx b/frontend/src/features/MarketDetail/components/MarketBetting/Sell/index.tsx new file mode 100644 index 0000000..81b849d --- /dev/null +++ b/frontend/src/features/MarketDetail/components/MarketBetting/Sell/index.tsx @@ -0,0 +1,53 @@ +import { Button, Stack } from '@mui/joy' +import { FormProvider } from 'react-hook-form' +import { useQuery } from '@tanstack/react-query' + +import { useCurrentAccount } from '@config/chain' +import { Market } from '@api/queries/Market' +import { positionsQuery } from '@api/queries/Positions' +import { useMarketSellForm } from './form' +import { OutcomeField } from '../OutcomeField' +import { BetTokensAmountField } from '../BetTokensAmountField' + +const MarketSellForm = (props: { market: Market }) => { + const { market } = props + const account = useCurrentAccount() + const positions = useQuery(positionsQuery(account.bech32Address, market.id)) + + const { form, canSubmit, onSubmit, outcome } = useMarketSellForm(market) + const tokensBalance = outcome ? positions.data?.outcomes.get(outcome) : undefined + console.log(outcome, tokensBalance?.toFixed()) + + return ( + + + + + + + + + + ) +} + +export { MarketSellForm } diff --git a/frontend/src/features/MarketDetail/components/MarketBetting/index.tsx b/frontend/src/features/MarketDetail/components/MarketBetting/index.tsx index cb36856..7108b6f 100644 --- a/frontend/src/features/MarketDetail/components/MarketBetting/index.tsx +++ b/frontend/src/features/MarketDetail/components/MarketBetting/index.tsx @@ -1,19 +1,16 @@ +import { useState } from 'react' import { useAccount } from 'graz' -import { Button, Stack, Typography } from '@mui/joy' -import { FormProvider } from 'react-hook-form' -import { useQuery } from '@tanstack/react-query' +import { P, match } from 'ts-pattern' +import { Button, Sheet, Stack, Typography } from '@mui/joy' -import { useCurrentAccount } from '@config/chain' -import { ntrnBalanceQuery } from '@api/queries/NtrnBalance' -import { ntrnPriceQuery } from '@api/queries/NtrnPrice' import { Market } from '@api/queries/Market' import { StyleProps, mergeSx } from '@utils/styles' import { LoadableWidget } from '@lib/Loadable/Widget' import { ConnectButton } from '@common/ConnectButton' -import { useSuspenseCurrentMarket } from '@features/MarketDetail/utils' -import { useMarketBettingForm } from './form' -import { NtrnAmountField } from './NtrnAmountField' -import { OutcomeField } from './OutcomeField' +import { MarketStatus, useMarketStatus, useSuspenseCurrentMarket } from '@features/MarketDetail/utils' +import { MarketClaimForm } from './Claim' +import { MarketBuyForm } from './Buy' +import { MarketSellForm } from './Sell' const MarketBetting = (props: StyleProps) => { return ( @@ -29,97 +26,102 @@ const MarketBetting = (props: StyleProps) => { const MarketBettingContent = (props: { market: Market }) => { const { market } = props const { isConnected } = useAccount() + const marketStatus = useMarketStatus(market) return ( isConnected - ? - : + ? + match(marketStatus) + .with({ state : "decided"}, () => ) + .with({ state: "deciding" }, () => ) + .otherwise((status) => ) + : ) } -const MarketBettingForm = (props: { market: Market }) => { - const { market } = props - const account = useCurrentAccount() - const balance = useQuery(ntrnBalanceQuery(account.bech32Address)) - const price = useQuery(ntrnPriceQuery) - - const { form, canSubmit, onSubmit } = useMarketBettingForm(market.id) +const MarketBettingForm = (props: { market: Market, status: MarketStatus }) => { + const { market, status } = props + const [action, setAction] = useState<"buy" | "sell">("buy") return ( - - - - - - - - - - - + <> + + - + + {match(action) + .with("buy", () => ) + .with("sell", () => ) + .exhaustive() + } + + ) +} + +const MarketBettingDeciding = () => { + return ( + <> + + Resolution + + + + This market is awaiting a decision by the arbitrator, and its outcome hasn't been decided yet. + + + ) } -const MarketBettingDisconnected = () => { +const MarketBettingDisconnected = (props: { status: MarketStatus }) => { + const { status } = props + return ( <> - - Connect your wallet to place a bet. + + {match(status.state) + .with(P.union("decided", "deciding"), () => "Connect your wallet to view your earnings.") + .with(P.union("withdrawals", "deposits"), () => "Connect your wallet to make a bet.") + .exhaustive() + } - + ) } const MarketBettingPlaceholder = () => { + // TODO: better placeholder return ( <> diff --git a/frontend/src/features/MarketDetail/components/MarketDescription/index.tsx b/frontend/src/features/MarketDetail/components/MarketDescription/index.tsx new file mode 100644 index 0000000..bb604e1 --- /dev/null +++ b/frontend/src/features/MarketDetail/components/MarketDescription/index.tsx @@ -0,0 +1,50 @@ +import { Box, Typography } from '@mui/joy' + +import { Market } from '@api/queries/Market' +import { StyleProps } from '@utils/styles' +import { LoadableWidget } from '@lib/Loadable/Widget' +import { useSuspenseCurrentMarket } from '@features/MarketDetail/utils' + +const MarketDescription = (props: StyleProps) => { + return ( + } + placeholderContent={} + sx={props.sx} + /> + ) +} + +const MarketDescriptionContent = (props: { market: Market }) => { + const { market } = props + + return ( + <> + + Description + + + + {market.description} + + + ) +} + +const MarketDescriptionPlaceholder = () => { + return ( + <> + + Description + + + theme.spacing(1)} + height={theme => theme.spacing(10)} + /> + + ) +} + +export { MarketDescription } diff --git a/frontend/src/features/MarketDetail/components/MarketImage/index.tsx b/frontend/src/features/MarketDetail/components/MarketImage/index.tsx new file mode 100644 index 0000000..acc1272 --- /dev/null +++ b/frontend/src/features/MarketDetail/components/MarketImage/index.tsx @@ -0,0 +1,31 @@ +import { Box, BoxProps } from '@mui/joy' + +import { Market } from '@api/queries/Market' +import { mergeSx } from '@utils/styles' + +interface MarketImageProps extends Omit { + market: Market, +} + +const MarketImage = (props: MarketImageProps) => { + const { market, ...boxProps } = props + + return ( + + ) +} + +export { MarketImage, type MarketImageProps } diff --git a/frontend/src/features/MarketDetail/components/MarketDetails/index.tsx b/frontend/src/features/MarketDetail/components/MarketOutcomes/index.tsx similarity index 53% rename from frontend/src/features/MarketDetail/components/MarketDetails/index.tsx rename to frontend/src/features/MarketDetail/components/MarketOutcomes/index.tsx index a6ae246..62a98aa 100644 --- a/frontend/src/features/MarketDetail/components/MarketDetails/index.tsx +++ b/frontend/src/features/MarketDetail/components/MarketOutcomes/index.tsx @@ -2,40 +2,33 @@ import { Box, Stack, Typography } from '@mui/joy' import { Market } from '@api/queries/Market' import { StyleProps } from '@utils/styles' +import { NTRN } from '@utils/tokens' import { LoadableWidget } from '@lib/Loadable/Widget' import { useSuspenseCurrentMarket } from '@features/MarketDetail/utils' -import { getPercentage } from '@utils/number' -import BigNumber from 'bignumber.js' -const MarketDetails = (props: StyleProps) => { +const MarketOutcomes = (props: StyleProps) => { return ( } - placeholderContent={} + renderContent={(market) => } + placeholderContent={} sx={props.sx} /> ) } -const MarketDetailsContent = (props: { market: Market }) => { +const MarketOutcomesContent = (props: { market: Market }) => { const { market } = props - const marketSum = market.possibleOutcomes.reduce((sum, outcome) => sum.plus(outcome.totalTokens), BigNumber(0)) return ( <> - - {market.title} + + Outcomes - - {market.description} - - {market.possibleOutcomes.map(outcome => @@ -44,26 +37,31 @@ const MarketDetailsContent = (props: { market: Market }) => { fontWeight={600} color={outcome.label === "Yes" ? "success" : outcome.label === "No" ? "danger" : "neutral"} > - {getPercentage(outcome.totalTokens, marketSum)}% {outcome.label} + {outcome.label} - {outcome.price.toFormat(3)} + + + {outcome.percentage.toFixed(1)}% - {outcome.totalTokens.toFixed(3)} Tokens + {outcome.totalTokens.toFixed(3)} tokens bet )} + + + Prize pool size: {NTRN.fromUnits(market.poolSize).toFormat(true)} + + ) } -const MarketDetailsPlaceholder = () => { +const MarketOutcomesPlaceholder = () => { return ( <> - - Loading this market's title... - - - Loading this market's description... + + Outcomes { level="title-lg" fontWeight={600} > - 0.00% {outcome} + {outcome} - 0.000 - - 0.000000 Tokens + + 0.0% + + + 0.000 tokens bet )} + + + Prize pool size: 0.000000 NTRN + + ) } -export { MarketDetails } +export { MarketOutcomes } diff --git a/frontend/src/features/MarketDetail/components/MarketStatus/index.tsx b/frontend/src/features/MarketDetail/components/MarketStatus/index.tsx new file mode 100644 index 0000000..641327e --- /dev/null +++ b/frontend/src/features/MarketDetail/components/MarketStatus/index.tsx @@ -0,0 +1,31 @@ +import { match } from 'ts-pattern' +import { Chip, ChipProps } from '@mui/joy' + +import { Market } from '@api/queries/Market' +import { useMarketStatus } from '@features/MarketDetail/utils' + +interface MarketStatusProps extends Omit { + market: Market, +} + +const MarketStatus = (props: MarketStatusProps) => { + const { market, ...chipProps } = props + const status = useMarketStatus(market) + + return ( + + {match(status) + .with({ state: "decided" }, ({ winner }) => `Winner: "${winner.label}"!`) + .with({ state: "deciding" }, () => "Awaiting arbitrator decision") + .with({ state: "deposits" }, ({ timeLeft }) => `Withdrawals ended. Deposits end in ${timeLeft}`) + .with({ state: "withdrawals" }, ({ timeLeft }) => `Withdrawals end in ${timeLeft}`) + .exhaustive() + } + + ) +} + +export { MarketStatus, type MarketStatusProps } diff --git a/frontend/src/features/MarketDetail/components/MarketTitle/index.tsx b/frontend/src/features/MarketDetail/components/MarketTitle/index.tsx new file mode 100644 index 0000000..a416219 --- /dev/null +++ b/frontend/src/features/MarketDetail/components/MarketTitle/index.tsx @@ -0,0 +1,80 @@ +import { Box, Chip, IconButton, Stack, Typography } from '@mui/joy' + +import { CopyIcon } from '@assets/icons/Copy' +import { TickIcon } from '@assets/icons/Tick' +import { routes } from '@config/router' +import { Market } from '@api/queries/Market' +import { StyleProps } from '@utils/styles' +import { useCopyToClipboard } from '@utils/hooks' +import { LoadableWidget } from '@lib/Loadable/Widget' +import { useSuspenseCurrentMarket } from '@features/MarketDetail/utils' +import { MarketImage } from '../MarketImage' +import { MarketStatus } from '../MarketStatus' + +const MarketTitle = (props: StyleProps) => { + return ( + } + placeholderContent={} + sx={props.sx} + /> + ) +} + +const MarketTitleContent = (props: { market: Market }) => { + const { market } = props + const [copied, copy] = useCopyToClipboard() + + return ( + + + ({ xs: theme.spacing(8), sm: theme.spacing(10) })} + minWidth={theme => ({ xs: theme.spacing(8), sm: theme.spacing(10) })} + /> + + + {market.title} + + + + + + + { + copy(`${window.location.host}${routes.market(market.id)}`, "Market URL") }} + > + {copied ? : } + + + + ) +} + +const MarketTitlePlaceholder = () => { + return ( + + ({ xs: theme.spacing(8), sm: theme.spacing(10) })} + height={theme => ({ xs: theme.spacing(8), sm: theme.spacing(10) })} + minWidth={theme => ({ xs: theme.spacing(8), sm: theme.spacing(10) })} + minHeight={theme => ({ xs: theme.spacing(8), sm: theme.spacing(10) })} + /> + + + + Loading market's title... + + + Loading market's status... + + + + ) +} + +export { MarketTitle } diff --git a/frontend/src/features/MarketDetail/components/MyPositions/index.tsx b/frontend/src/features/MarketDetail/components/MyPositions/index.tsx index c7ad31c..0c9664a 100644 --- a/frontend/src/features/MarketDetail/components/MyPositions/index.tsx +++ b/frontend/src/features/MarketDetail/components/MyPositions/index.tsx @@ -5,6 +5,7 @@ import { useCurrentAccount } from '@config/chain' import { Market } from '@api/queries/Market' import { Positions, positionsQuery } from '@api/queries/Positions' import { StyleProps } from '@utils/styles' +import { NTRN } from '@utils/tokens' import { LoadableWidget } from '@lib/Loadable/Widget' import { useSuspenseCurrentMarket } from '@features/MarketDetail/utils' @@ -39,7 +40,7 @@ const MyPositionsContent = (props: { market: Market, positions: Positions }) => gap={4} > {market.possibleOutcomes - .filter(outcome => positions.get(outcome.id)?.gt(0)) + .filter(outcome => positions.outcomes.get(outcome.id)?.gt(0)) .map(outcome => {outcome.label} - {positions.get(outcome.id)?.toFixed(3)} Tokens + Potential winnings: {NTRN.fromUnits(positions.outcomes.get(outcome.id)?.times(market.poolSize) ?? 0).toFormat(true)} + + + {positions.outcomes.get(outcome.id)?.toFixed(3)} Tokens ) @@ -71,11 +75,14 @@ const MyPositionsPlaceholder = () => { > {[0, 1, 2].map(index => - - 0.00% Yes + + Yes + + + 0 Tokens - - 10000 TOKENS + + Potential winnings: 0.000000 NTRN )} diff --git a/frontend/src/features/MarketDetail/utils.ts b/frontend/src/features/MarketDetail/utils.ts index d8640c1..b288417 100644 --- a/frontend/src/features/MarketDetail/utils.ts +++ b/frontend/src/features/MarketDetail/utils.ts @@ -1,7 +1,9 @@ import { useParams } from 'react-router-dom' import { useSuspenseQuery } from '@tanstack/react-query' -import { Market, marketQuery } from '@api/queries/Market' +import { useTimedMemo } from '@state/timestamps' +import { Market, MarketOutcome, marketQuery } from '@api/queries/Market' +import { getTimeBetween } from '@utils/time' const useCurrentMarketQuery = () => { const { marketId } = useParams() @@ -17,4 +19,28 @@ const useSuspenseCurrentMarket = (): Market => { return useSuspenseQuery(query).data } -export { useCurrentMarketQuery, useSuspenseCurrentMarket } +type MarketStatus = + | { state: "withdrawals", timeLeft: string } + | { state: "deposits", timeLeft: string } + | { state: "deciding" } + | { state: "decided", winner: MarketOutcome } + +const useMarketStatus = (market: Market): MarketStatus => { + return useTimedMemo("marketsStatus", (timestamp) => { + if (market.winnerOutcome) { + return { state: "decided", winner: market.winnerOutcome } + } + + if (timestamp.gte(market.depositStopDate)) { + return { state: "deciding" } + } + + if (timestamp.gte(market.withdrawalStopDate)) { + return { state: "deposits", timeLeft: getTimeBetween(timestamp, market.depositStopDate) } + } + + return { state: "withdrawals", timeLeft: getTimeBetween(timestamp, market.withdrawalStopDate) } + }, [market.winnerOutcome, market.depositStopDate, market.withdrawalStopDate]) +} + +export { useCurrentMarketQuery, useSuspenseCurrentMarket, useMarketStatus, type MarketStatus } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index f80d606..b500d3e 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -9,18 +9,21 @@ 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' +import { ModalsHandler } from '@state/modals' +import { TimestampsHandler } from '@state/timestamps' ReactDOM.createRoot(document.getElementById('root')!).render( - - - - - + + + + + + + diff --git a/frontend/src/pages/Market/index.tsx b/frontend/src/pages/Market/index.tsx index 83c81ae..2fe64b2 100644 --- a/frontend/src/pages/Market/index.tsx +++ b/frontend/src/pages/Market/index.tsx @@ -3,13 +3,13 @@ import { Box } from '@mui/joy' import { buildGridAreas } from '@utils/styles' import { BasePage } from '@common/BasePage' -import { useSuspenseCurrentMarket } from '@features/MarketDetail/utils' -import { MarketDetails } from '@features/MarketDetail/components/MarketDetails' +import { MarketTitle } from '@features/MarketDetail/components/MarketTitle' import { MyPositions } from '@features/MarketDetail/components/MyPositions' import { MarketBetting } from '@features/MarketDetail/components/MarketBetting' +import { MarketOutcomes } from '@features/MarketDetail/components/MarketOutcomes' +import { MarketDescription } from '@features/MarketDetail/components/MarketDescription' const MarketPage = () => { - useSuspenseCurrentMarket() const account = useAccount() return ( @@ -24,21 +24,27 @@ const MarketPage = () => { gridTemplateAreas: { xs: buildGridAreas([ "title", + "outcomes", "positions", + "description", "betting", ]), md: buildGridAreas([ "title betting", + "outcomes betting", "positions betting", + "description betting", "rest betting", ]), }, }} > - + + {account.isConnected && } + diff --git a/frontend/src/state/modals.tsx b/frontend/src/state/modals.tsx index 8400c7b..b930e78 100644 --- a/frontend/src/state/modals.tsx +++ b/frontend/src/state/modals.tsx @@ -36,7 +36,7 @@ const dismiss = (key: string) => { }) } -const ModalsProvider = (props: PropsWithChildren) => { +const ModalsHandler = (props: PropsWithChildren) => { const { children } = props const modals = useStore(modalsStore) @@ -57,4 +57,4 @@ const ModalsProvider = (props: PropsWithChildren) => { ) } -export { ModalsProvider, present, dismiss } +export { ModalsHandler, present, dismiss } diff --git a/frontend/src/state/timestamps.tsx b/frontend/src/state/timestamps.tsx new file mode 100644 index 0000000..8f7e0d0 --- /dev/null +++ b/frontend/src/state/timestamps.tsx @@ -0,0 +1,62 @@ +import { DependencyList, PropsWithChildren, useEffect, useMemo } from 'react' +import { Store, useStore } from '@tanstack/react-store' + +import { MS_IN_SECOND, Nanoseconds } from '@utils/time' + +export const MARKET_STATUS_REFRESH_RATE = MS_IN_SECOND * 5 +export const STAKINGS_STATUS_REFRESH_RATE = MS_IN_SECOND * 10 +export const UNSTAKINGS_STATUS_REFRESH_RATE = MS_IN_SECOND * 10 +export const VESTINGS_STATUS_REFRESH_RATE = MS_IN_SECOND * 10 + +const now = Nanoseconds.fromDate(new Date()) + +const timestampsStore = new Store({ + marketsStatus: now, +}) + +type TimestampKey = keyof typeof timestampsStore.state + +const updateTimestamp = (key: TimestampKey) => { + return timestampsStore.setState(state => ({ + ...state, + [key]: Nanoseconds.fromDate(new Date()), + })) +} + +const getTimestamp = (key: TimestampKey) => timestampsStore.state[key] + +const useTimestamps = () => useStore(timestampsStore) + +const useRefreshPeriodically = (key: TimestampKey, refreshRate: number) => { + useEffect(() => { + const callback = () => updateTimestamp(key) + const interval = setInterval(callback, refreshRate) + return () => { + clearInterval(interval) + } + }, [refreshRate]) +} + +const TimestampsHandler = (props: PropsWithChildren) => { + useRefreshPeriodically("marketsStatus", MARKET_STATUS_REFRESH_RATE) + + return ( + props.children + ) +} + +/** + * Stores a memoized value that is recalculated every time the given timestamp refreshes, or whenever the list of dependencies changes. + * + * @param key The identifier of the timestamp to use for updates. + * @param getValue The callback that calculates a new value. Can receive the updated timestamp. + * @param deps The list of dependencies that cause the value to be recalculated. + */ +const useTimedMemo = (key: TimestampKey, getValue: (ts: Nanoseconds) => T, deps: DependencyList): T => { + const timestamp = useTimestamps()[key] + const value = useMemo(() => getValue(timestamp), [timestamp, ...deps]) + + return value +} + +export { TimestampsHandler, updateTimestamp, getTimestamp, useTimestamps, useTimedMemo } diff --git a/frontend/src/utils/errors.ts b/frontend/src/utils/errors.ts index 17bc4ab..48a8bbe 100644 --- a/frontend/src/utils/errors.ts +++ b/frontend/src/utils/errors.ts @@ -39,14 +39,36 @@ class AppError extends Error { } type UserAction = + | "connect" | "buy" | "sell" - | "collect" + | "claim" /** * @returns user-friendly errors (if possible), based on the action that is being performed. */ -const errorForAction = (err: T, actionType?: UserAction): AppError | T => { +const errorForAction = (err: any, actionType?: UserAction): AppError | any => { + + if (actionType === "connect") { + return match(err) + .with( + { message: P.string.regex("Request rejected") }, + () => new AppError("User rejected the connection.", { cause: err, level: "suppress" }), + ) + .with( + { message: P.string.regex("User closed wallet connect") }, + () => new AppError("User rejected the connection.", { cause: err, level: "suppress" }), + ) + .with( + { message: P.string.regex("There is no chain info for .+") }, + () => AppError.withCause( + `Your app or extension doesn't have the necessary chain info for ${CHAIN_ID}. Please add the necessary chain and try again.`, + err, + ), + ) + .otherwise(() => err ) + } + if (err instanceof AxiosError) { const message = match(err.response?.data) .with(P.string, (msg) => msg) diff --git a/frontend/src/utils/forms.ts b/frontend/src/utils/forms.ts new file mode 100644 index 0000000..bbadf23 --- /dev/null +++ b/frontend/src/utils/forms.ts @@ -0,0 +1,17 @@ +import { FieldValues, UseFormReturn } from 'react-hook-form' + +/** + * Utility to get the latest values in a form. Helpful when a field's state changes after the render cycle. + * Note: unchanged (and even unregistered) fields will return their default values. + * + * @see https://github.com/react-hook-form/react-hook-form/issues/6548 + * @see https://github.com/react-hook-form/react-hook-form/issues/6482 + */ +const useLatestFormValues = (form: UseFormReturn) => { + return { + ...form.watch(), // This causes a re-render when a field input changes by a direct action, but fields can change afterwards + ...form.getValues(), // This gets the absolute latest values, but alone wouldn't cause a re-render + } +} + +export { useLatestFormValues } diff --git a/frontend/src/utils/time.ts b/frontend/src/utils/time.ts index ac12676..8a1d4b3 100644 --- a/frontend/src/utils/time.ts +++ b/frontend/src/utils/time.ts @@ -1,5 +1,7 @@ import { BigNumber } from 'bignumber.js' +import { pluralize } from './string' + export const NS_IN_MS = 1000000 export const MS_IN_SECOND = 1000 export const MS_IN_MINUTE = 60 * MS_IN_SECOND @@ -10,6 +12,20 @@ const sleep = (ms: number) => { return new Promise(resolve => setTimeout(resolve, ms)) } +const formatDate = (date: Date, options?: Intl.DateTimeFormatOptions): string => { + return date.toLocaleString(undefined, { + timeStyle: "short", + dateStyle: "short", + hour12: false, + ...options, + }) +} + +const getTimeBetween = (from: Nanoseconds, to: Nanoseconds) => { + const ns = to.minus(from) + return nsToLargestTimeUnit(ns) +} + /** * A `BigNumber` that represents a number of nanoseconds, to make APIs more readable. */ @@ -43,4 +59,22 @@ class Nanoseconds extends BigNumber { } } -export { Nanoseconds, sleep } +const nsToLargestTimeUnit = (ns: Nanoseconds) => { + const ms = ns.dividedBy(NS_IN_MS) + const units = [ + { key: "day", ms: MS_IN_DAY }, + { key: "hour", ms: MS_IN_HOUR }, + { key: "minute", ms: MS_IN_MINUTE }, + { key: "second", ms: MS_IN_SECOND }, + ] + + for (const unit of units) { + if (ms.gte(unit.ms)) { + return pluralize(unit.key, ms.dividedToIntegerBy(unit.ms).toNumber(), true) + } + } + + return "0 days" // TODO: do we want to display this everywhere? +} + +export { Nanoseconds, sleep, formatDate, getTimeBetween }