diff --git a/frontend/components/MainPage/header/CannotStartAgentPopover.tsx b/frontend/components/MainPage/header/CannotStartAgentPopover.tsx index 0f880a7a..db882f9c 100644 --- a/frontend/components/MainPage/header/CannotStartAgentPopover.tsx +++ b/frontend/components/MainPage/header/CannotStartAgentPopover.tsx @@ -1,10 +1,11 @@ import { InfoCircleOutlined } from '@ant-design/icons'; -import { Popover, PopoverProps, Typography } from 'antd'; +import { Flex, Popover, PopoverProps, Typography } from 'antd'; import { COLOR } from '@/constants/colors'; import { UNICODE_SYMBOLS } from '@/constants/symbols'; import { SUPPORT_URL } from '@/constants/urls'; import { useStakingContractInfo } from '@/hooks/useStakingContractInfo'; +import { formatToShortDateTime } from '@/utils/time'; const { Paragraph, Text } = Typography; @@ -42,16 +43,37 @@ export const CannotStartAgentDueToUnexpectedError = () => ( ); const evictedDescription = - "You didn't run your agent enough and it missed its targets multiple times. Please wait a few days and try to run your agent again."; -const AgentEvictedPopover = () => ( - {evictedDescription}} - > - {cannotStartAgentText} - -); + "You didn't run your agent enough and it missed its targets multiple times. You can run the agent again when the eviction period ends."; +const AgentEvictedPopover = () => { + const { evictionExpiresAt } = useStakingContractInfo(); + + return ( + + {evictedDescription} + {evictionExpiresAt && ( + + Eviction ends at{' '} + + {formatToShortDateTime(evictionExpiresAt * 1000)} + + + )} + + } + > + {cannotStartAgentText} + + ); +}; const JoinOlasCommunity = () => (
diff --git a/frontend/components/MainPage/index.tsx b/frontend/components/MainPage/index.tsx index 7013ae40..06eca34e 100644 --- a/frontend/components/MainPage/index.tsx +++ b/frontend/components/MainPage/index.tsx @@ -18,7 +18,7 @@ import { KeepAgentRunningSection } from './sections/KeepAgentRunningSection'; import { MainNeedsFunds } from './sections/NeedsFundsSection'; import { NewStakingProgramAlertSection } from './sections/NewStakingProgramAlertSection'; import { MainOlasBalance } from './sections/OlasBalanceSection'; -import { MainRewards } from './sections/RewardsSection'; +import { MainRewards } from './sections/RewardsSection/RewardsSection'; import { StakingContractUpdate } from './sections/StakingContractUpdate'; export const Main = () => { diff --git a/frontend/components/MainPage/sections/RewardsSection.tsx b/frontend/components/MainPage/sections/RewardsSection/RewardsSection.tsx similarity index 87% rename from frontend/components/MainPage/sections/RewardsSection.tsx rename to frontend/components/MainPage/sections/RewardsSection/RewardsSection.tsx index 3376812e..2e73598f 100644 --- a/frontend/components/MainPage/sections/RewardsSection.tsx +++ b/frontend/components/MainPage/sections/RewardsSection/RewardsSection.tsx @@ -1,5 +1,5 @@ -import { InfoCircleOutlined, RightOutlined } from '@ant-design/icons'; -import { Button, Flex, Modal, Skeleton, Tag, Tooltip, Typography } from 'antd'; +import { RightOutlined } from '@ant-design/icons'; +import { Button, Flex, Modal, Skeleton, Tag, Typography } from 'antd'; import Image from 'next/image'; import { useCallback, useEffect, useRef, useState } from 'react'; @@ -11,10 +11,11 @@ import { useReward } from '@/hooks/useReward'; import { useStore } from '@/hooks/useStore'; import { balanceFormat } from '@/utils/numberFormatters'; -import { ConfettiAnimation } from '../../Confetti/ConfettiAnimation'; -import { CardSection } from '../../styled/CardSection'; +import { ConfettiAnimation } from '../../../Confetti/ConfettiAnimation'; +import { CardSection } from '../../../styled/CardSection'; +import { StakingRewardsThisEpoch } from './StakingRewardsThisEpoch'; -const { Text, Title, Paragraph } = Typography; +const { Text, Title } = Typography; const Loader = () => ( @@ -35,20 +36,7 @@ const DisplayRewards = () => { return ( - - Staking rewards this epoch  - - The agent's working period lasts at least 24 hours, but its - start and end point may not be at the same time every day. - - } - > - - - + {isBalanceLoaded ? ( diff --git a/frontend/components/MainPage/sections/RewardsSection/StakingRewardsThisEpoch.tsx b/frontend/components/MainPage/sections/RewardsSection/StakingRewardsThisEpoch.tsx new file mode 100644 index 00000000..88bf6ab5 --- /dev/null +++ b/frontend/components/MainPage/sections/RewardsSection/StakingRewardsThisEpoch.tsx @@ -0,0 +1,78 @@ +import { InfoCircleOutlined } from '@ant-design/icons'; +import { useQuery } from '@tanstack/react-query'; +import { Popover, Typography } from 'antd'; +import { gql, request } from 'graphql-request'; +import { z } from 'zod'; + +import { SUBGRAPH_URL } from '@/constants/urls'; +import { useStakingProgram } from '@/hooks/useStakingProgram'; +import { formatToTime } from '@/utils/time'; + +const { Text } = Typography; + +const EpochTimeResponseSchema = z.object({ + epochLength: z.string(), + blockTimestamp: z.string(), +}); +type EpochTimeResponse = z.infer; + +const useEpochEndTime = () => { + const { activeStakingProgramAddress } = useStakingProgram(); + const latestEpochTimeQuery = gql` + query { + checkpoints( + orderBy: epoch + orderDirection: desc + first: 1 + where: { + contractAddress: "${activeStakingProgramAddress}" + } + ) { + epochLength + blockTimestamp + } + } + `; + + const { data, isLoading } = useQuery({ + queryKey: ['latestEpochTime'], + queryFn: async () => { + const response = (await request(SUBGRAPH_URL, latestEpochTimeQuery)) as { + checkpoints: EpochTimeResponse[]; + }; + return EpochTimeResponseSchema.parse(response.checkpoints[0]); + }, + select: (data) => { + // last epoch end time + epoch length + return Number(data.blockTimestamp) + Number(data.epochLength); + }, + enabled: !!activeStakingProgramAddress, + }); + + return { data, isLoading }; +}; + +export const StakingRewardsThisEpoch = () => { + const { data: epochEndTimeInMs } = useEpochEndTime(); + + return ( + + Staking rewards this epoch  + + The epoch ends each day at ~{' '} + + {epochEndTimeInMs + ? `${formatToTime(epochEndTimeInMs * 1000)} (UTC)` + : '--'} + + + } + > + + + + ); +}; diff --git a/frontend/components/RewardsHistory/useRewardsHistory.ts b/frontend/components/RewardsHistory/useRewardsHistory.ts index 3feed5d6..761d24ac 100644 --- a/frontend/components/RewardsHistory/useRewardsHistory.ts +++ b/frontend/components/RewardsHistory/useRewardsHistory.ts @@ -7,6 +7,7 @@ import { z } from 'zod'; import { Chain } from '@/client'; import { SERVICE_STAKING_TOKEN_MECH_USAGE_CONTRACT_ADDRESSES } from '@/constants/contractAddresses'; import { STAKING_PROGRAM_META } from '@/constants/stakingProgramMeta'; +import { SUBGRAPH_URL } from '@/constants/urls'; import { StakingProgramId } from '@/enums/StakingProgram'; import { useServices } from '@/hooks/useServices'; @@ -30,9 +31,6 @@ const beta2Address = SERVICE_STAKING_TOKEN_MECH_USAGE_CONTRACT_ADDRESSES[Chain.GNOSIS] .pearl_beta_2; -const SUBGRAPH_URL = - 'https://api.studio.thegraph.com/query/81855/pearl-staking-rewards-history/version/latest'; - const fetchRewardsQuery = gql` { allRewards: checkpoints(orderBy: epoch, orderDirection: desc) { diff --git a/frontend/constants/urls.ts b/frontend/constants/urls.ts index ccced30c..216f221b 100644 --- a/frontend/constants/urls.ts +++ b/frontend/constants/urls.ts @@ -4,6 +4,9 @@ export const COW_SWAP_GNOSIS_XDAI_OLAS_URL: string = export const FAQ_URL = 'https://olas.network/operate#faq'; +export const SUBGRAPH_URL = + 'https://api.studio.thegraph.com/query/81855/pearl-staking-rewards-history/version/latest'; + // discord export const SUPPORT_URL = 'https://discord.com/channels/899649805582737479/1244588374736502847'; diff --git a/frontend/hooks/useStakingContractInfo.ts b/frontend/hooks/useStakingContractInfo.ts index 75a67674..72f24d6b 100644 --- a/frontend/hooks/useStakingContractInfo.ts +++ b/frontend/hooks/useStakingContractInfo.ts @@ -43,9 +43,7 @@ export const useStakingContractInfo = () => { serviceIds.length < maxNumServices; const hasEnoughRewardsAndSlots = isRewardsAvailable && hasEnoughServiceSlots; - const isAgentEvicted = serviceStakingState === 2; - const isServiceStaked = !!serviceStakingStartTime && serviceStakingState === 1; @@ -78,10 +76,15 @@ export const useStakingContractInfo = () => { !isNil(hasEnoughRewardsAndSlots) && (isAgentEvicted ? isServiceStakedForMinimumDuration : true); + // Eviction expire time in seconds + const evictionExpiresAt = + (serviceStakingStartTime ?? 0) + (minimumStakingDuration ?? 0); + return { activeStakingContractInfo, hasEnoughServiceSlots, isAgentEvicted, + evictionExpiresAt, isEligibleForStaking, isPaused, isRewardsAvailable, diff --git a/frontend/hooks/useStakingProgram.ts b/frontend/hooks/useStakingProgram.ts index 560c8c81..b99ca675 100644 --- a/frontend/hooks/useStakingProgram.ts +++ b/frontend/hooks/useStakingProgram.ts @@ -1,5 +1,7 @@ import { useContext, useMemo } from 'react'; +import { Chain } from '@/client'; +import { SERVICE_STAKING_TOKEN_MECH_USAGE_CONTRACT_ADDRESSES } from '@/constants/contractAddresses'; import { STAKING_PROGRAM_META } from '@/constants/stakingProgramMeta'; import { StakingProgramContext } from '@/context/StakingProgramContext'; @@ -23,7 +25,7 @@ export const useStakingProgram = () => { * returns `null` if not actively staked */ const activeStakingProgramMeta = useMemo(() => { - if (activeStakingProgramId === undefined) return undefined; + if (activeStakingProgramId === undefined) return; if (activeStakingProgramId === null) return null; return STAKING_PROGRAM_META[activeStakingProgramId]; }, [activeStakingProgramId]); @@ -31,8 +33,16 @@ export const useStakingProgram = () => { const defaultStakingProgramMeta = STAKING_PROGRAM_META[defaultStakingProgramId]; + const activeStakingProgramAddress = useMemo(() => { + if (!activeStakingProgramId) return; + return SERVICE_STAKING_TOKEN_MECH_USAGE_CONTRACT_ADDRESSES[Chain.GNOSIS][ + activeStakingProgramId + ]; + }, [activeStakingProgramId]); + return { activeStakingProgramId, + activeStakingProgramAddress, activeStakingProgramMeta, defaultStakingProgramId, defaultStakingProgramMeta, diff --git a/frontend/styles/globals.scss b/frontend/styles/globals.scss index 1a06c305..07f658c6 100644 --- a/frontend/styles/globals.scss +++ b/frontend/styles/globals.scss @@ -172,18 +172,16 @@ button, input, select, textarea, .ant-input-suffix { margin-right: auto !important; } +// font size .text-xl { font-size: 20px; } - .text-base { font-size: 16px !important; } - .text-sm { font-size: 14px !important; } - .text-xs { font-size: 12px !important; } diff --git a/frontend/theme/index.ts b/frontend/theme/index.ts index 0291c95c..eed4d88b 100644 --- a/frontend/theme/index.ts +++ b/frontend/theme/index.ts @@ -40,6 +40,9 @@ export const mainTheme: ThemeConfig = { Typography: { colorTextDescription: '#4D596A', }, + Popover: { + fontSize: 14, + }, Tag: { colorSuccess: '#135200', }, diff --git a/frontend/utils/time.ts b/frontend/utils/time.ts index a09b9ab6..7a9faa4e 100644 --- a/frontend/utils/time.ts +++ b/frontend/utils/time.ts @@ -27,22 +27,36 @@ export const getTimeAgo = (timestampInSeconds: number) => { * @returns formatted date in the format of 'MMM DD' * @example 1626825600 => 'Jul 21' */ -export const formatToMonthDay = (timeInSeconds: number) => { - if (!isNumber(timeInSeconds)) return '--'; - return new Date(timeInSeconds).toLocaleDateString('en-US', { +export const formatToMonthDay = (timeInMs: number) => { + if (!isNumber(timeInMs)) return '--'; + return new Date(timeInMs).toLocaleDateString('en-US', { month: 'short', day: 'numeric', }); }; +/** + * @returns formatted time in the format of 'HH:MM AM/PM' + * @example 1626825600 => '12:00 PM' + */ +export const formatToTime = (timeInMs: number) => { + if (!isNumber(timeInMs)) return '--'; + return new Date(timeInMs).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: 'numeric', + hour12: true, + timeZone: 'UTC', + }); +}; + /** * * @returns formatted date and time in the format of 'MMM DD, HH:MM AM/PM' * @example 1626825600 => 'Jul 21, 12:00 PM' */ -export const formatToShortDateTime = (timeInSeconds?: number) => { - if (!isNumber(timeInSeconds)) return '--'; - return new Date(timeInSeconds).toLocaleDateString('en-US', { +export const formatToShortDateTime = (timeInMs?: number) => { + if (!isNumber(timeInMs)) return '--'; + return new Date(timeInMs).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric',