diff --git a/cypress/e2e/home.cy.ts b/cypress/e2e/home.cy.ts new file mode 100644 index 00000000..440a5a3f --- /dev/null +++ b/cypress/e2e/home.cy.ts @@ -0,0 +1,47 @@ +import 'cypress'; + +describe('E2E tests for the index page', () => { + beforeEach(() => { + cy.visit('/'); + // Initializing the API connections and fetching data + cy.get('[data-cy="loading"]').should('exist'); + // Fetching complete + cy.get('[data-cy="loading"]', { timeout: 60 * 1000 }).should('not.exist'); + }); + + it('Test UI elements on the home page', () => { + cy.get('[data-cy="upcoming-burn"]').should('exist'); + cy.get('[data-cy="previous-burn"]').should('exist'); + cy.get('[data-cy="total-burn"]').should('exist'); + cy.get('[data-cy="cores-sold"]').should('exist'); + cy.get('[data-cy="cores-on-sale"]').should('exist'); + cy.get('[data-cy="current-price"]').should('exist'); + cy.get('[data-cy="renewals"]').should('exist'); + cy.get('[data-cy="renewal-cost"]').should('exist'); + cy.get('[data-cy="price-increase"]').should('exist'); + + // Buttons + cy.get('[data-cy="btn-purchase-a-core"]').should('exist'); + cy.get('[data-cy="btn-manage-regions"]').should('exist'); + cy.get('[data-cy="btn-manage-paras"]').should('exist'); + cy.get('[data-cy="btn-track-consumption"]').should('exist'); + + // Purchase history table + cy.get('[data-cy="purchase-history-table"]').should('exist'); + }); + + it('Test button: Purchase a core', () => { + cy.get('[data-cy="btn-purchase-a-core"]').click(); + cy.url({ timeout: 60 * 1000 }).should('contain', 'purchase'); + }); + + it('Test button: Manage your regions', () => { + cy.get('[data-cy="btn-manage-regions"]').click(); + cy.url({ timeout: 60 * 1000 }).should('contain', 'regions'); + }); + + it('Test button: Parachain Dashboard', () => { + cy.get('[data-cy="btn-manage-paras"]').click(); + cy.url({ timeout: 60 * 1000 }).should('contain', 'paras'); + }); +}); diff --git a/cypress/e2e/wallet.cy.ts b/cypress/e2e/wallet.cy.ts index 6ec988c5..5c581530 100644 --- a/cypress/e2e/wallet.cy.ts +++ b/cypress/e2e/wallet.cy.ts @@ -1,5 +1,5 @@ -import '@chainsafe/cypress-polkadot-wallet'; import { encodeAddress } from '@polkadot/util-crypto'; +import '@chainsafe/cypress-polkadot-wallet'; import 'cypress-wait-until'; const ALICE = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'; diff --git a/next.config.js b/next.config.js index 725d4bb3..400cadb5 100644 --- a/next.config.js +++ b/next.config.js @@ -12,8 +12,6 @@ const nextConfig = { WS_REGIONX_CHAIN: process.env.WS_REGIONX_CHAIN || '', WS_ROCOCO_RELAY_CHAIN: process.env.WS_ROCOCO_RELAY_CHAIN, WS_KUSAMA_RELAY_CHAIN: process.env.WS_KUSAMA_RELAY_CHAIN, - KUSAMA_CORETIME_API: process.env.KUSAMA_CORETIME_API, - ROCOCO_CORETIME_API: process.env.ROCOCO_CORETIME_API, EXPERIMENTAL: process.env.EXPERIMENTAL, }, }; diff --git a/src/apis/index.ts b/src/apis/index.ts new file mode 100644 index 00000000..05f527ba --- /dev/null +++ b/src/apis/index.ts @@ -0,0 +1,22 @@ +import { SUBSCAN_CORETIME_API } from '@/consts'; +import { NetworkType } from '@/models'; + +export const fetchPurchaseHistoryData = async ( + network: NetworkType, + regionBegin: number, + page: number, + row: number +) => { + const res = await fetch( + `${SUBSCAN_CORETIME_API[network]}/api/scan/broker/purchased`, + { + method: 'POST', + body: JSON.stringify({ + region_begin: regionBegin, + row, + page, + }), + } + ); + return res; +}; diff --git a/src/components/Elements/Address/index.tsx b/src/components/Elements/Address/index.tsx index 5039dc08..9a7c06f7 100644 --- a/src/components/Elements/Address/index.tsx +++ b/src/components/Elements/Address/index.tsx @@ -31,7 +31,7 @@ export const Address = ({ }; return ( - + diff --git a/src/components/Elements/Link/index.tsx b/src/components/Elements/Link/index.tsx index 7d0262f6..666d063a 100644 --- a/src/components/Elements/Link/index.tsx +++ b/src/components/Elements/Link/index.tsx @@ -1,6 +1,5 @@ -import { Button } from '@mui/material'; +import { Box, useTheme } from '@mui/material'; import React from 'react'; - interface LinkProps { href: string; target?: string; @@ -8,10 +7,23 @@ interface LinkProps { } export const Link = ({ href, target = '_blank', children }: LinkProps) => { + const theme = useTheme(); + const onClick = () => { if (!href) return; window.open(href, target); }; - return ; + return ( + + {children} + + ); }; diff --git a/src/components/Tables/PurchaseHistoryTable/index.tsx b/src/components/Tables/PurchaseHistoryTable/index.tsx index 1a6900b5..ae8551dc 100644 --- a/src/components/Tables/PurchaseHistoryTable/index.tsx +++ b/src/components/Tables/PurchaseHistoryTable/index.tsx @@ -77,7 +77,7 @@ export const PurchaseHistoryTable = ({ data }: PurchaseHistoryTableProps) => { index ) => ( - + { {extrinsic_index} - + { /> - {core} - + {core} + {planckBnToUnit(price.toString(), decimals)} - {type} - + {type} + {timeAgo.format(timestamp * 1000, 'round-minute')} @@ -113,27 +113,37 @@ export const PurchaseHistoryTable = ({ data }: PurchaseHistoryTableProps) => { - - - + + + - - + }} + onPageChange={handleChangePage} + onRowsPerPageChange={handleChangeRowsPerPage} + /> + + + ); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index fdf73a82..a5bfff4e 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,3 @@ export * from './parasInfo'; -export * from './purchaseHistory'; export * from './renewableParas'; +export * from './sale'; diff --git a/src/hooks/sale/burnInfo.ts b/src/hooks/sale/burnInfo.ts new file mode 100644 index 00000000..5820a528 --- /dev/null +++ b/src/hooks/sale/burnInfo.ts @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react'; + +import { sleep } from '@/utils/functions'; + +import { fetchPurchaseHistoryData } from '@/apis'; +import { NetworkType, PurchaseHistoryResponse } from '@/models'; + +import { useSaleHistory } from './saleHistory'; + +export const useBurnInfo = (network: NetworkType) => { + const [totalBurn, setTotalBurn] = useState(0); + const [loading, setLoading] = useState(false); + const [currentBurn, setCurrentBurn] = useState(0); + const [prevBurn, setPrevBurn] = useState(0); + + const saleHistory = useSaleHistory(network, 0, 100); + + useEffect(() => { + const asyncFetchData = async () => { + setLoading(false); + setTotalBurn(0); + setCurrentBurn(0); + setPrevBurn(0); + + if (saleHistory.isError) { + return; + } + if (saleHistory.loading) { + setLoading(true); + return; + } + + const regionBegins = saleHistory.data + .map((item) => item.region_begin) + .sort((a, b) => b - a); + + await sleep(1000); // 5 req/s limit in free plan + + let total = 0; + for (let idx = 0; idx < regionBegins.length; ++idx) { + const regionBegin = regionBegins[idx]; + + const res = await fetchPurchaseHistoryData( + network, + regionBegin, + 0, + 1000 + ); + if (res.status !== 200) { + idx--; + continue; + } + + const { message, data } = await res.json(); + if (message !== 'Success') continue; + + const { list } = data as PurchaseHistoryResponse; + const burn = list + ? list.reduce((acc, { price }) => acc + parseInt(price), 0) + : 0; + total += burn; + + if (idx === 0) setCurrentBurn(burn); + else if (idx === 1) setPrevBurn(burn); + + await sleep(500); + } + setTotalBurn(total); + setLoading(false); + }; + asyncFetchData(); + }, [network, saleHistory.loading, saleHistory.isError]); + return { loading, totalBurn, currentBurn, prevBurn }; +}; diff --git a/src/hooks/sale/index.ts b/src/hooks/sale/index.ts new file mode 100644 index 00000000..acbab2a5 --- /dev/null +++ b/src/hooks/sale/index.ts @@ -0,0 +1,3 @@ +export * from './burnInfo'; +export * from './purchaseHistory'; +export * from './saleHistory'; diff --git a/src/hooks/purchaseHistory.ts b/src/hooks/sale/purchaseHistory.ts similarity index 62% rename from src/hooks/purchaseHistory.ts rename to src/hooks/sale/purchaseHistory.ts index c0cc109e..9658a060 100644 --- a/src/hooks/purchaseHistory.ts +++ b/src/hooks/sale/purchaseHistory.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import { SUBSCAN_CORETIME_API } from '@/consts'; +import { fetchPurchaseHistoryData } from '@/apis'; import { NetworkType, PurchaseHistoryItem, @@ -19,35 +19,31 @@ export const usePurchaseHistory = ( useEffect(() => { const asyncFetchData = async () => { - setLoading(true); + setData([]); + setError(false); + setLoading(false); + + if (regionBegin === 0) return; + try { - const res = await fetch( - `${SUBSCAN_CORETIME_API[network]}/api/scan/broker/purchased`, - { - method: 'POST', - body: JSON.stringify({ - region_begin: regionBegin, - row, - page, - }), - } + setLoading(true); + const res = await fetchPurchaseHistoryData( + network, + regionBegin, + page, + row ); if (res.status !== 200) { setError(true); } else { - const jsonData = await res.json(); - if (jsonData.message !== 'Success') { + const { message, data } = await res.json(); + if (message !== 'Success') { setError(true); - setData([]); } else { - if (jsonData.data.count == 0) { - setData([]); - setLoading(false); - return; - } - const data = jsonData.data as PurchaseHistoryResponse; + const { list } = data as PurchaseHistoryResponse; + setData( - data.list.map( + list.map( ({ account: { address }, core, @@ -61,17 +57,18 @@ export const usePurchaseHistory = ( core, extrinsic_index, timestamp: block_timestamp, - price, + price: parseInt(price), type: purchased_type, - }) as PurchaseHistoryItem + } as PurchaseHistoryItem) ) ); } } } catch { setError(true); + } finally { + setLoading(false); } - setLoading(false); }; asyncFetchData(); }, [network, regionBegin, page, row]); diff --git a/src/hooks/sale/saleHistory.ts b/src/hooks/sale/saleHistory.ts new file mode 100644 index 00000000..5f53c074 --- /dev/null +++ b/src/hooks/sale/saleHistory.ts @@ -0,0 +1,59 @@ +import { useEffect, useState } from 'react'; + +import { SUBSCAN_CORETIME_API } from '@/consts'; +import { + NetworkType, + SaleHistoryItem, + SaleHistoryResponse, + SaleHistoryResponseItem, +} from '@/models'; + +export const useSaleHistory = ( + network: NetworkType, + page: number, + row: number +) => { + const [loading, setLoading] = useState(false); + const [data, setData] = useState([]); + const [isError, setError] = useState(false); + + useEffect(() => { + const asyncFetchData = async () => { + setLoading(true); + setData([]); + setError(false); + + try { + const res = await fetch( + `${SUBSCAN_CORETIME_API[network]}/api/scan/broker/sales`, + { + method: 'POST', + body: JSON.stringify({ row, page }), + } + ); + if (res.status !== 200) { + setError(true); + } else { + const { message, data } = await res.json(); + + if (message !== 'Success') { + setError(true); + } else { + const { list } = data as SaleHistoryResponse; + + setData( + list.map((x: SaleHistoryResponseItem) => x as SaleHistoryItem) + ); + } + } + } catch { + setError(true); + } + setLoading(false); + }; + + asyncFetchData(); + }, [network, page, row]); + + return { loading, data, isError }; +}; diff --git a/src/models/regions/sale.ts b/src/models/regions/sale.ts index f49f396c..467067eb 100644 --- a/src/models/regions/sale.ts +++ b/src/models/regions/sale.ts @@ -93,7 +93,7 @@ export type PurchaseHistoryResponseItem = { event_index: string; extrinsic_index: string; mask: string; - price: number; + price: string; purchased_type: string; }; @@ -110,3 +110,16 @@ export type PurchaseHistoryItem = { price: number; type: string; }; + +export type SaleHistoryResponseItem = { + sales_cycle: number; + region_begin: number; + region_end: number; +}; + +export type SaleHistoryResponse = { + count: number; + list: SaleHistoryResponseItem[]; +}; + +export type SaleHistoryItem = SaleHistoryResponseItem; diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 07fe2c55..3feb8aa9 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,77 +1,316 @@ -import { Box, Grid, Typography, useTheme } from '@mui/material'; +import { Sync } from '@mui/icons-material'; +import MonetizationOnIcon from '@mui/icons-material/MonetizationOn'; +import ShoppingCartIcon from '@mui/icons-material/ShoppingCart'; +import WhatshotIcon from '@mui/icons-material/Whatshot'; +import { + Backdrop, + Box, + Button, + Card, + CircularProgress, + Paper, + Stack, + Typography, + useTheme, +} from '@mui/material'; +import Image from 'next/image'; +import { useRouter } from 'next/router'; -import { FeatureCard } from '@/components'; +import { useBurnInfo, usePurchaseHistory } from '@/hooks'; +import { getBalanceString } from '@/utils/functions'; + +import { PurchaseHistoryTable } from '@/components'; import Chart from '@/assets/chart.png'; import Config from '@/assets/config.png'; import Manage from '@/assets/manage.png'; import Trade from '@/assets/trade.png'; +import { useCoretimeApi } from '@/contexts/apis'; +import { ApiState } from '@/contexts/apis/types'; +import { useNetwork } from '@/contexts/network'; +import { useSaleInfo } from '@/contexts/sales'; +import { ContextStatus } from '@/models'; const Home = () => { const theme = useTheme(); + const { push, query } = useRouter(); + + const { network } = useNetwork(); + const { + state: { decimals, symbol, apiState }, + } = useCoretimeApi(); + const { + status, + saleInfo: { regionBegin, coresSold, coresOffered }, + phase: { currentPrice }, + } = useSaleInfo(); + + const { data: purchaseHistoryData, loading: loadingPurchaseHistory } = + usePurchaseHistory(network, regionBegin, 0, 1000); + + const renewals = purchaseHistoryData.filter( + (item) => item.type === 'renewed' + ); + const numRenewals = renewals.length; + const renewalCost = + numRenewals === 0 + ? 0 + : Math.floor( + renewals.reduce((sum, item) => sum + item.price, 0) / numRenewals + ); + + const { + currentBurn, + prevBurn, + totalBurn, + loading: loadingBurnInfo, + } = useBurnInfo(network); + + const formatBalance = (value: number): string => { + return getBalanceString(value.toString(), decimals, symbol); + }; + + const buttons = [ + { + label: 'Purchase a Core', + image: Trade, + url: `/purchase?network=${network}`, + dataCy: 'btn-purchase-a-core', + }, + { + label: 'Manage Your Regions', + image: Config, + url: '/regions', + dataCy: 'btn-manage-regions', + }, + { + label: 'Parachain Dashboard', + image: Manage, + url: '/paras', + dataCy: 'btn-manage-paras', + }, + { + label: 'Track Coretime Consumption', + image: Chart, + url: 'https://www.polkadot-weigher.com/', + dataCy: 'btn-track-consumption', + }, + ]; + + const sections = [ + { + top: [ + { + label: 'Upcoming Burn', + value: formatBalance(currentBurn), + icon: , + dataCy: 'upcoming-burn', + }, + { + label: 'Previous Burn', + value: formatBalance(prevBurn), + icon: , + dataCy: 'previous-burn', + }, + ], + bottom: { + label: 'Total Burn', + value: totalBurn ? formatBalance(totalBurn) : '...', + dataCy: 'total-burn', + }, + }, + { + top: [ + { + label: 'Cores Sold', + value: coresSold, + icon: , + dataCy: 'cores-sold', + }, + { + label: 'Cores On Sale', + value: coresOffered, + icon: , + dataCy: 'cores-on-sale', + }, + ], + bottom: { + label: 'Current Price', + value: formatBalance(currentPrice), + dataCy: 'current-price', + }, + }, + { + top: [ + { + label: 'Current Sale Renewals', + value: numRenewals, + icon: , + dataCy: 'renewals', + }, + { + label: 'Average Renewal Cost', + value: formatBalance(renewalCost), + icon: , + dataCy: 'renewal-cost', + }, + ], + bottom: { + label: 'Spent on Renewals', + value: formatBalance(renewalCost * numRenewals), + dataCy: 'price-increase', + }, + }, + ]; - return ( - - - - Corehub | Home - - - Explore all the possibilities RegionX Corehub offers - - - - - - - - - - - - - - - - - - - + return status !== ContextStatus.LOADED || + apiState !== ApiState.READY || + loadingBurnInfo || + loadingPurchaseHistory ? ( + + + + ) : ( + + + + + RegionX | CoreHub + + + Explore all the possibilities RegionX Corehub offers + + + + {sections.map(({ top, bottom }, index) => ( + + {top.map(({ icon, label, value, dataCy }, index) => ( + + + + {value} + + {label} + + + {icon} + + + ))} + + + {bottom.label} + + + {bottom.value} + + + + ))} + + + + {buttons.map(({ label, image, url, dataCy }, index) => ( + + ))} + + {status === ContextStatus.LOADED && ( + + + + + Purchase History + + + Get an insight into all purchases and renewals made during the + current bulk period + + + + + + )} + ); }; diff --git a/src/utils/functions/common.ts b/src/utils/functions/common.ts index b1d495da..bd6df72e 100644 --- a/src/utils/functions/common.ts +++ b/src/utils/functions/common.ts @@ -29,3 +29,7 @@ export const truncateAddres = (address: string) => { export const writeToClipboard = async (value: string) => { await navigator.clipboard.writeText(value); }; + +export const sleep = (ms: number) => { + return new Promise((resolve) => setTimeout(resolve, ms)); +};