diff --git a/packages/insight/src/Routing.tsx b/packages/insight/src/Routing.tsx index 796f2eec62d..c0b337ac30b 100644 --- a/packages/insight/src/Routing.tsx +++ b/packages/insight/src/Routing.tsx @@ -1,6 +1,7 @@ import React, {lazy, Suspense} from 'react'; import {Navigate, Route, Routes} from 'react-router-dom'; import Home from './pages'; +import Chain from './pages/chain'; const Blocks = lazy(() => import('./pages/blocks')); const Block = lazy(() => import('./pages/block')); const TransactionHash = lazy(() => import('./pages/transaction')); @@ -12,6 +13,7 @@ function Routing() { } /> + } /> } /> } /> } /> diff --git a/packages/insight/src/assets/images/arrow-down-black.svg b/packages/insight/src/assets/images/arrow-down-black.svg new file mode 100644 index 00000000000..cd26e1e77a0 --- /dev/null +++ b/packages/insight/src/assets/images/arrow-down-black.svg @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/packages/insight/src/assets/images/arrow-down.svg b/packages/insight/src/assets/images/arrow-down.svg new file mode 100644 index 00000000000..7de9ca03b57 --- /dev/null +++ b/packages/insight/src/assets/images/arrow-down.svg @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/packages/insight/src/assets/images/arrow-forward-blue.svg b/packages/insight/src/assets/images/arrow-forward-blue.svg new file mode 100644 index 00000000000..d08d59d0f47 --- /dev/null +++ b/packages/insight/src/assets/images/arrow-forward-blue.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/insight/src/assets/images/arrow-outward.svg b/packages/insight/src/assets/images/arrow-outward.svg new file mode 100644 index 00000000000..658974e740c --- /dev/null +++ b/packages/insight/src/assets/images/arrow-outward.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/insight/src/assets/images/arrow-thin.svg b/packages/insight/src/assets/images/arrow-thin.svg new file mode 100644 index 00000000000..da9da611522 --- /dev/null +++ b/packages/insight/src/assets/images/arrow-thin.svg @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/packages/insight/src/assets/images/cube.svg b/packages/insight/src/assets/images/cube.svg new file mode 100644 index 00000000000..8f58c5695f4 --- /dev/null +++ b/packages/insight/src/assets/images/cube.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/insight/src/components/InfoCard.tsx b/packages/insight/src/components/InfoCard.tsx new file mode 100644 index 00000000000..e99bc4f9f22 --- /dev/null +++ b/packages/insight/src/components/InfoCard.tsx @@ -0,0 +1,45 @@ +import { FC } from 'react'; +import CopyText from './copy-text'; +import styled from 'styled-components'; + + +type InfoCardType = { + data: Array<{label: string, value: any, copyText?: boolean}>, +}; + +const Card = styled.div` + display: flex; + flex-direction: column; + width: 100%; + background-color: ${({theme: {dark}}) => dark ? '#222' : '#fff'}; + padding: 14px; + border-radius: 8px; +`; + +const Label = styled.span` + color: ${({theme: {dark}}) => dark ? '#888' : '#474d53'}; + align-self: flex-start; + line-height: 1.6; + margin-bottom: -2; + font-size: 18px; +`; + +const InfoCard: FC = ({data}) => { + return ( + + {data.map((d, index) => { + const { label, value, copyText } = d; + return (<> + +
+ {value} + {copyText && } +
+ { index !== data.length - 1 &&
} + ); + })} +
+ ); +} + +export default InfoCard; \ No newline at end of file diff --git a/packages/insight/src/components/block-details.tsx b/packages/insight/src/components/block-details.tsx index 1c3036db88b..5c8d3210974 100644 --- a/packages/insight/src/components/block-details.tsx +++ b/packages/insight/src/components/block-details.tsx @@ -184,9 +184,7 @@ const BlockDetails: FC = ({currency, network, block}) => { diff --git a/packages/insight/src/components/block-sample.tsx b/packages/insight/src/components/block-sample.tsx new file mode 100644 index 00000000000..99693e58462 --- /dev/null +++ b/packages/insight/src/components/block-sample.tsx @@ -0,0 +1,183 @@ +import React, {FC, useState} from 'react'; +import {getApiRoot, getConvertedValue, getDifficultyFromBits, getFormattedDate} from 'src/utilities/helper-methods'; +import {BitcoinBlockType} from 'src/utilities/models'; +import Cube from '../assets/images/cube.svg'; +import Arrow from '../assets/images/arrow-thin.svg'; +import ArrowOutward from '../assets/images/arrow-outward.svg'; +import ForwardArrow from '../assets/images/arrow-forward-blue.svg'; +import ArrowDown from '../assets/images/arrow-down.svg'; +import styled, { useTheme } from 'styled-components'; +import InfoCard from './InfoCard'; +import InfiniteScroll from 'react-infinite-scroll-component'; +import { fetcher } from 'src/api/api'; +import InfiniteScrollLoadSpinner from './infinite-scroll-load-spinner'; +import Info from './info'; +import { useNavigate } from 'react-router-dom'; + +const BlockListTableRow = styled.tr` + text-align: center; + line-height: 45px; + + &:nth-child(odd) { + background-color: ${({theme: {dark}}) => (dark ? '#2a2a2a' : '#f6f7f9')}; + } + + &:nth-child(even) { + background-color: ${({theme: {dark}}) => (dark ? '#0f0f0f' : '#e0e4e7')}; + } + + font-size: 16px; +`; + + +const getBlocksUrl = (currency: string, network: string) => { + return `${getApiRoot(currency)}/${currency}/${network}/block?limit=200`; +}; + +const BlockSample: FC<{currency: string, network: string, blocks: BitcoinBlockType[]}> = ({currency, network, blocks}) => { + const theme = useTheme(); + const navigate = useNavigate(); + const [expandedBlocks, setExpandedBlocks] = useState([]); + const [blocksList, setBlocksList] = useState(blocks); + const [error, setError] = useState(''); + const [hasMore, setHasMore] = useState(true); + + const fetchMore = async (_blocksList: BitcoinBlockType[]) => { + if (!_blocksList.length || !currency || !network) return; + const since = _blocksList[_blocksList.length - 1].height; + try { + const newData: [BitcoinBlockType] = await fetcher( + `${getBlocksUrl(currency, network)}&since=${since}&paging=height&direction=-1`, + ); + if (newData?.length) { + setBlocksList(_blocksList.concat(newData)); + } else { + setHasMore(false); + } + } catch (e: any) { + setError(e.message || 'Something went wrong. Please try again later.'); + } + }; + + const gotoSingleBlockDetailsView = async (hash: string) => { + await navigate(`/${currency}/${network}/block/${hash}`); + }; + + if (!blocksList?.length) return null; + return ( + <> + {error ? : null} + fetchMore(blocksList)} + hasMore={hasMore} + loader={} + dataLength={blocksList.length}> + + + + + + + + + + + + + { + blocksList.map((block: BitcoinBlockType, index: number) => { + const feeData = block.feeData; + const expanded = expandedBlocks.includes(block.height); + return ( + + + + + + + + + {expanded && <> + {/* Alternates the color so the data below this row stays the same*/} + + + + + + + } + + ); + }) + } + +
HeightTimestampTransactionsSizeFee Rate
+ expanded + ? setExpandedBlocks(expandedBlocks.filter(h => h !== block.height)) + : setExpandedBlocks([...expandedBlocks, block.height])}> + {expanded + ? arrow + : arrow + } + cube + {block.height} + + {getFormattedDate(block.time)}{block.transactionCount}{block.size}{feeData.median.toFixed(4)} +
+
+ + Summary +
+ + + {block.height + 1} + gotoSingleBlockDetailsView(blocksList[index - 1].hash)} + alt='Next Block' + title={`Go to block ${block.height + 1}`} + /> + }, + {label: 'Nonce', value: block.nonce}, + {label: 'Confirmations', value: blocksList[0].height - block.height + 1}, + {label: 'Difficulty', value: getDifficultyFromBits(block.bits).toFixed(0)}, + {label: 'Fee data', value:
+ {[{label: 'Mean', value: feeData.mean}, {label: 'Median', value: feeData.median}, {label: 'Mode', value: feeData.mode}] + .map(({label, value}, key) => { + return ( +
+ {label} + {value.toFixed(4)} +
+
) + }) + } +
+ } + ]}/> +
+ gotoSingleBlockDetailsView(block.hash)}> + View transactions + arrow + +
+
+
+ + ); +}; + +export default BlockSample; diff --git a/packages/insight/src/components/chain-header.tsx b/packages/insight/src/components/chain-header.tsx new file mode 100644 index 00000000000..5327326e93c --- /dev/null +++ b/packages/insight/src/components/chain-header.tsx @@ -0,0 +1,238 @@ +import {FC, useEffect, useRef, useState} from 'react'; +import {useApi} from 'src/api/api'; +import {Chart as ChartJS} from 'chart.js'; +import {colorCodes} from 'src/utilities/constants'; +import {BitcoinBlockType} from 'src/utilities/models'; +import styled, { useTheme } from 'styled-components'; +import { getName } from 'src/utilities/helper-methods'; +import Dropdown from './dropdown'; + +const ChartTile = styled.div` + height: 400px; + width: 50%; + background-color: ${({theme: {dark}}) => dark ? '#222' : '#fff'}; + border-radius: 10px; + padding: 1.5rem; + margin: 1rem; + display: flex; + flex-direction: column; +`; + +const ChartTileHeader = styled.span` + font-size: 27px; + font-weight: bolder; +`; + +const ChainHeader: FC<{ currency: string; network: string; blocks?: BitcoinBlockType[] }> = ({ currency, network, blocks }) => { + const theme = useTheme(); + const priceDetails: { + data: { + code: string, + name: string, + rate: number + } + } = useApi(`https://bitpay.com/rates/${currency}/usd`).data; + + const priceDisplay: { + data: Array<{ + prices: Array<{price: number, time: string}>, + currencyPair: string, + currencies: Array, + priceDisplay: Array, + percentChange: string, + priceDisplayPercentChange: string + }> + } = useApi( + `https://bitpay.com/currencies/prices?currencyPairs=["${currency}:USD"]`, + ).data; + + const price = network === 'mainnet' ? priceDetails?.data?.rate : 0; + + const feeChartRef = useRef(null); + const feeChartInstanceRef = useRef(null); + + const priceChartRef = useRef(null); + const priceChartInstanceRef = useRef(null); + const priceList = (priceDisplay?.data?.[0]?.priceDisplay || []); + + const feeRanges = ['128 Blocks', '32 Blocks', '16 Blocks', '8 Blocks']; + const priceRanges = ['24 Hours', '12 Hours', '6 Hours', '3 Hours']; + + const [feeSelectedRange, setFeesSelectedRange] = useState('32 Blocks'); + const [priceSelectedRange, setPriceSelectedRange] = useState('24 Hours'); + + const [feeChangeSpan, setFeeChangeSpan] = useState(() => { return null; }); + const [priceChangeSpan, setPriceChangeSpan] = useState(() => { return null; }); + + useEffect(() => { + if (feeChartRef.current && blocks) { + if (feeChartInstanceRef.current) { + feeChartInstanceRef.current.destroy(); + } + const num = Number(feeSelectedRange.slice(0, feeSelectedRange.indexOf(' '))); + const fees = blocks.map((block: BitcoinBlockType) => block.feeData.median).reverse().slice(blocks.length - num); + const dates = blocks.map((block: BitcoinBlockType) => + new Date(block.time).toLocaleString('en-US', { + year: '2-digit', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit' + }) + ).reverse().slice(blocks.length - num); + const chartData = { + labels: dates, + datasets: [ + { + data: fees, + fill: false, + spanGaps: true, + borderColor: colorCodes[currency], + borderWidth: 1.5, + pointRadius: 3 + } + ] + }; + const options = { + scales: { + y: { + display: true, + beginAtZero: true, + ticks: { maxTicksLimit: 6 } + }, + x: { display: false } + }, + plugins: {legend: {display: false}}, + events: [], + responsive: true, + maintainAspectRatio: false, + tension: 0 + }; + feeChartInstanceRef.current = new ChartJS(feeChartRef.current, { + type: 'line', + data: chartData, + options + }); + + const feeChange = fees[fees.length - 1] - fees[0]; + const percentFeeChange = feeChange / fees[0] * 100; + + + setFeeChangeSpan(() => { + return + {feeChange.toFixed(2)} sats/byte ({percentFeeChange.toFixed(2)}%) + Last {feeSelectedRange} + + }); + } + + return () => { + feeChartInstanceRef.current?.destroy(); + }; + }, [blocks, feeSelectedRange, currency]); + + useEffect(() => { + const hours = Number(priceSelectedRange.slice(0, priceSelectedRange.indexOf(' '))) + const usedPrices = priceList.slice(priceList.length - hours); + const priceChartData = { + labels: usedPrices, + datasets: [ + { + data: usedPrices, + fill: false, + spanGaps: true, + borderColor: colorCodes[currency], + borderWidth: 1.5, + pointRadius: 3, + }, + ], + }; + + const priceOptions = { + scales: { + y: { + display: true, + beginAtZero: false, + ticks: { + maxTicksLimit: 4, + } + }, + x: {display: false} + }, + plugins: {legend: {display: false}}, + events: [], + responsive: true, + maintainAspectRatio: false, + tension: 0, + }; + if (priceChartRef.current) { + if (priceChartInstanceRef.current) { + priceChartInstanceRef.current.destroy(); + } + priceChartInstanceRef.current = new ChartJS(priceChartRef.current, { + type: 'line', + data: priceChartData, + options: priceOptions, + }); + } + + const priceChange = price - usedPrices[0]; + const percentPriceChange = priceChange / usedPrices[0] * 100; + + let color = 'gray'; + if (priceChange > 0) { + color = 'green'; + } else if (priceChange < 0) { + color = 'red'; + } + + setPriceChangeSpan(() => { + return + ${priceChange.toFixed(2)} ({percentPriceChange.toFixed(2)}%) + Last {priceSelectedRange} + + }); + return () => { + priceChartInstanceRef.current?.destroy(); + }; + }, [priceList, price, priceSelectedRange, currency]); + + return ( +
+ Blocks + {currency} +
+
+ + {getName(currency)} Exchange Rate +
+ ${price.toLocaleString()} + +
+ {priceChangeSpan} +
+ +
+
+ + {getName(currency)} Fee +
+ {blocks?.at(0)?.feeData.median.toFixed(3)} sats/byte + +
+ {feeChangeSpan} +
+ +
+
+
+
+
+ ); +}; + +export default ChainHeader; diff --git a/packages/insight/src/components/copy-text.tsx b/packages/insight/src/components/copy-text.tsx index 156b18c8e25..2ef40f912df 100644 --- a/packages/insight/src/components/copy-text.tsx +++ b/packages/insight/src/components/copy-text.tsx @@ -1,5 +1,5 @@ import {AnimatePresence, motion} from 'framer-motion'; -import {FC, memo, useState} from 'react'; +import {CSSProperties, FC, memo, useState} from 'react'; import {CopyToClipboard} from 'react-copy-to-clipboard'; import styled from 'styled-components'; import CopySvg from '../assets/images/copy-icon.svg'; @@ -23,8 +23,9 @@ const IconImage = styled(motion.img)` interface CopyTextProps { text: string; + style?: CSSProperties } -const CopyText: FC = ({text}) => { +const CopyText: FC = ({text, style}) => { const [copied, setCopied] = useState(false); const onClickCopy = () => { @@ -60,7 +61,7 @@ const CopyText: FC = ({text}) => { }; return ( - + {copied ? ( = ({currency}) => { const {height, time, transactionCount, size} = data[0]; const imgSrc = `https://bitpay.com/img/icon/currencies/${currency}.svg`; - const gotoAllBlocks = async () => { - await navigate(`/${currency}/mainnet/blocks`); + const gotoChain = async () => { + await navigate(`/${currency}/mainnet/chain`); }; return ( - + {`${currency}
diff --git a/packages/insight/src/components/data-box.tsx b/packages/insight/src/components/data-box.tsx index eec3695db14..3ff608ae97e 100644 --- a/packages/insight/src/components/data-box.tsx +++ b/packages/insight/src/components/data-box.tsx @@ -1,29 +1,41 @@ -import {Children, FC, ReactNode} from 'react'; -import {useTheme} from 'styled-components'; +import {CSSProperties, FC, ReactNode} from 'react'; +import styled from 'styled-components'; + +const DataBox: FC<{ + children: ReactNode, + label?: string, + style?: CSSProperties, + centerLabel?: boolean, + colorDark?: string, + colorLight?: string}> = ({children, label, style, centerLabel, colorDark='#5f5f5f', colorLight='#ccc'}) => { + + const DataBoxFieldset = styled.fieldset` + border: 2.5px solid ${({theme: {dark}}) => dark ? colorDark : colorLight}; + border-radius: 5px; + padding: 0.1rem 0.4rem; + word-break: break-all; + white-space: normal; + width: fit-content; + height: fit-content; + margin: 0.7rem 0.2rem; + `; -const DataBox: FC<{children: ReactNode, label: string, style?: object}> = ({children, label, style}) => { - const theme = useTheme(); - const modifiedChildren = typeof children === 'object' - ? Children.map(children as JSX.Element, (child: JSX.Element) => { - return ; - }) - : children; - return ( -
- {label} - {modifiedChildren} -
+ + { label && + + {label} + + } + {children} + ); } diff --git a/packages/insight/src/components/dropdown.tsx b/packages/insight/src/components/dropdown.tsx new file mode 100644 index 00000000000..7bc68250003 --- /dev/null +++ b/packages/insight/src/components/dropdown.tsx @@ -0,0 +1,51 @@ +import { FC, CSSProperties } from 'react'; +import ArrowDown from '../assets/images/arrow-down.svg'; +import ArrowDownBlack from '../assets/images/arrow-down-black.svg'; +import { useTheme } from 'styled-components'; + +const Dropdown: FC<{ + options: string[], + value?: string, + onChange?: (value: string) => void, + style?: CSSProperties +}> = ({options, value, onChange, style}) => { + const theme = useTheme(); + return ( +
+ + Arrow Down +
+ ); +} + +export default Dropdown; \ No newline at end of file diff --git a/packages/insight/src/components/transaction-details.tsx b/packages/insight/src/components/transaction-details.tsx index d970727d981..2719a87d530 100644 --- a/packages/insight/src/components/transaction-details.tsx +++ b/packages/insight/src/components/transaction-details.tsx @@ -204,27 +204,21 @@ const TransactionDetails: FC = ({ - - - goToTx(item.mintTxid, undefined, item.mintIndex) - }> - {item.mintTxid} - - + + goToTx(item.mintTxid, undefined, item.mintIndex) + }> + {item.mintTxid} +
- - {item.mintIndex} - + {item.mintIndex} {item.uiConfirmations && confirmations > 0 ? ( - - {item.uiConfirmations + confirmations} - + {item.uiConfirmations + confirmations} ) : null}
diff --git a/packages/insight/src/pages/chain.tsx b/packages/insight/src/pages/chain.tsx new file mode 100644 index 00000000000..dcb68726a5e --- /dev/null +++ b/packages/insight/src/pages/chain.tsx @@ -0,0 +1,72 @@ +import BlockSample from 'src/components/block-sample'; +import React, {useEffect, useState} from 'react'; +import ChainHeader from '../components/chain-header'; +import {useParams} from 'react-router-dom'; +import {useDispatch} from 'react-redux'; +import {changeCurrency, changeNetwork} from 'src/store/app.actions'; +import {getApiRoot, normalizeParams} from 'src/utilities/helper-methods'; +import styled from 'styled-components'; +import {size} from 'src/utilities/constants'; +import {fetcher} from 'src/api/api'; +import nProgress from 'nprogress'; +import {BitcoinBlockType} from 'src/utilities/models'; +import Info from 'src/components/info'; + +const HeaderDataContainer = styled.div` + width: 100%; + min-width: 0; + align-items: center; /* center on mobile by default */ + + @media screen and (min-width: ${size.mobileL}) { + flex-direction: row; + align-items: flex-start; + } +`; + +const Chain: React.FC = () => { + let {currency, network} = useParams<{currency: string; network: string}>(); + const dispatch = useDispatch(); + + const [blocksList, setBlocksList] = useState(); + const [error, setError] = useState(''); + + useEffect(() => { + nProgress.start(); + if (!currency || !network) + return; + Promise.all([fetcher(`${getApiRoot(currency)}/${currency}/${network}/block?limit=128`)]) + .then(([data]) => { + setBlocksList(data); + }) + .finally(() => { + nProgress.done(); + }) + .catch((e: any) => { + setError(e.message || 'Something went wrong. Please try again later.'); + }); + }, []); + + useEffect(() => { + if (!currency || !network) return; + const _normalizeParams = normalizeParams(currency, network); + currency = _normalizeParams.currency; + network = _normalizeParams.network; + + dispatch(changeCurrency(currency)); + dispatch(changeNetwork(network)); + }, [currency, network]); + + if (!currency || !network) return null; + + return ( + <> + {error ? : null} + + + { blocksList && } + + + ); +} + +export default Chain; \ No newline at end of file diff --git a/packages/insight/src/pages/index.tsx b/packages/insight/src/pages/index.tsx index f24f49be01b..072433425be 100644 --- a/packages/insight/src/pages/index.tsx +++ b/packages/insight/src/pages/index.tsx @@ -1,5 +1,4 @@ import {SUPPORTED_CURRENCIES} from '../utilities/constants'; -import {SecondaryTitle} from '../assets/styles/titles'; import CurrencyTile from '../components/currency-tile'; import Masonry from 'react-masonry-css'; import {motion} from 'framer-motion'; @@ -24,8 +23,6 @@ const Home: React.FC = () => { return ( - Latest Blocks - { } }; +export const getName = (currency: string) => { + switch (currency.toUpperCase()) { + case 'BTC': + default: + return 'Bitcoin'; + case 'BCH': + return 'Bitcoin Cash'; + case 'DOGE': + return 'Doge'; + case 'LTC': + return 'Litecoin'; + case 'ETH': + return 'Ethereum'; + } +} + export const getDifficultyFromBits = (bits: number) => { const maxBody = Math.log(0x00ffff); const scaland = Math.log(256); return Math.exp(maxBody - Math.log(bits & 0x00ffffff) + scaland * (0x1d - ((bits & 0xff000000) >> 24))); +} + +/** + * Merges the source object into the destination object. + * For each property in the source object: + * It sets the destination object property to the source property unless + * both properties are object and the destination object property is not an array. + * + * @param object destination object + * @param source source object + */ +export const merge = (dest: TDest, src: TSrc): TDest & TSrc => { + for (const key in src) { + const destProp = dest !== undefined ? (dest as any)[key] : undefined; + const srcProp = src[key]; + let result; + if (srcProp instanceof Object && destProp instanceof Object && !Array.isArray(destProp)) { + result = merge(destProp, srcProp); + } else { + result = srcProp; + } + (dest as any)[key] = result; + } + return dest as TDest & TSrc; +} +export default merge; + +export const darkenHexColor = (hex: string, amount: number) => { + hex = hex.replace(/^#/, ''); + + let r = parseInt(hex.substring(0, 2), 16); + let g = parseInt(hex.substring(2, 4), 16); + let b = parseInt(hex.substring(4, 6), 16); + + r = Math.max(0, r - amount); + g = Math.max(0, g - amount); + b = Math.max(0, b - amount); + + const toHex = (v: number) => v.toString(16).padStart(2, '0'); + return `#${toHex(r)}${toHex(g)}${toHex(b)}`; } \ No newline at end of file diff --git a/packages/insight/src/utilities/models.ts b/packages/insight/src/utilities/models.ts index 2128c1fd3e1..7c1bbc33743 100644 --- a/packages/insight/src/utilities/models.ts +++ b/packages/insight/src/utilities/models.ts @@ -109,3 +109,18 @@ export interface BlocksType { size: number; hash: string; } + +export type BitcoinBlockType = BlocksType & { + merkleRoot: string, + bits: number, + nonce: number, + reward: number, + version: number, + confirmations: number, + feeData: { + feeTotal: number; + mean: number; + median: number; + mode: number; + } +};