diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index f9731c8..acce4e9 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -8,6 +8,10 @@ on: env: PROJECT_ID: bright-meridian-316511 +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: runs-on: ubuntu-latest @@ -44,13 +48,13 @@ jobs: echo "::set-output name=tag::$(git rev-parse --short HEAD)" if [ "$REF" = "refs/heads/main" ]; then echo "::set-output name=app_env::prod" - echo "::set-output name=testnet_squid_api_url::https://subsquid.squids.live/subsquid-network-testnet/graphql" - echo "::set-output name=mainnet_squid_api_url::https://subsquid.squids.live/subsquid-network-mainnet/graphql" + echo "::set-output name=testnet_squid_api_url::https://subsquid.squids.live/subsquid-network-testnet:prod/api/graphql" + echo "::set-output name=mainnet_squid_api_url::https://subsquid.squids.live/subsquid-network-mainnet:prod/api/graphql" echo "::set-output name=enable_demo_features::false" else echo "::set-output name=app_env::dev" - echo "::set-output name=testnet_squid_api_url::https://subsquid.squids.live/subsquid-network-testnet/v/v5/graphql" - echo "::set-output name=mainnet_squid_api_url::https://subsquid.squids.live/subsquid-network-mainnet/v/v5/graphql" + echo "::set-output name=testnet_squid_api_url::https://subsquid.squids.live/subsquid-network-testnet:dev/api/graphql" + echo "::set-output name=mainnet_squid_api_url::https://subsquid.squids.live/subsquid-network-mainnet:dev/api/graphql" echo "::set-output name=enable_demo_features::true" fi if [ "$NETWORK" = "mainnet" ]; then diff --git a/src/api/subsquid-network-squid/settings-graphql.ts b/src/api/subsquid-network-squid/settings-graphql.ts index e2a8c03..4495673 100644 --- a/src/api/subsquid-network-squid/settings-graphql.ts +++ b/src/api/subsquid-network-squid/settings-graphql.ts @@ -44,7 +44,7 @@ export function useNetworkSummary() { epoch: res.epoches.length ? res.epoches[0] : undefined, }; }, - refetchInterval: 6000, // a half of block time in l1 + refetchInterval: 12_000, // block time in l1 }, ); diff --git a/src/hooks/useCountdown.ts b/src/hooks/useCountdown.ts new file mode 100644 index 0000000..71a3bf9 --- /dev/null +++ b/src/hooks/useCountdown.ts @@ -0,0 +1,10 @@ +import { relativeDateFormat } from '@i18n'; + +import { useTicker } from './useTicker'; + +export function useCountdown({ timestamp }: { timestamp?: Date | string | number | undefined }) { + const curTimestamp = useTicker(() => Date.now(), 1000); + const timeLeft = timestamp ? relativeDateFormat(curTimestamp, timestamp) : undefined; + + return timeLeft; +} diff --git a/src/hooks/useTicker.ts b/src/hooks/useTicker.ts new file mode 100644 index 0000000..4861ebe --- /dev/null +++ b/src/hooks/useTicker.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from 'react'; + +export function useTicker(ticker: () => T, ms: number = 1000) { + const [tickerValue, setTickerValue] = useState(ticker()); + + useEffect(() => { + const interval = setInterval(() => { + setTickerValue(ticker()); + }, ms); + return () => clearInterval(interval); + }, [ms, ticker]); + + return tickerValue; +} diff --git a/src/i18n/dateFormat.ts b/src/i18n/dateFormat.ts index 10632f4..5e647c1 100644 --- a/src/i18n/dateFormat.ts +++ b/src/i18n/dateFormat.ts @@ -4,7 +4,7 @@ export function dateFormat( value: Date | string | number | undefined, tpl: 'dateTime' | 'date' | string = 'date', ) { - if (!value) return null; + if (!value) return undefined; if (tpl === 'dateTime') { tpl = 'dd.MM.yyyy HH:mm:ss'; @@ -12,10 +12,10 @@ export function dateFormat( tpl = 'dd.MM.yyyy'; } - if (value.valueOf() == 0) return null; + if (value.valueOf() == 0) return undefined; const date = new Date(value); - if (!isValid(date)) return null; + if (!isValid(date)) return undefined; return format(new Date(value), tpl); } diff --git a/src/layouts/NetworkLayout/BasicMenuItem.tsx b/src/layouts/NetworkLayout/BasicMenuItem.tsx index d56e82a..78c18de 100644 --- a/src/layouts/NetworkLayout/BasicMenuItem.tsx +++ b/src/layouts/NetworkLayout/BasicMenuItem.tsx @@ -15,7 +15,7 @@ const Item = styled(MenuItem)(({ theme }) => ({ transition: 'all ease-out 150ms', // paddingLeft: theme.spacing(1.5), // paddingRight: theme.spacing(1), - // borderRadius: '2px', + borderRadius: '4px', '& path': { transition: 'fill ease-out 150ms', }, diff --git a/src/layouts/NetworkLayout/UserMenu.tsx b/src/layouts/NetworkLayout/UserMenu.tsx index f682bc8..7527a9c 100644 --- a/src/layouts/NetworkLayout/UserMenu.tsx +++ b/src/layouts/NetworkLayout/UserMenu.tsx @@ -12,7 +12,7 @@ import { LogoutMenuItem } from './LogoutMenuItem'; export const UserMenuStyled = styled(Menu, { name: 'UserMenuStyled', -})(() => ({ +})(({ theme }) => ({ minWidth: '100%', })); @@ -71,6 +71,10 @@ export function UserMenu() { sx: { overflow: 'visible', width: 192, + pl: 1, + pr: 1, + pt: 0.5, + pb: 0.5, }, }, }} diff --git a/src/lib/formatters/formatters.ts b/src/lib/formatters/formatters.ts index ee41f83..e847fac 100644 --- a/src/lib/formatters/formatters.ts +++ b/src/lib/formatters/formatters.ts @@ -16,7 +16,7 @@ const formatter8 = new Intl.NumberFormat('en', { }); export function numberWithCommasFormatter(val?: number | bigint | string) { - if (val === undefined) return ''; + if (!val) return '0'; return formatter8.format(typeof val === 'string' ? Number(val) : val); } diff --git a/src/pages/AssetsPage/Assets.tsx b/src/pages/AssetsPage/Assets.tsx index 3581324..dcb921c 100644 --- a/src/pages/AssetsPage/Assets.tsx +++ b/src/pages/AssetsPage/Assets.tsx @@ -166,6 +166,13 @@ export function MyAssets() { background: theme.palette.secondary.main, tip: 'Tokens delegated to workers', }; + const lockedPortal: TokenBalance = { + name: 'Locked in Portal', + value: BigNumber(0), + color: theme.palette.text.primary, + background: theme.palette.text.primary, + tip: '', + }; sourcesQuery?.accounts.forEach(s => { if (s.type === AccountType.User) { @@ -185,7 +192,7 @@ export function MyAssets() { }); }); - return [transferable, vesting, claimable, bonded, delegated]; + return [transferable, vesting, claimable, bonded, delegated, lockedPortal]; }, [sourcesQuery?.accounts, theme.palette]); const totalBalance = useMemo(() => { @@ -274,6 +281,7 @@ export function MyAssets() { } spacing={1} flex={1}> + {/* */} diff --git a/src/pages/DashboardPage/Summary.tsx b/src/pages/DashboardPage/Summary.tsx index f64cdf1..b4d0b8d 100644 --- a/src/pages/DashboardPage/Summary.tsx +++ b/src/pages/DashboardPage/Summary.tsx @@ -1,6 +1,5 @@ import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react'; -import { relativeDateFormat } from '@i18n'; import { bytesFormatter, numberWithCommasFormatter, @@ -21,12 +20,12 @@ import { } from '@mui/material'; import Grid from '@mui/material/Unstable_Grid2'; import { AreaChart, Area, ResponsiveContainer, Tooltip, TooltipProps } from 'recharts'; -import { useDebounce } from 'use-debounce'; import { useNetworkSummary } from '@api/subsquid-network-squid'; import SquaredChip from '@components/Chip/SquaredChip'; import { HelpTooltip } from '@components/HelpTooltip'; import { Loader } from '@components/Loader'; +import { useCountdown } from '@hooks/useCountdown'; import { useContracts } from '@network/useContracts'; export function ColumnLabel({ children, color }: PropsWithChildren<{ color?: string }>) { @@ -107,15 +106,24 @@ function OnlineInfo() { ); } +function CurrentEpochEstimation({ epochEnd }: { epochEnd: number }) { + const timeLeft = useCountdown({ timestamp: epochEnd }); + + return ( + + Ends in + ~{timeLeft}} + color="warning" + /> + + ); +} + function CurrentEpoch() { const { data, isLoading } = useNetworkSummary(); const [epochEnd, setEpochEnd] = useState(Date.now()); - - const [curTime] = useDebounce(Date.now(), 1000); - - const epochEndsIn = useMemo(() => relativeDateFormat(curTime, epochEnd), [curTime, epochEnd]); - useEffect(() => { if (!data || !data.epoch) return; @@ -131,15 +139,7 @@ function CurrentEpoch() { sx={{ height: 1 }} loading={isLoading} title={} - action={ - - Ends in - ~{epochEndsIn}} - color="warning" - /> - - } + action={} > {data?.epoch?.number || 0} @@ -230,11 +230,15 @@ function AprChart({ data }: { data: { date: string; value: number }[] }) { // contentStyle={{ transition: 'all ease-out 5500ms' }} content={} animationDuration={0} + // animationEasing="ease-out" cursor={{ stroke: theme.palette.text.secondary, strokeWidth: 2, strokeDasharray: 6, }} + cursorStyle={{ + transition: 'all ease-out 300ms !important', + }} defaultIndex={Math.max(data.length - 2, 0)} active allowEscapeViewBox={{ x: true }} diff --git a/src/pages/GatewaysPage/AddNewGateway.tsx b/src/pages/GatewaysPage/AddNewGateway.tsx index 2f68e00..b999696 100644 --- a/src/pages/GatewaysPage/AddNewGateway.tsx +++ b/src/pages/GatewaysPage/AddNewGateway.tsx @@ -3,7 +3,7 @@ import React, { useMemo, useState } from 'react'; import { peerIdToHex } from '@lib/network'; import { Add } from '@mui/icons-material'; import { LoadingButton } from '@mui/lab'; -import { Alert, SxProps } from '@mui/material'; +import { SxProps } from '@mui/material'; import { useFormik } from 'formik'; import toast from 'react-hot-toast'; import { useClient } from 'wagmi'; @@ -117,7 +117,7 @@ export function AddGatewayDialog({ onClose(); } catch (e: unknown) { - toast.custom({errorMessage(e)}); + toast.error(errorMessage(e)); } }, }); @@ -160,7 +160,7 @@ export function AddGatewayDialog({ showErrorOnlyOfTouched id="peerId" label={ - + Peer ID } diff --git a/src/pages/GatewaysPage/GatewayStake.tsx b/src/pages/GatewaysPage/GatewayStake.tsx index 29392c4..d732955 100644 --- a/src/pages/GatewaysPage/GatewayStake.tsx +++ b/src/pages/GatewaysPage/GatewayStake.tsx @@ -15,6 +15,7 @@ import { useBlock, useReadContracts } from 'wagmi'; import { gatewayRegistryAbi, + useReadGatewayRegistryMinStake, useReadNetworkControllerWorkerEpochLength, useReadRouterNetworkController, } from '@api/contracts'; @@ -45,9 +46,11 @@ export const stakeSchema = yup.object({ .label('Amount') .required() .positive() - .max(yup.ref('max')) + .max(yup.ref('max'), 'Insufficient balance') + .min(yup.ref('min')) .typeError('${path} is invalid'), max: yup.string().label('Max').required().typeError('${path} is invalid'), + min: yup.string().label('Min').required().typeError('${path} is invalid'), autoExtension: yup.boolean().label('Auto extend').default(true), durationBlocks: yup .number() @@ -98,11 +101,16 @@ export function GatewayStakeDialog({ const { GATEWAY_REGISTRATION, ROUTER, CHAIN_ID_L1 } = useContracts(); - const networkController = useReadRouterNetworkController({ - address: ROUTER, - }); - const workerEpochLength = useReadNetworkControllerWorkerEpochLength({ - address: networkController.data, + const { data: networkController, isLoading: isNetworkControllerLoading } = + useReadRouterNetworkController({ + address: ROUTER, + }); + const { data: workerEpochLength, isLoading: isWorkerEpochLengthLoading } = + useReadNetworkControllerWorkerEpochLength({ + address: networkController, + }); + const { data: minStake, isLoading: isMinStakeLoading } = useReadGatewayRegistryMinStake({ + address: GATEWAY_REGISTRATION, }); // const myGatewaysStake = useMyGatewayStake(); @@ -112,7 +120,11 @@ export function GatewayStakeDialog({ chainId: CHAIN_ID_L1, }); - const isLoading = isLastL1BlockLoading; + const isLoading = + isLastL1BlockLoading || + isNetworkControllerLoading || + isWorkerEpochLengthLoading || + isMinStakeLoading; const isSourceDisabled = (source: SourceWalletWithBalance) => source.balance === '0' || source.type === AccountType.Vesting; @@ -123,11 +135,12 @@ export function GatewayStakeDialog({ return { source: source?.id || '0x', - amount: '0', - max: fromSqd(source?.balance)?.toString() || '0', + amount: !!source?.stake.amount ? '0' : fromSqd(minStake).toFixed() || '0', + max: fromSqd(source?.balance)?.toFixed() || '0', + min: fromSqd(minStake)?.toFixed() || '0', durationBlocks: (source?.stake.duration || MIN_BLOCKS_LOCK).toString(), }; - }, [sources]); + }, [sources, minStake]); const formik = useFormik({ initialValues, @@ -170,6 +183,7 @@ export function GatewayStakeDialog({ onClose(); } catch (e: unknown) { toast.error(errorMessage(e)); + // toast.error(errorMessage(e)); } }, }); @@ -206,7 +220,7 @@ export function GatewayStakeDialog({ const preview = useMemo(() => { if (!newContractValues.data || !lastL1Block || !selectedSource) return; - const workerEpochLengthValue = workerEpochLength.data || 0n; + const workerEpochLengthValue = workerEpochLength || 0n; const epochCount = Math.ceil(debouncedValues.durationBlocks / Number(workerEpochLengthValue)); @@ -231,11 +245,12 @@ export function GatewayStakeDialog({ totalAmount, }; }, [ - debouncedValues, + debouncedValues.amount, + debouncedValues.durationBlocks, lastL1Block, newContractValues.data, selectedSource, - workerEpochLength.data, + workerEpochLength, ]); return ( @@ -281,7 +296,11 @@ export function GatewayStakeDialog({ + Amount + + } formik={formik} showErrorOnlyOfTouched autoComplete="off" @@ -309,9 +328,7 @@ export function GatewayStakeDialog({ id="durationBlocks" label={ // TODO: add tooltip text - - Duration - + Duration (blocks) } formik={formik} showErrorOnlyOfTouched @@ -333,7 +350,7 @@ export function GatewayStakeDialog({ {numberWithCommasFormatter(preview?.epochCount)} - + Available CUs {numberWithCommasFormatter(preview?.cuPerEpoch)} diff --git a/src/pages/GatewaysPage/GatewayUnregister.tsx b/src/pages/GatewaysPage/GatewayUnregister.tsx index 62159d6..59dcce4 100644 --- a/src/pages/GatewaysPage/GatewayUnregister.tsx +++ b/src/pages/GatewaysPage/GatewayUnregister.tsx @@ -75,7 +75,7 @@ export function GatewayUnregisterDialog({ return ( { if (!confirmed) return onClose(); @@ -85,7 +85,7 @@ export function GatewayUnregisterDialog({ loading={gatewayRegistryContract.isPending} hideCancelButton={false} > - Are you sure you want to unregister this gateway? This will disable the gateway, but you can + Are you sure you want to unregister this portal? This will disable the portal, but you can re-register it later. ); diff --git a/src/pages/GatewaysPage/GatewayUnstake.tsx b/src/pages/GatewaysPage/GatewayUnstake.tsx index 6453ac2..58babbe 100644 --- a/src/pages/GatewaysPage/GatewayUnstake.tsx +++ b/src/pages/GatewaysPage/GatewayUnstake.tsx @@ -1,8 +1,9 @@ import React, { useState } from 'react'; -import { LockOpen as LockOpenIcon } from '@mui/icons-material'; +import { dateFormat } from '@i18n'; +import { Lock } from '@mui/icons-material'; import { LoadingButton } from '@mui/lab'; -import { SxProps } from '@mui/material'; +import { Box, SxProps, Tooltip } from '@mui/material'; import toast from 'react-hot-toast'; import { useClient } from 'wagmi'; import * as yup from 'yup'; @@ -11,6 +12,7 @@ import { gatewayRegistryAbi } from '@api/contracts'; import { useWriteSQDTransaction } from '@api/contracts/useWriteTransaction'; import { errorMessage } from '@api/contracts/utils'; import { ContractCallDialog } from '@components/ContractCallDialog'; +import { useCountdown } from '@hooks/useCountdown'; import { useSquidHeight } from '@hooks/useSquidNetworkHeightHooks'; import { useContracts } from '@network/useContracts'; @@ -24,22 +26,68 @@ export const stakeSchema = yup.object({ // .max(yup.ref('max'), ({ max }) => `Amount should be less than ${formatSqd(max)} `), }); -export function GatewayUnstakeButton({ sx, disabled }: { sx?: SxProps; disabled?: boolean }) { +function UnlocksTooltip({ timestamp }: { timestamp?: Date | string | number | undefined }) { + const timeLeft = useCountdown({ timestamp }); + + return `Unlocks in ${timeLeft} (${dateFormat(timestamp, 'dateTime')})`; +} + +export function GatewayUnstakeButton({ + sx, + disabled, + source, +}: { + sx?: SxProps; + disabled?: boolean; + source: { + locked: boolean; + unlockedAt?: string; + }; +}) { const [open, setOpen] = useState(false); return ( <> - } - disabled={disabled} - loading={open} - variant="contained" - color="error" - onClick={() => setOpen(true)} - sx={sx} + + + {source.locked && !disabled && ( + + )} + } + disabled={disabled || source.locked} + loading={open} + variant="contained" + color="error" + onClick={() => setOpen(true)} + sx={sx} + > + WITHDRAW + + + setOpen(false)} /> ); diff --git a/src/pages/GatewaysPage/GatewaysPage.tsx b/src/pages/GatewaysPage/GatewaysPage.tsx index 5eacecf..7a92d0d 100644 --- a/src/pages/GatewaysPage/GatewaysPage.tsx +++ b/src/pages/GatewaysPage/GatewaysPage.tsx @@ -9,6 +9,7 @@ import { Avatar, Box, Button, + Card, Collapse, Divider, IconButton, @@ -26,7 +27,7 @@ import { useMediaQuery, useTheme, } from '@mui/material'; -import { Outlet } from 'react-router-dom'; +import { Link, Outlet } from 'react-router-dom'; import { useBlock } from 'wagmi'; import { @@ -44,6 +45,7 @@ import { import SquaredChip from '@components/Chip/SquaredChip'; import { HelpTooltip } from '@components/HelpTooltip'; import { DashboardTable, NoItems } from '@components/Table'; +import { useCountdown } from '@hooks/useCountdown'; import { CenteredPageWrapper } from '@layouts/NetworkLayout'; import { ConnectedWalletRequired } from '@network/ConnectedWalletRequired'; import { useAccount } from '@network/useAccount'; @@ -57,6 +59,18 @@ import { GatewayStakeButton } from './GatewayStake'; import { GatewayUnregisterButton } from './GatewayUnregister'; import { GatewayUnstakeButton } from './GatewayUnstake'; +function AppliesTooltip({ timestamp }: { timestamp?: string }) { + const timeLeft = useCountdown({ timestamp }); + + return {`Applies in ${timeLeft} (${dateFormat(timestamp, 'dateTime')})`}; +} + +function ExpiresTooltip({ timestamp }: { timestamp?: string }) { + const timeLeft = useCountdown({ timestamp }); + + return {`Expires in ${timeLeft} (${dateFormat(timestamp, 'dateTime')})`}; +} + export function MyStakes() { const theme = useTheme(); const narrowXs = useMediaQuery(theme.breakpoints.down('xs')); @@ -95,6 +109,14 @@ export function MyStakes() { const { data: lastL1Block, isLoading: isL1BlockLoading } = useBlock({ chainId: l1ChainId, }); + const { data: appliedAtL1Block, isLoading: isAppliedAtBlockLoading } = useBlock({ + chainId: l1ChainId, + blockNumber: stake?.lockStart, + includeTransactions: false, + query: { + enabled: stake && stake?.lockStart <= (lastL1Block?.number || 0n), + }, + }); const { data: unlockedAtL1Block, isLoading: isUnlockedAtBlockLoading } = useBlock({ chainId: l1ChainId, blockNumber: stake?.lockEnd, @@ -124,15 +146,27 @@ export function MyStakes() { stake.lockEnd >= (lastL1Block?.number || 0n); const isExpired = !!stake?.amount && stake.lockEnd < (lastL1Block?.number || 0n); - const unlockDate = useMemo(() => { + const appliedAt = useMemo(() => { if (!stake || !lastL1Block) return; + if (stake.lockStart < lastL1Block.number) + return new Date(Number(appliedAtL1Block?.timestamp || 0n) * 1000).toISOString(); + + return new Date( + Number(lastL1Block.timestamp) * 1000 + + getBlockTime(stake.lockStart - lastL1Block.number + 1n), + ).toISOString(); + }, [appliedAtL1Block?.timestamp, lastL1Block, stake]); + + const unlockedAt = useMemo(() => { + if (!stake || !lastL1Block || stake.autoExtension) return; + if (stake.lockEnd < lastL1Block.number) - return Number(unlockedAtL1Block?.timestamp || 0n) * 1000; + return new Date(Number(unlockedAtL1Block?.timestamp || 0n) * 1000).toISOString(); - return ( - Number(lastL1Block.timestamp) * 1000 + getBlockTime(stake.lockEnd - lastL1Block.number + 1n) - ); + return new Date( + Number(lastL1Block.timestamp) * 1000 + getBlockTime(stake.lockEnd - lastL1Block.number + 1n), + ).toISOString(); }, [lastL1Block, stake, unlockedAtL1Block?.timestamp]); const cuPerEpoch = useMemo(() => { @@ -154,7 +188,13 @@ export function MyStakes() { action={ - + } > @@ -176,22 +216,27 @@ export function MyStakes() { Amount - - - {stake && - lastL1Block && - (isPending ? ( + + {stake && + lastL1Block && + (isPending ? ( + } + placement="top" + > - ) : isActive ? ( + + ) : isActive ? ( + } + placement="top" + > - ) : isExpired ? ( - - ) : null)} - - + + ) : isExpired ? ( + + ) : null)} + {tokenFormatter(fromSqd(stake?.amount), SQD_TOKEN, 3)} @@ -205,9 +250,9 @@ export function MyStakes() { {numberWithCommasFormatter(cuPerEpoch || 0)} - } spacing={1} flex={1}> + {/* } spacing={1} flex={1}> - Unlocked At + Expired At {!stake?.autoExtension ? unlockDate && stake?.lockEnd @@ -216,7 +261,7 @@ export function MyStakes() { : 'Auto-extension enabled'} - + */} @@ -245,7 +290,13 @@ export function MyGateways() { - @@ -295,7 +346,7 @@ export function MyGateways() { ); } -const GettingStartedAlert = () => { +const GettingStarted = () => { const theme = useTheme(); const [open, setOpen] = useState(false); @@ -304,15 +355,22 @@ const GettingStartedAlert = () => { primary: 'Get SQD tokens', secondary: ( <> - Make sure you have enough SQD tokens. How much do I need? + Make sure you have enough SQD tokens to get started.{' '} + + How much do I need? + ), }, { - primary: 'Lock you tokens', + primary: 'Lock your tokens', secondary: ( <> - Lock your tokens to obtain Compute Units (CUs). How do I lock my tokens? + Lock your SQD tokens to generate Compute Units (CUs), which are used to handle SQD Network + queries.{' '} + + How do CUs transfer to SQD? + ), }, @@ -320,36 +378,42 @@ const GettingStartedAlert = () => { primary: 'Generate a Peer ID', secondary: ( <> - Create a Peer ID to identify your portal. How do I generate a Peer ID? + Create a Peer ID to identify your portal.{' '} + + How to generate a Peer ID? + ), }, { - primary: 'Add your portal', - secondary: <>Register your portal on a chain., + primary: 'Register Your Portal', + secondary: <>Add your portal to the chain to complete the setup., }, ]; return ( - } - action={ - setOpen(!open)}> - - - } - > - Getting started with your portal + + } + action={ + + + + } + onClick={() => setOpen(!open)} + > + Getting started with your portal + - - + + {steps.map(({ primary, secondary }, i) => ( { ))} - That's it! You're ready to run your Portal. If you need more guidance read our{' '} - portal section in our docs or reach out to our{' '} - support team for help. + That's it! Your portal is now ready to run. For more detailed guidance, check out the{' '} + + Portal Documentation + {' '} + or contact our team for help. - + ); }; @@ -389,7 +455,7 @@ export function GatewaysPage() { return ( - + diff --git a/src/pages/WorkersPage/AddNewWorker.tsx b/src/pages/WorkersPage/AddNewWorker.tsx index c288216..cb3a13e 100644 --- a/src/pages/WorkersPage/AddNewWorker.tsx +++ b/src/pages/WorkersPage/AddNewWorker.tsx @@ -21,6 +21,7 @@ import { ConfirmDialog } from '@components/ConfirmDialog'; import { ContractCallDialog } from '@components/ContractCallDialog'; import { Form, FormikCheckBoxInput, FormikTextInput, FormRow } from '@components/Form'; import { FormikSelect } from '@components/Form/FormikSelect'; +import { HelpTooltip } from '@components/HelpTooltip'; import { Loader } from '@components/Loader'; import { SourceWalletOption } from '@components/SourceWallet'; import { useSquidHeight } from '@hooks/useSquidNetworkHeightHooks'; @@ -106,10 +107,6 @@ export function AddNewWorkerDialog({ (source: SourceWalletWithBalance) => BigInt(source.balance) < (bondAmount || 0n), [bondAmount], ); - const hasAvailableSource = useMemo( - () => !!sources?.some(s => !isSourceDisabled(s)), - [isSourceDisabled, sources], - ); const initialValues = useMemo(() => { const source = sources?.find(s => !isSourceDisabled(s)) || sources?.[0]; @@ -121,7 +118,8 @@ export function AddNewWorkerDialog({ email: '', peerId: '', source: source?.id || '', - bond: fromSqd(bondAmount).toString(), + amount: fromSqd(bondAmount).toString(), + max: fromSqd(source?.balance).toString(), }; }, [bondAmount, isSourceDisabled, sources]); @@ -173,7 +171,7 @@ export function AddNewWorkerDialog({ formik.handleSubmit(); }} - disableConfirmButton={isLoading || !hasAvailableSource} + disableConfirmButton={isLoading || !formik.isValid} loading={contractWriter.isPending} > {isLoading ? ( @@ -195,12 +193,19 @@ export function AddNewWorkerDialog({ }) || [] } formik={formik} + onChange={e => { + const source = sources?.find(w => w?.id === e.target.value); + if (!source) return; + + formik.setFieldValue('source', source.id); + formik.setFieldValue('max', fromSqd(source.balance).toString()); + }} /> + Peer ID + + } formik={formik} /> diff --git a/src/pages/WorkersPage/WorkerDelegate.tsx b/src/pages/WorkersPage/WorkerDelegate.tsx index b80cca8..bf957e5 100644 --- a/src/pages/WorkersPage/WorkerDelegate.tsx +++ b/src/pages/WorkersPage/WorkerDelegate.tsx @@ -42,7 +42,7 @@ export const delegateSchema = yup.object({ .label('Amount') .required() .positive() - .max(yup.ref('max')) + .max(yup.ref('max'), 'Insufficient balance') .typeError('${path} is invalid'), max: yup.string().label('Max').required().typeError('${path} is invalid'), }); @@ -97,7 +97,7 @@ export function WorkerDelegateDialog({ const { setWaitHeight } = useSquidHeight(); - const stakingAddress = useReadRouterStaking({ + const { data: stakingAddress, isLoading: isStakingAddressLoading } = useReadRouterStaking({ address: contracts.ROUTER, }); @@ -122,7 +122,7 @@ export function WorkerDelegateDialog({ enableReinitialize: true, onSubmit: async values => { - if (!stakingAddress.data) return; + if (!stakingAddress) return; if (!worker) return; try { @@ -135,7 +135,7 @@ export function WorkerDelegateDialog({ const receipt = await writeTransactionAsync({ abi: stakingAbi, - address: stakingAddress.data, + address: stakingAddress, functionName: 'deposit', args: [BigInt(worker.id), sqdAmount], vesting: source.type === AccountType.Vesting ? (source.id as `0x${string}`) : undefined, @@ -157,6 +157,8 @@ export function WorkerDelegateDialog({ enabled: open && !!worker, }); + const isLoading = isStakingAddressLoading || isExpectedAprPending; + return (
diff --git a/src/pages/WorkersPage/WorkerStatus.tsx b/src/pages/WorkersPage/WorkerStatus.tsx index 86efec9..d731abb 100644 --- a/src/pages/WorkersPage/WorkerStatus.tsx +++ b/src/pages/WorkersPage/WorkerStatus.tsx @@ -1,12 +1,12 @@ import { useMemo } from 'react'; -import { dateFormat, relativeDateFormat } from '@i18n'; +import { dateFormat } from '@i18n'; import { CircleRounded } from '@mui/icons-material'; import { Box, Chip as MaterialChip, Tooltip, chipClasses, styled } from '@mui/material'; import capitalize from 'lodash-es/capitalize'; -import { useDebounce } from 'use-debounce'; import { WorkerStatus as Status, Worker } from '@api/subsquid-network-squid'; +import { useCountdown } from '@hooks/useCountdown'; export const Chip = styled(MaterialChip)(({ theme }) => ({ // [`&.${chipClasses.colorSuccess}`]: { @@ -23,6 +23,12 @@ export const Chip = styled(MaterialChip)(({ theme }) => ({ }, })); +function AppliesTooltip({ timestamp }: { timestamp?: string }) { + const timeLeft = useCountdown({ timestamp }); + + return `Applies in ${timeLeft} (${dateFormat(timestamp, 'dateTime')})`; +} + export function WorkerStatusChip({ worker, }: { @@ -55,20 +61,9 @@ export function WorkerStatusChip({ return { label: capitalize(worker.status), color: 'primary' }; }, [worker.jailReason, worker.jailed, worker.online, worker.status]); - const [curTimestamp] = useDebounce(Date.now(), 1000); - const timeLeft = useMemo( - () => - worker.statusChangeAt ? relativeDateFormat(curTimestamp, worker.statusChangeAt) : undefined, - [curTimestamp, worker.statusChangeAt], - ); - const chip = ( } placement="top" > {`Unlocks in ${timeLeft} (${dateFormat(timestamp, 'dateTime')})`}; +} + export type SourceWalletWithDelegation = SourceWalletWithBalance & { locked: boolean; unlockedAt?: string; @@ -36,7 +43,7 @@ export const undelegateSchema = yup.object({ .label('Amount') .required() .positive() - .max(yup.ref('max')) + .max(yup.ref('max'), 'Insufficient balance') .typeError('${path} is invalid'), max: yup.string().label('Max').required().typeError('${path} is invalid'), }); @@ -57,8 +64,7 @@ export function WorkerUndelegate({ const isLocked = useMemo(() => !!sources?.length && !sources?.some(d => !d.locked), [sources]); - const [curTimestamp] = useDebounce(Date.now(), 1000); - const { unlockedAt, timeLeft } = useMemo(() => { + const { unlockedAt } = useMemo(() => { const min = sources?.reduce( (r, d) => { if (!d.unlockedAt) return r; @@ -71,18 +77,17 @@ export function WorkerUndelegate({ return { unlockedAt: min ? new Date(min).toISOString() : undefined, - timeLeft: min ? relativeDateFormat(curTimestamp, min) : undefined, }; - }, [curTimestamp, sources]); + }, [sources]); return ( <> - - {isLocked && ( - + } + placement="top" + > + + {isLocked && !disabled && ( - - )} - setOpen(true)} - variant="outlined" - color="error" - disabled={disabled || isLocked} - > - UNDELEGATE - - + )} + setOpen(true)} + variant="outlined" + color="error" + disabled={disabled || isLocked} + > + UNDELEGATE + + + setOpen(false)} @@ -135,7 +140,7 @@ function WorkerUndelegateDialog({ const contracts = useContracts(); const { writeTransactionAsync, isPending } = useWriteSQDTransaction(); - const stakingAddress = useReadRouterStaking({ + const { data: stakingAddress, isLoading: isStakingAddressLoading } = useReadRouterStaking({ address: contracts.ROUTER, }); @@ -158,7 +163,7 @@ function WorkerUndelegateDialog({ enableReinitialize: true, onSubmit: async values => { - if (!stakingAddress.data) return; + if (!stakingAddress) return; if (!worker) return; try { @@ -171,7 +176,7 @@ function WorkerUndelegateDialog({ const receipt = await writeTransactionAsync({ abi: stakingAbi, - address: stakingAddress.data, + address: stakingAddress, functionName: 'withdraw', args: [BigInt(worker.id), sqdAmount], vesting: source.type === AccountType.Vesting ? (source.id as `0x${string}`) : undefined, @@ -192,6 +197,8 @@ function WorkerUndelegateDialog({ enabled: open && !!worker, }); + const isLoading = isStakingAddressLoading || isExpectedAprPending; + return ( diff --git a/src/pages/WorkersPage/WorkerWithdraw.tsx b/src/pages/WorkersPage/WorkerWithdraw.tsx index 145052b..c48e9c3 100644 --- a/src/pages/WorkersPage/WorkerWithdraw.tsx +++ b/src/pages/WorkersPage/WorkerWithdraw.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import { dateFormat, relativeDateFormat } from '@i18n'; +import { dateFormat } from '@i18n'; import { peerIdToHex } from '@lib/network'; import { Lock } from '@mui/icons-material'; import { LoadingButton } from '@mui/lab'; @@ -13,10 +13,17 @@ import { useWriteSQDTransaction } from '@api/contracts/useWriteTransaction'; import { errorMessage } from '@api/contracts/utils'; import { AccountType, SourceWallet, Worker } from '@api/subsquid-network-squid'; import { ContractCallDialog } from '@components/ContractCallDialog'; +import { useCountdown } from '@hooks/useCountdown'; import { useSquidHeight } from '@hooks/useSquidNetworkHeightHooks'; import { useAccount } from '@network/useAccount'; import { useContracts } from '@network/useContracts'; +function UnlocksTooltip({ timestamp }: { timestamp?: string }) { + const timeLeft = useCountdown({ timestamp }); + + return {`Unlocks in ${timeLeft} (${dateFormat(timestamp, 'dateTime')})`}; +} + export function WorkerWithdrawButton({ worker, source, @@ -35,15 +42,12 @@ export function WorkerWithdrawButton({ return ( <> - - {source.locked && ( - + } + placement="top" + > + + {source.locked && !disabled && ( - - )} - setOpen(true)} - variant="outlined" - color="error" - disabled={disabled || source.locked} - > - WITHDRAW - - + )} + setOpen(true)} + variant="outlined" + color="error" + disabled={disabled || source.locked} + > + WITHDRAW + + + setOpen(false)} @@ -124,7 +128,7 @@ export function WorkerWithdrawDialog({ return ( { if (!confirmed) return onClose(); diff --git a/src/pages/WorkersPage/worker-schema.ts b/src/pages/WorkersPage/worker-schema.ts index e712810..3e65786 100644 --- a/src/pages/WorkersPage/worker-schema.ts +++ b/src/pages/WorkersPage/worker-schema.ts @@ -1,4 +1,4 @@ -import * as yup from 'yup'; +import * as yup from '@schema'; export const editWorkerSchema = yup.object({ name: yup.string().label('Name').max(255).trim().required('Worker name is required'), @@ -17,4 +17,12 @@ export const addWorkerSchema = editWorkerSchema.shape({ .trim() .required('Peer ID is required'), source: yup.string().label('Source address').trim().required('Source address is required'), + amount: yup + .decimal() + .label('Amount') + .required() + .positive() + .max(yup.ref('max'), 'Insufficient balance') + .typeError('${path} is invalid'), + max: yup.string().label('Max').required().typeError('${path} is invalid'), });