From 70580a39ace867891d0af87ecbd5ce72e87665da Mon Sep 17 00:00:00 2001 From: Rithvik Vibhu Date: Sun, 13 Jun 2021 18:58:41 +0530 Subject: [PATCH] Portfolio cards and Balance break down (#358) * Functions for calculating balance and cards info * Balance and cards UI * Add coin height check * Split bids and names stats * Handle finalize button * Check isExpired * Renew Many * Refactor and move to background * Remove card buttons * Remove console logs --- app/background/wallet/bulk-renewal.js | 82 +++++ app/background/wallet/client.js | 4 +- app/background/wallet/service.js | 16 + app/background/wallet/stats.js | 270 +++++++++++++++ app/ducks/names.js | 11 + app/pages/Account/account.scss | 134 ++++++-- app/pages/Account/index.js | 473 +++++++++++++++++++++++--- 7 files changed, 911 insertions(+), 79 deletions(-) create mode 100644 app/background/wallet/bulk-renewal.js create mode 100644 app/background/wallet/stats.js diff --git a/app/background/wallet/bulk-renewal.js b/app/background/wallet/bulk-renewal.js new file mode 100644 index 000000000..76ec3298f --- /dev/null +++ b/app/background/wallet/bulk-renewal.js @@ -0,0 +1,82 @@ +const rules = require("hsd/lib/covenants/rules"); +const { types } = rules; +const { states } = require("hsd/lib/covenants/namestate"); +const MTX = require("hsd/lib/primitives/mtx"); +const Output = require("hsd/lib/primitives/output"); + +export const renewMany = async (wallet, names) => { + if (!Array.isArray(names)) { + throw new Error("names must be an array"); + } + + const mtx = new MTX(); + + for (const name of names) { + if (!rules.verifyName(name)) { + throw new Error("Invalid name."); + } + + const rawName = Buffer.from(name, "ascii"); + const nameHash = rules.hashName(rawName); + const ns = await wallet.getNameState(nameHash); + const height = wallet.wdb.height + 1; + const network = wallet.network; + + if (!ns) { + throw new Error("Auction not found."); + } + + const { hash, index } = ns.owner; + const coin = await wallet.getCoin(hash, index); + + if (!coin) { + throw new Error(`Wallet does not own: "${name}".`); + } + + if (ns.isExpired(height, network)) throw new Error("Name has expired!"); + + // Is local? + if (coin.height < ns.height) { + throw new Error(`Wallet does not own: "${name}".`); + } + + const state = ns.state(height, network); + + if (state !== states.CLOSED) { + throw new Error("Auction is not yet closed."); + } + + if ( + !coin.covenant.isRegister() && + !coin.covenant.isUpdate() && + !coin.covenant.isRenew() && + !coin.covenant.isFinalize() + ) { + throw new Error("Name must be registered."); + } + + if (height < ns.renewal + network.names.treeInterval) { + throw new Error("Must wait to renew."); + } + + const output = new Output(); + output.address = coin.address; + output.value = coin.value; + output.covenant.type = types.RENEW; + output.covenant.pushHash(nameHash); + output.covenant.pushU32(ns.height); + output.covenant.pushHash(await wallet.wdb.getRenewalBlock()); + + mtx.addOutpoint(ns.owner); + mtx.outputs.push(output); + } + + const unlock = await wallet.fundLock.lock(); + try { + await wallet.fill(mtx); + const finalizedTX = await wallet.finalize(mtx); + await wallet.sendMTX(finalizedTX, null); + } finally { + unlock(); + } +}; diff --git a/app/background/wallet/client.js b/app/background/wallet/client.js index 5974bac03..d2df29bc7 100644 --- a/app/background/wallet/client.js +++ b/app/background/wallet/client.js @@ -40,6 +40,7 @@ export const clientStub = ipcRendererInjector => makeClient(ipcRendererInjector, 'sendRegisterAll', 'transferMany', 'finalizeMany', + 'renewMany', 'sendTransfer', 'cancelTransfer', 'finalizeTransfer', @@ -55,5 +56,6 @@ export const clientStub = ipcRendererInjector => makeClient(ipcRendererInjector, 'zap', 'importName', 'rpcGetWalletInfo', - 'listWallets' + 'listWallets', + 'getStats', ]); diff --git a/app/background/wallet/service.js b/app/background/wallet/service.js index d85872e3d..c1d14ce1d 100644 --- a/app/background/wallet/service.js +++ b/app/background/wallet/service.js @@ -21,6 +21,8 @@ import { import {SET_FEE_INFO, SET_NODE_INFO} from "../../ducks/nodeReducer"; import createRegisterAll from "./create-register-all"; import {finalizeMany, transferMany} from "./bulk-transfer"; +import {renewMany} from "./bulk-renewal"; +import {getStats} from "./stats"; import {get, put} from "../db/service"; import hsdLedger from 'hsd-ledger'; @@ -446,6 +448,12 @@ class WalletService { await finalizeMany(wallet, names); }; + renewMany = async (names) => { + const {wdb} = this.node; + const wallet = await wdb.get(this.name); + await renewMany(wallet, names); + }; + sendRevealAll = () => this._ledgerProxy( () => this._executeRPC('createreveal', ['']), () => this._executeRPC('sendreveal', [''], this.lock), @@ -748,6 +756,12 @@ class WalletService { return ret; }; + getStats = async () => { + const {wdb} = this.node; + const wallet = await wdb.get(this.name); + return getStats(wallet); + }; + _onNodeStart = async (networkName, network, apiKey) => { const conn = await getConnection(); @@ -1225,6 +1239,7 @@ const methods = { sendRenewal: service.sendRenewal, transferMany: service.transferMany, finalizeMany: service.finalizeMany, + renewMany: service.renewMany, sendTransfer: service.sendTransfer, cancelTransfer: service.cancelTransfer, finalizeTransfer: service.finalizeTransfer, @@ -1241,6 +1256,7 @@ const methods = { importName: service.importName, rpcGetWalletInfo: service.rpcGetWalletInfo, listWallets: service.listWallets, + getStats: service.getStats, }; export async function start(server) { diff --git a/app/background/wallet/stats.js b/app/background/wallet/stats.js new file mode 100644 index 000000000..6995caa50 --- /dev/null +++ b/app/background/wallet/stats.js @@ -0,0 +1,270 @@ +const { states } = require("hsd/lib/covenants/namestate"); + +async function fromBids(wallet) { + const height = wallet.wdb.height + 1; + const network = wallet.network; + + // Live Auctions + let biddingHNS = 0; + let biddingNum = 0; + + // In Reveals + let revealingHNS = 0; + let revealingNum = 0; + + // Actionable + let revealableHNS = 0; + let revealableNum = 0; + let revealableBlock = null; + + // All bids + const bids = await wallet.getBids(); + + for (let bid of bids) { + // Don't bother if not own bid + if (!bid.own) { + continue; + } + + const blind = bid.lockup - bid.value; + const ns = await wallet.getNameState(bid.nameHash); + const state = ns.state(height, network); + + // Full bid + blind is locked up in the bidding phase + if (state === states.BIDDING) { + biddingNum++; + biddingHNS += bid.lockup; + continue; + } + + // Only care about auctions in REVEAL and CLOSED + if (state !== states.REVEAL && state !== states.CLOSED) { + continue; + } + + // If in reveal, first assume that the bid is not yet revealed and the whole amount is locked up + // Subtract later if a reveal is found + if (state === states.REVEAL) { + revealingHNS += bid.lockup; + revealingNum++; + } + + const bidCoin = await wallet.getCoin(bid.prevout.hash, bid.prevout.index); + const isRevealed = !bidCoin; + + // If bid not yet revealed and is in reveal period + if (!isRevealed) { + if (state === states.REVEAL) { + revealableHNS += blind; + revealableNum++; + const stats = ns.toStats(height, network); + if ( + revealableBlock === null || + stats.blocksUntilClose < revealableBlock + ) { + revealableBlock = stats.blocksUntilClose; + } + } + continue; + } + + // At this point, the bid is revealed (at least) + // If still in reveal period, the blind is no longer locked + if (state === states.REVEAL) { + revealingHNS -= blind; + continue; + } + } + + return { + bidding: { HNS: biddingHNS, num: biddingNum }, + revealing: { HNS: revealingHNS, num: revealingNum }, + revealable: { + HNS: revealableHNS, + num: revealableNum, + block: revealableBlock, + }, + }; +} + +async function fromReveals(wallet) { + const height = wallet.wdb.height + 1; + const network = wallet.network; + + let redeemableHNS = 0; + let redeemableNum = 0; + + // All reveals + const reveals = await wallet.getReveals(); + + for (let reveal of reveals) { + const ns = await wallet.getNameState(reveal.nameHash); + + if (!ns) { + continue; + } + + if (ns.isExpired(height, network)) { + continue; + } + + const state = ns.state(height, network); + + if (state < states.CLOSED) { + continue; + } + + if (!reveal.own) { + continue; + } + + if (reveal.prevout.equals(ns.owner)) { + continue; + } + + const revealCoin = await wallet.getCoin( + reveal.prevout.hash, + reveal.prevout.index + ); + + if (!revealCoin) { + continue; + } + + // Is local? + if (revealCoin.height < ns.height) { + continue; + } + + redeemableHNS += revealCoin.value; + redeemableNum++; + } + + return { + redeemable: { HNS: redeemableHNS, num: redeemableNum }, + }; +} + +async function fromNames(wallet) { + const height = wallet.wdb.height + 1; + const network = wallet.network; + + let transferringDomains = new Set(); + let transferringBlock = null; + let finalizableDomains = new Set(); + let renewableDomains = new Set(); + let renewableBlock = null; + let registerableHNS = 0; + let registerableNum = 0; + + const names = await wallet.getNames(); + + for (let ns of names) { + const name = ns.name.toString("utf-8"); + const stats = ns.toStats(height, network); + + const ownerCoin = await wallet.getCoin(ns.owner.hash, ns.owner.index); + + // Only act on currently owned names + if (!ownerCoin) { + continue; + } + + if (ns.isExpired(height, network)) { + continue; + } + + // Registerable names + if (ownerCoin.covenant.isReveal() || ownerCoin.covenant.isClaim()) { + if ( + !ownerCoin.covenant.isClaim() || + height >= ownerCoin.height + network.coinbaseMaturity + ) { + if (ns.state(height, network) === states.CLOSED) { + registerableHNS += ns.highest - ns.value; + registerableNum++; + continue; + } + } + } + + // Mark for renew if the name is going to expire in the next 2 months + if (stats.daysUntilExpire < 30 * 2) { + const isRenewable = + ns.registered && + ns.transfer === 0 && + ns.renewal + network.names.treeInterval <= height; + if (isRenewable) { + renewableDomains.add(name); + if ( + renewableBlock === null || + stats.blocksUntilExpire < renewableBlock + ) { + renewableBlock = stats.blocksUntilExpire; + } + } + } + + // Names being transferred? + if (ns.transfer !== 0) { + // Either finalizable now, or not + if (stats.blocksUntilValidFinalize <= 0) { + finalizableDomains.add(name); + } else { + transferringDomains.add(name); + if ( + transferringBlock === null || + stats.blocksUntilValidFinalize < transferringBlock + ) { + transferringBlock = stats.blocksUntilValidFinalize; + } + } + } + } + + return { + registerable: { HNS: registerableHNS, num: registerableNum }, + renewable: { + domains: [...renewableDomains], + block: renewableBlock, + }, + transferring: { + domains: [...transferringDomains], + block: transferringBlock, + }, + finalizable: { domains: [...finalizableDomains] }, + }; +} + +export async function getStats(wallet) { + const [statsFromBids, statsFromReveals, statsFromNames] = await Promise.all([ + fromBids(wallet), + fromReveals(wallet), + fromNames(wallet), + ]); + + return { + lockedBalance: { + bidding: { + HNS: + statsFromBids.bidding.HNS + + statsFromBids.revealing.HNS - + statsFromBids.revealable.HNS, + num: statsFromBids.bidding.num + statsFromBids.revealing.num, + }, + revealable: statsFromBids.revealable, + finished: { + HNS: statsFromNames.registerable.HNS + statsFromReveals.redeemable.HNS, + num: statsFromNames.registerable.num + statsFromReveals.redeemable.num, + }, + }, + actionableInfo: { + revealable: statsFromBids.revealable, + redeemable: statsFromReveals.redeemable, + registerable: statsFromNames.registerable, + renewable: statsFromNames.renewable, + transferring: statsFromNames.transferring, + finalizable: statsFromNames.finalizable, + }, + }; +} diff --git a/app/ducks/names.js b/app/ducks/names.js index 73e76ac8b..8c40f60d8 100644 --- a/app/ducks/names.js +++ b/app/ducks/names.js @@ -329,6 +329,17 @@ export const finalizeMany = (names) => async (dispatch) => { await walletClient.finalizeMany(names); }; +export const renewMany = (names) => async (dispatch) => { + if (!names || !names.length) { + return; + } + + await new Promise((resolve, reject) => { + dispatch(getPassphrase(resolve, reject)); + }); + await walletClient.renewMany(names); +}; + export const sendTransfer = (name, recipient) => async (dispatch) => { if (!name) { return; diff --git a/app/pages/Account/account.scss b/app/pages/Account/account.scss index bbc35bc58..45f27e149 100644 --- a/app/pages/Account/account.scss +++ b/app/pages/Account/account.scss @@ -1,25 +1,68 @@ -@import '../../variables.scss'; +@import "../../variables.scss"; .account { width: 100%; justify-content: stretch; overflow-y: auto; - &__address { + &__header { @extend %row-nowrap; + padding: 1rem 0; align-items: center; - font-weight: 500; - color: $manatee-gray; - } + background: rgba($black, 0.04); + border: solid 1px #f1f1f3; + overflow-x: auto; + + &__section { + padding: 0 1rem; + flex-shrink: 0; + + // Spendable block + &:first-child { + text-align: right; + border-right: solid 2px rgba($black, 0.5); + + & > .label { + visibility: visible; + } + & > .amount { + font-weight: 700; + } + } - &__header { - @extend %row-nowrap; - padding: 2rem 1.5rem 1rem; - } + // First Locked block - show "LOCKED" label + &:nth-of-type(2) > .label { + visibility: visible; + } - &__header-section { - @extend %col-nowrap; - padding: 0 1.5rem; + & > .label { + font-size: 0.75rem; + font-weight: 700; + visibility: hidden; + } + + & > .amount { + margin: 0; + font-size: 1.125rem; + font-weight: 500; + line-height: 1.75rem; + } + + & > .subtext { + display: inline-block; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.25rem; + color: rgba($black, 0.6); + } + } + + & > .plus { + font-size: 1.5rem; + line-height: 2rem; + font-weight: 500; + color: rgba($black, 0.5); + } } &__info-icon { @@ -33,21 +76,6 @@ cursor: pointer; } - &__balance-wrapper { - @extend %col-nowrap; - flex: 1 0 auto; - - &__label { - color: rgba($black, 0.4); - } - - &__amount { - font-size: 1.6125rem; - line-height: 3rem; - font-weight: 500; - } - } - &__actions { @extend %col-nowrap; flex: 0 0 auto; @@ -75,16 +103,15 @@ justify-content: flex-end; flex: 1 1 auto; - &:before { - content: ''; + content: ""; display: block; height: 0.8rem; width: 0.8rem; background-size: cover; background-position: center; margin-right: 4px; - background-image: url('../../assets/images/brick-loader.svg'); + background-image: url("../../assets/images/brick-loader.svg"); } } } @@ -95,7 +122,7 @@ &__panel-title { @extend %row-nowrap; - font-size: .8125rem; + font-size: 0.8125rem; font-weight: 500; margin: 1rem 3rem 1rem 3rem; } @@ -193,3 +220,48 @@ } } } + +.cards { + &__container { + @extend %row-nowrap; + margin: 1.5rem 0; + overflow-x: auto; + } + + &__card { + margin-left: 0.6rem; + padding: 0.5rem 0.9rem 1rem; + flex-shrink: 0; + border-radius: 0.25rem; + cursor: pointer; + + &--red { + background-color: #ffdcdc; + border: solid 1px #ff9d9d; + } + + &--yellow { + background-color: #fff4c6; + border: solid 1px #ffcd00; + } + + &--green { + background-color: #daffda; + border: solid 1px #84ff93; + } + + .title { + margin: 0; + font-size: 1rem; + line-height: 2rem; + } + + .subtitle { + margin: 0; + font-size: 0.7rem; + line-height: 1rem; + font-weight: 300; + color: #374151; + } + } +} diff --git a/app/pages/Account/index.js b/app/pages/Account/index.js index 3c5fb8f92..e394d2646 100644 --- a/app/pages/Account/index.js +++ b/app/pages/Account/index.js @@ -1,80 +1,147 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import Transactions from '../../components/Transactions'; -import './account.scss'; -import { displayBalance } from '../../utils/balances'; -import Tooltipable from '../../components/Tooltipable'; -import { clientStub as aClientStub } from '../../background/analytics/client'; +import React, { Component, Fragment } from "react"; +import PropTypes from "prop-types"; +import c from "classnames"; +import { withRouter } from "react-router"; +import { connect } from "react-redux"; +import Transactions from "../../components/Transactions"; +import "./account.scss"; +import walletClient from "../../utils/walletClient"; +import { displayBalance } from "../../utils/balances"; +import { hoursToNow } from "../../utils/timeConverter"; +import * as networks from "hsd/lib/protocol/networks"; +import { clientStub as aClientStub } from "../../background/analytics/client"; import * as walletActions from "../../ducks/walletActions"; +import { showError, showSuccess } from "../../ducks/notifications"; +import * as nameActions from "../../ducks/names"; -const pkg = require('../../../package.json'); - -const analytics = aClientStub(() => require('electron').ipcRenderer); +const analytics = aClientStub(() => require("electron").ipcRenderer); +@withRouter @connect( (state) => ({ - unconfirmedBalance: state.wallet.balance.unconfirmed, spendableBalance: state.wallet.balance.spendable, height: state.node.chain.height, isFetching: state.wallet.isFetching, + network: state.node.network, }), - dispatch => ({ + (dispatch) => ({ fetchWallet: () => dispatch(walletActions.fetchWallet()), - }), + sendRevealAll: () => dispatch(nameActions.sendRevealAll()), + sendRedeemAll: () => dispatch(nameActions.sendRedeemAll()), + sendRegisterAll: () => dispatch(nameActions.sendRegisterAll()), + finalizeMany: (names) => dispatch(nameActions.finalizeMany(names)), + renewMany: (names) => dispatch(nameActions.renewMany(names)), + }) ) export default class Account extends Component { static propTypes = { - unconfirmedBalance: PropTypes.number.isRequired, spendableBalance: PropTypes.number.isRequired, height: PropTypes.number.isRequired, isFetching: PropTypes.bool.isRequired, + network: PropTypes.string.isRequired, fetchWallet: PropTypes.func.isRequired, + sendRevealAll: PropTypes.func.isRequired, + sendRedeemAll: PropTypes.func.isRequired, + sendRegisterAll: PropTypes.func.isRequired, + finalizeMany: PropTypes.func.isRequired, + renewMany: PropTypes.func.isRequired, + history: PropTypes.object.isRequired, + }; + + state = { + isLoadingStats: true, + spendableBalance: { + HNS: this.props.spendableBalance, + USD: null, + }, + lockedBalance: { + bidding: { HNS: null, num: null }, + revealable: { HNS: null, num: null }, + finished: { HNS: null, num: null }, + }, + actionableInfo: { + revealable: { HNS: null, num: null, block: null }, + redeemable: { HNS: null, num: null }, + renewable: { domains: null, block: null }, + transferring: { domains: null, block: null }, + finalizable: { domains: null, block: null }, + registerable: { HNS: null, num: null }, + }, }; componentDidMount() { - analytics.screenView('Account'); + analytics.screenView("Account"); this.props.fetchWallet(); + this.updateStatsAndBalance(); + } + + componentDidUpdate(prevProps, prevState) { + if (this.props.height !== prevProps.height) { + this.updateStatsAndBalance(); + } + } + + async updateStatsAndBalance() { + // USD Conversion Rate for Spendable Balance + getUsdConversion().then((HNSToUSD) => { + this.setState({ + spendableBalance: { + HNS: this.props.spendableBalance, + USD: this.props.spendableBalance * HNSToUSD, + }, + }); + }); + + // Stats for balance and cards + try { + const stats = await walletClient.getStats(); + this.setState({ + isLoadingStats: false, + ...stats, + }); + } catch (error) { + console.error(error); + this.setState({ isLoadingStats: false }); + } } + onCardButtonClick = async (action, args) => { + const functionToExecute = { + reveal: this.props.sendRevealAll, + redeem: this.props.sendRedeemAll, + register: this.props.sendRegisterAll, + finalize: this.props.finalizeMany, + renew: this.props.renewMany, + }[action]; + + try { + await functionToExecute(args); + showSuccess( + "Your request is submitted! Please wait about 15 minutes for it to complete." + ); + } catch (e) { + showError(e.message); + } + }; + render() { - const {unconfirmedBalance, spendableBalance, isFetching} = this.props; + const { isFetching } = this.props; return (
{this.maybeRenderTXAlert()} -
-
-
-
Total Balance
-
-
-
{`${displayBalance(unconfirmedBalance)} HNS`}
-
-
-
-
-
Spendable Balance
- -
- -
-
-
{`${displayBalance(spendableBalance)} HNS`}
-
-
-
+ {this.renderBalance()} + {this.renderCards()} + + {/* Transactions */}
Transaction History - { - isFetching && ( -
- Loading transactions... -
- ) - } + {isFetching && ( +
+ Loading transactions... +
+ )}
@@ -82,6 +149,266 @@ export default class Account extends Component { ); } + renderBalance() { + const { spendableBalance, lockedBalance, isLoadingStats } = this.state; + + const showFirstPlus = + lockedBalance.bidding.num && lockedBalance.revealable.num; + const showSecondPlus = + (lockedBalance.bidding.num && lockedBalance.finished.num) || + (lockedBalance.revealable.num && lockedBalance.finished.num); + const noLockedHNS = !( + lockedBalance.bidding.num || + lockedBalance.revealable.num || + lockedBalance.finished.num + ); + + return ( +
+ {/* Spendable Balance */} +
+ SPENDABLE +

+ {displayBalance(spendableBalance.HNS ?? 0, true)} +

+ + ~${displayBalance(spendableBalance.USD ?? 0, false)} USD + +
+ + {/* Locked Balance - In bids */} + {lockedBalance.bidding.num > 0 ? ( +
+ LOCKED +

+ {displayBalance(lockedBalance.bidding.HNS, true)} +

+ + In bids ({lockedBalance.bidding.num}{" "} + {pluralize(lockedBalance.bidding.num, "bid")}) + +
+ ) : ( + "" + )} + + {showFirstPlus ?
+
: ""} + + {/* Locked Balance - In Reveal */} + {lockedBalance.revealable.num > 0 ? ( +
+ LOCKED +

+ {displayBalance(lockedBalance.revealable.HNS, true)} +

+ + In reveal ({lockedBalance.revealable.num}{" "} + {pluralize(lockedBalance.revealable.num, "bid")}) + +
+ ) : ( + "" + )} + + {showSecondPlus ?
+
: ""} + + {/* Locked Balance - Finished */} + {lockedBalance.finished.num > 0 ? ( +
+ LOCKED +

+ {displayBalance(lockedBalance.finished.HNS, true)} +

+ + In finished auctions ({lockedBalance.finished.num}{" "} + {pluralize(lockedBalance.finished.num, "bid")}) + +
+ ) : ( + "" + )} + + {/* No Locked HNS (or still loading) */} + {noLockedHNS ? ( +
+ LOCKED +

+ {isLoadingStats ? "Loading balance..." : displayBalance(0, true)} +

+ + {isLoadingStats ? "" : "No locked HNS"} + +
+ ) : ( + "" + )} +
+ ); + } + + renderCards() { + const network = this.props.network; + const { + revealable, + redeemable, + renewable, + transferring, + finalizable, + registerable, + } = this.state.actionableInfo; + + return ( +
+ {/* Revealable Card */} + {revealable.num ? ( + + Reveal {revealable.num}{" "} + {pluralize(revealable.num, "bid")} + + } + subtext={ + + within{" "} + + {blocksDeltaToTimeDelta(revealable.block, network, true)} + {" "} + for the bids to count + + } + buttonAction={() => this.onCardButtonClick("reveal")} + /> + ) : ( + "" + )} + + {/* Renewable Card */} + {renewable?.domains?.length ? ( + + Renew {renewable.domains.length}{" "} + {pluralize(renewable.domains.length, "domain")} + + } + subtext={ + + in{" "} + + {blocksDeltaToTimeDelta(renewable.block, network, true)} + {" "} + or lose the {pluralize(renewable.domains.length, "domain")} + + } + buttonAction={() => + this.onCardButtonClick("renew", renewable.domains) + } + /> + ) : ( + "" + )} + + {/* Redeemable Card */} + {redeemable.num ? ( + + Redeem {redeemable.num}{" "} + {pluralize(redeemable.num, "bid")} + + } + subtext={ + + from lost auctions to get back{" "} + {redeemable.HNS / 1e6} HNS + + } + buttonAction={() => this.onCardButtonClick("redeem")} + /> + ) : ( + "" + )} + + {/* Registerable Card */} + {registerable.num ? ( + + Register {registerable.num}{" "} + {pluralize(registerable.num, "domain")} + + } + subtext={ + + that you’ve won and get back{" "} + {registerable.HNS / 1e6} HNS + + } + buttonAction={() => this.onCardButtonClick("register")} + /> + ) : ( + "" + )} + + {/* Finalizable Card */} + {finalizable?.domains?.length ? ( + + Finalize {finalizable.domains.length}{" "} + {pluralize(finalizable.domains.length, "domain")} + + } + subtext={ + + to complete your{" "} + {pluralize(finalizable.domains.length, "transfer")} + + } + buttonAction={() => + this.onCardButtonClick("finalize", finalizable.domains) + } + /> + ) : ( + "" + )} + + {/* Transferring Card */} + {transferring?.domains?.length ? ( + + Transferring {transferring.domains.length}{" "} + {pluralize(transferring.domains.length, "domain")} + + } + subtext={ + + ready to finalize in{" "} + + {blocksDeltaToTimeDelta(transferring.block, network)} + + + } + /> + ) : ( + "" + )} +
+ ); + } + maybeRenderTXAlert() { if (this.props.height > 2016) { return null; @@ -89,8 +416,60 @@ export default class Account extends Component { return (
- Important: Transactions are disabled for the first two weeks of mainnet. + Important: Transactions are disabled for the first two + weeks of mainnet.
); } } + +class ActionCard extends Component { + static propTypes = { + color: PropTypes.string.isRequired, + text: PropTypes.object.isRequired, + subtext: PropTypes.object.isRequired, + buttonAction: PropTypes.func, + }; + + render() { + const { color, text, subtext, buttonAction } = this.props; + + return ( +
+

{text}

+

{subtext}

+
+ ); + } +} + +function pluralize(value, word, ending = "s") { + if (value == 1) { + return word; + } + return word + ending; +} + +function blocksDeltaToTimeDelta(blocks, network, hideMinsIfLarge = false) { + const hours = (blocks * networks[network].pow.targetSpacing) / 3600; + if (hideMinsIfLarge === true && hours > 48) { + return `${(hours / 24) >>> 0} days`; + } + return hoursToNow(hours); +} + +async function getUsdConversion() { + try { + const response = await ( + await fetch( + "https://api.coingecko.com/api/v3/simple/price?ids=handshake&vs_currencies=usd" + ) + ).json(); + return response.handshake.usd || 0; + } catch (error) { + return 0; + } +}