diff --git a/package.json b/package.json index 44c8115..28fcc7e 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "react-minimal-pie-chart": "^8.4.0", "react-toastify": "^9.1.3", "viem": "2.0.6", - "wagmi": "2.2.0" + "wagmi": "2.2.1" }, "devDependencies": { "@tanstack/eslint-plugin-query": "^5.17.7", diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index 41b9aea..ab724a3 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -1,14 +1,72 @@ 'use client'; +import Image from 'next/image'; +import { SolidButton } from 'src/components/buttons/SolidButton'; import { Section } from 'src/components/layout/Section'; +import { Amount } from 'src/components/numbers/Amount'; +import { useBalance, useLockedBalance } from 'src/features/account/hooks'; +import { LockActionType } from 'src/features/locking/types'; +import { useStakingRewards } from 'src/features/staking/rewards/useStakingRewards'; +import { useStakingBalances } from 'src/features/staking/useStakingBalances'; +import { useTransactionModal } from 'src/features/transactions/TransactionModal'; +import { TxModalType } from 'src/features/transactions/types'; +import Lock from 'src/images/icons/lock.svg'; +import Unlock from 'src/images/icons/unlock.svg'; +import Withdraw from 'src/images/icons/withdraw.svg'; import { usePageInvariant } from 'src/utils/navigation'; import { useAccount } from 'wagmi'; export default function Page() { const account = useAccount(); const address = account?.address; - usePageInvariant(!!address, '/', 'No account connected'); - return
TODO
; + const { balance: walletBalance } = useBalance(address); + const { lockedBalance } = useLockedBalance(address); + const { groupToStake } = useStakingBalances(address); + const { totalRewards: _totalRewards } = useStakingRewards(address, groupToStake); + + const totalBalance = (walletBalance || 0n) + (lockedBalance || 0n); + + const showTxModal = useTransactionModal(); + + return ( +
+

Dashboard

+
+
+

Total Balance

+ +
+
+ showTxModal(TxModalType.Lock, { defaultAction: LockActionType.Lock })} + > +
+ + Lock +
+
+ showTxModal(TxModalType.Lock, { defaultAction: LockActionType.Unlock })} + > +
+ + Unlock +
+
+ + showTxModal(TxModalType.Lock, { defaultAction: LockActionType.Withdraw }) + } + > +
+ + Withdraw +
+
+
+
+
+ ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 6520552..230ba4a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,98 +1,86 @@ 'use client'; import { useMemo } from 'react'; import { SolidButton } from 'src/components/buttons/SolidButton'; -import { Card } from 'src/components/layout/Card'; import { Section } from 'src/components/layout/Section'; -import { Modal, useModal } from 'src/components/menus/Modal'; -import { Amount } from 'src/components/numbers/Amount'; -import { StakeForm } from 'src/features/staking/StakeForm'; +import { StatBox } from 'src/components/layout/StatBox'; import { useTransactionModal } from 'src/features/transactions/TransactionModal'; import { TxModalType } from 'src/features/transactions/types'; import { ValidatorGroupTable } from 'src/features/validators/ValidatorGroupTable'; import { ValidatorGroup, ValidatorStatus } from 'src/features/validators/types'; import { useValidatorGroups } from 'src/features/validators/useValidatorGroups'; import { bigIntMin } from 'src/utils/math'; +import { objLength } from 'src/utils/objects'; export default function Index() { const { groups, totalVotes } = useValidatorGroups(); - const { isModalOpen, openModal: _openModal, closeModal } = useModal(); - return ( <> -
- - -
- - - +
+
+ + +
+
); } function HeroSection({ totalVotes, groups }: { totalVotes?: bigint; groups?: ValidatorGroup[] }) { - const minVotes = useMemo(() => { - if (!groups?.length) return 0n; - let min = BigInt(1e40); + const { minVotes, numValidators } = useMemo(() => { + if (!groups?.length) return { minVotes: 0n, numValidators: 0 }; + let minVotes = BigInt(1e40); + let numValidators = 0; for (const g of groups) { + numValidators += objLength(g.members); const numElectedMembers = Object.values(g.members).filter( (m) => m.status === ValidatorStatus.Elected, ).length; if (!numElectedMembers) continue; const votesPerMember = g.votes / BigInt(numElectedMembers); - min = bigIntMin(min, votesPerMember); + minVotes = bigIntMin(minVotes, votesPerMember); } - return min; + return { minVotes, numValidators }; }, [groups]); const showStakeModal = useTransactionModal(TxModalType.Stake); return ( -
-
-
-

Discover Validators

-

Stake your CELO with validators to start earning rewards immediately.

- {`Stake and earn 4%`} -
-
- - - - -
+
+
+

Discover Validators

+ showStakeModal()} className="px-8"> + Stake + +
+
+ + + +
+
+

Total Groups

+
{groups?.length || 0}
+
+
+

Total Validators

+
{numValidators}
+
+
+
-
- ); -} - -function HeroStat({ - label, - text, - amount, -}: { - label: string; - text?: string | number; - amount?: bigint; -}) { - return ( -
- - {!!text &&
{text}
} - {!!amount && ( - - )}
); } function ListSection({ totalVotes, groups }: { totalVotes?: bigint; groups?: ValidatorGroup[] }) { - return ( -
- - - -
- ); + return ; } diff --git a/src/app/staking/[address]/page.tsx b/src/app/staking/[address]/page.tsx index dda84e9..6fe3d50 100644 --- a/src/app/staking/[address]/page.tsx +++ b/src/app/staking/[address]/page.tsx @@ -1,7 +1,9 @@ 'use client'; +import BigNumber from 'bignumber.js'; import { useMemo, useState } from 'react'; import { PieChart } from 'react-minimal-pie-chart'; +import { toast } from 'react-toastify'; import { Spinner } from 'src/components/animation/Spinner'; import { ExternalLink } from 'src/components/buttons/ExternalLink'; import { IconButton } from 'src/components/buttons/IconButton'; @@ -9,21 +11,29 @@ import { OutlineButton } from 'src/components/buttons/OutlineButton'; import { SolidButton } from 'src/components/buttons/SolidButton'; import { TabHeaderButton } from 'src/components/buttons/TabHeaderButton'; import { TextLink } from 'src/components/buttons/TextLink'; -import { HeatmapSquares } from 'src/components/charts/Heatmap'; +import { HeatmapLines } from 'src/components/charts/Heatmap'; import { ArrowIcon } from 'src/components/icons/Arrow'; +import { Checkmark } from 'src/components/icons/Checkmark'; import { Circle } from 'src/components/icons/Circle'; import { Identicon } from 'src/components/icons/Identicon'; +import { SlashIcon } from 'src/components/icons/Slash'; +import { XIcon } from 'src/components/icons/XIcon'; import { Section } from 'src/components/layout/Section'; +import { StatBox } from 'src/components/layout/StatBox'; import { Twitter } from 'src/components/logos/Twitter'; import { Web } from 'src/components/logos/Web'; -import { formatNumberString } from 'src/components/numbers/Amount'; +import { Amount, formatNumberString } from 'src/components/numbers/Amount'; import { EPOCH_DURATION_MS, ZERO_ADDRESS } from 'src/config/consts'; import { VALIDATOR_GROUPS } from 'src/config/validators'; +import { useLockedBalance } from 'src/features/account/hooks'; +import { useStore } from 'src/features/store'; +import { TxModalType } from 'src/features/transactions/types'; import { ValidatorGroupLogo } from 'src/features/validators/ValidatorGroupLogo'; import { ValidatorGroup, ValidatorStatus } from 'src/features/validators/types'; import { useGroupRewardHistory } from 'src/features/validators/useGroupRewardHistory'; import { useValidatorGroups } from 'src/features/validators/useValidatorGroups'; import { useValidatorStakers } from 'src/features/validators/useValidatorStakers'; +import { getGroupStats } from 'src/features/validators/utils'; import { Color } from 'src/styles/Color'; import { useIsMobile } from 'src/styles/mediaQueries'; import { eqAddressSafe, shortenAddress } from 'src/utils/addresses'; @@ -31,8 +41,9 @@ import { fromWei, fromWeiRounded } from 'src/utils/amount'; import { useCopyHandler } from 'src/utils/clipboard'; import { usePageInvariant } from 'src/utils/navigation'; import { objLength } from 'src/utils/objects'; +import { getDateTimeString, getHumanReadableTimeString } from 'src/utils/time'; -const HEATMAP_SIZE = 100; +const HEATMAP_SIZE = 50; export const dynamicParams = true; @@ -46,10 +57,12 @@ export default function Page({ params: { address } }: { params: { address: Addre usePageInvariant(!groups || group, '/', 'Validator group not found'); return ( -
- - - +
+ <> + + + +
); } @@ -60,50 +73,78 @@ function HeaderSection({ group }: { group?: ValidatorGroup }) { const twitterUrl = VALIDATOR_GROUPS[address]?.twitter; const onClickAddress = useCopyHandler(group?.address); + const onClickSlash = () => { + if (group?.lastSlashed) { + toast.info(`This group was last slashed on ${getDateTimeString(group.lastSlashed)}`); + } else { + toast.info('This group has never been slashed'); + } + }; + + const setTxModal = useStore((state) => state.setTransactionModal); + const onClickStake = () => { + setTxModal({ type: TxModalType.Stake, props: { defaultGroup: address } }); + }; return (
-
+
- Staking + Browse Validators
-
-
+
+

{group?.name || '...'}

-
- +
+ {shortenAddress(address)} + +
+ + + {group?.lastSlashed ? getHumanReadableTimeString(group.lastSlashed) : 'Never'} + +
+
{webUrl && ( - + )} {twitterUrl && ( - + )}
-
- Stake -
+ + Stake +
); } -function HeatmapSection({ group }: { group?: ValidatorGroup }) { +function StatSection({ group }: { group?: ValidatorGroup }) { const { rewardHistory } = useGroupRewardHistory(group?.address, HEATMAP_SIZE); const data = useMemo(() => { @@ -120,20 +161,47 @@ function HeatmapSection({ group }: { group?: ValidatorGroup }) { return hasReward; }, [rewardHistory]); + const capacityPercent = Math.min( + BigNumber(group?.votes?.toString() || 0) + .div(group?.capacity?.toString() || 1) + .multipliedBy(100) + .decimalPlaces(0) + .toNumber(), + 100, + ); + + const heatmapStartDate = new Date(Date.now() - EPOCH_DURATION_MS * HEATMAP_SIZE); + return ( -
-

Reward payments (last 100 days)

- -
-
-
- +
+ +
+
-
-
- +
{`Maximum: ${formatNumberString( + fromWei(group?.capacity), + )} CELO`}
+ + +
+ {heatmapStartDate.toLocaleDateString()} + Yesterday
-
+ +
+
+
+ +
+
+
+ +
+
+
); } @@ -143,20 +211,20 @@ function DetailsSection({ group }: { group?: ValidatorGroup }) { return (
-
+
setTab('members')} count={objLength(group?.members || {})} > - Members + Group members setTab('stakers')} count={getStakersHeaderCount(group)} > - Stakers + Stakers
{tab === 'members' && } @@ -167,34 +235,64 @@ function DetailsSection({ group }: { group?: ValidatorGroup }) { function Members({ group }: { group?: ValidatorGroup }) { const isMobile = useIsMobile(); + const groupStats = getGroupStats(group); + + const { lockedBalance } = useLockedBalance(group?.address); + return ( - - - - - - - - - - {Object.values(group?.members || {}).map((member) => ( - - - - + <> +
+
+

Elected

+ {`${groupStats.numElected} / ${groupStats.numMembers}`} +
+
+

Average Score

+ {`${groupStats.avgScore.toFixed(2)}%`} +
+
+

Locked CELO

+ +
+
+
AddressScoreElected
-
- - - {isMobile ? shortenAddress(member.address) : member.address} - -
-
{fromWeiRounded(member.score, 22, 0) + '%'} - {member.status === ValidatorStatus.Elected ? 'Yes' : 'No'} -
+ + + + + - ))} - -
AddressScoreElected
+ + + {Object.values(group?.members || {}).map((member) => ( + + +
+ + + {isMobile ? shortenAddress(member.address) : member.address} + +
+ + {fromWeiRounded(member.score, 22, 0) + '%'} + + {member.status === ValidatorStatus.Elected ? ( +
+ + Yes +
+ ) : ( +
+ + No +
+ )} + + + ))} + + + ); } @@ -221,17 +319,18 @@ function Stakers({ group }: { group?: ValidatorGroup }) { if (!chartData?.length) { return (
- +
); } return ( -
+
+ @@ -246,6 +345,9 @@ function Stakers({ group }: { group?: ValidatorGroup }) { + ))} diff --git a/src/components/animation/Spinner.module.css b/src/components/animation/Spinner.module.css deleted file mode 100644 index 83ca1ab..0000000 --- a/src/components/animation/Spinner.module.css +++ /dev/null @@ -1,39 +0,0 @@ -.spinner { - display: inline-block; - position: relative; - width: 80px; - height: 80px; - opacity: 0.8; -} - -.spinner div { - box-sizing: border-box; - display: block; - position: absolute; - width: 64px; - height: 64px; - margin: 8px; - border: 8px solid #2e3338; - border-radius: 50%; - animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; - border-color: #2e3338 transparent transparent transparent; -} - -.spinner div:nth-of-type(1) { - animation-delay: -0.45s; -} -.spinner div:nth-of-type(2) { - animation-delay: -0.3s; -} -.spinner div:nth-of-type(3) { - animation-delay: -0.15s; -} - -@keyframes lds-ring { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } -} diff --git a/src/components/animation/Spinner.tsx b/src/components/animation/Spinner.tsx index f6b9e89..01ee12d 100644 --- a/src/components/animation/Spinner.tsx +++ b/src/components/animation/Spinner.tsx @@ -1,16 +1,27 @@ -import { memo } from 'react'; -import styles from 'src/components/animation/Spinner.module.css'; +import clsx from 'clsx'; +import { PropsWithChildren } from 'react'; -// From https://loading.io/css/ -function _Spinner() { +type Size = 'xs' | 'sm' | 'md' | 'lg'; + +export function Spinner({ size, className }: { size: Size; className?: string }) { + return
; +} + +export function SpinnerWithLabel({ + children, + className, +}: PropsWithChildren<{ className?: string }>) { return ( -
-
-
-
-
+
+ + {children}
); } -export const Spinner = memo(_Spinner); +const sizeToClass = { + xs: 'loading-xs', + sm: 'loading-sm', + md: 'loading-md', + lg: 'loading-lg', +}; diff --git a/src/components/buttons/FormSubmitButton.tsx b/src/components/buttons/FormSubmitButton.tsx new file mode 100644 index 0000000..d046dac --- /dev/null +++ b/src/components/buttons/FormSubmitButton.tsx @@ -0,0 +1,33 @@ +import { useFormikContext } from 'formik'; +import { useCallback } from 'react'; +import { SolidButtonWithSpinner } from 'src/components/buttons/SolidButtonWithSpinner'; +import { useTimeout } from 'src/utils/asyncHooks'; + +type Props = React.ComponentProps; + +export function FormSubmitButton({ children, ...props }: Props) { + const { errors, setErrors, touched, setTouched } = useFormikContext(); + + const hasError = Object.keys(touched).length > 0 && Object.keys(errors).length > 0; + const firstError = `${Object.values(errors)[0]}` || 'Unknown error'; + + const className = hasError + ? 'all:bg-red-500 all:hover:bg-red-500 all:hover:opacity-100' + : undefined; + const content = hasError ? firstError : children; + + // Automatically clear error state after a timeout + const clearErrors = useCallback(async () => { + setErrors({}); + await setTouched({}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [setErrors, setTouched, errors, touched]); + + useTimeout(clearErrors, 3000); + + return ( + + {content} + + ); +} diff --git a/src/components/buttons/SolidButton.tsx b/src/components/buttons/SolidButton.tsx index 0673f6a..8e044b2 100644 --- a/src/components/buttons/SolidButton.tsx +++ b/src/components/buttons/SolidButton.tsx @@ -7,7 +7,7 @@ export function SolidButton({ }: PropsWithChildren>) { return (
AddressPercent Value
+ {((data.value / fromWei(group?.votes || 1)) * 100).toFixed(2) + '%'} + {formatNumberString(data.value)}
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -113,15 +112,15 @@ export function ValidatorGroupTable({ {header.isPlaceholder ? null : (
{flexRender(header.column.columnDef.header, header.getContext())} {{ - asc: ' 🔼', - desc: ' 🔽', + asc: , + desc: , }[header.column.getIsSorted() as string] ?? null}
)} @@ -144,7 +143,7 @@ export function ValidatorGroupTable({ ))}
- +
); } @@ -160,7 +159,7 @@ function useTableColumns(totalVotes: bigint) { cell: (props) =>
{getRowSortedIndex(props)}
, }), columnHelper.accessor('name', { - header: 'Name', + header: 'Group name', cell: (props) => (
@@ -169,7 +168,7 @@ function useTableColumns(totalVotes: bigint) { ), }), columnHelper.accessor('votes', { - header: 'Total Staked', + header: 'Staked', cell: (props) => ( ( - { e.preventDefault(); setTxModal({ type: TxModalType.Stake, props: { defaultGroup: props.row.original } }); }} + className="all:bg-white all:hover:bg-white/70" > -
- Stake - -
-
+ Stake + ), }), ]; @@ -240,19 +237,12 @@ function useTableRows({ ), ); - const groupRows = filteredGroups.map((g): ValidatorGroupRow => { - const members = Object.values(g.members); - const electedMembers = members.filter((m) => m.status === ValidatorStatus.Elected); - const avgScore = electedMembers.length - ? parseFloat(fromWeiRounded(bigIntMean(electedMembers.map((m) => m.score)), 22, 0)) - : 0; - return { + const groupRows = filteredGroups.map( + (g): ValidatorGroupRow => ({ ...g, - numMembers: members.length, - numElected: electedMembers.length, - avgScore, - }; - }); + ...getGroupStats(g), + }), + ); return groupRows; }, [groups, filter, searchQuery]); } @@ -307,6 +297,14 @@ function CumulativeColumn({ ); } +function TableSortChevron({ direction }: { direction: 'n' | 's' }) { + return ( +
+ +
+ ); +} + function computeCumulativeShare( groups: ValidatorGroupRow[], groupAddr: Address, diff --git a/src/features/validators/types.ts b/src/features/validators/types.ts index ddbddf5..f107ad8 100644 --- a/src/features/validators/types.ts +++ b/src/features/validators/types.ts @@ -9,6 +9,7 @@ export interface ValidatorGroup { eligible: boolean; capacity: bigint; votes: bigint; + lastSlashed: number | null; // timestamp members: Record; } diff --git a/src/features/validators/useValidatorGroups.ts b/src/features/validators/useValidatorGroups.ts index 1d4eeae..222b738 100644 --- a/src/features/validators/useValidatorGroups.ts +++ b/src/features/validators/useValidatorGroups.ts @@ -64,6 +64,7 @@ async function fetchValidatorGroupInfo(publicClient: PublicClient) { eligible: false, capacity: 0n, votes: 0n, + lastSlashed: null, }; } // Create new validator group member @@ -88,12 +89,14 @@ async function fetchValidatorGroupInfo(publicClient: PublicClient) { // Fetch details about the validator groups const groupAddrs = Object.keys(groups) as Address[]; const groupNames = await fetchNamesForAccounts(publicClient, groupAddrs); + const groupSlashTimes = await fetchGroupLastSlashed(publicClient, groupAddrs); for (let i = 0; i < groupAddrs.length; i++) { const groupAddr = groupAddrs[i]; groups[groupAddr].name = groupNames[i] || groupAddr.substring(0, 10) + '...'; + groups[groupAddr].lastSlashed = groupSlashTimes[i]; } - // Fetch vote-related details about the validator groups + // Fetch vote-related total amounts const { eligibleGroups, groupVotes, totalLocked, totalVotes } = await fetchVotesAndTotalLocked(publicClient); @@ -167,7 +170,7 @@ async function fetchValidatorDetails(publicClient: PublicClient, addresses: read async function fetchNamesForAccounts(publicClient: PublicClient, addresses: readonly Address[]) { // @ts-ignore Bug with viem 2.0 multicall types - const names: MulticallReturnType = await publicClient.multicall({ + const results: MulticallReturnType = await publicClient.multicall({ contracts: addresses.map((addr) => ({ address: Addresses.Accounts, abi: accountsABI, @@ -176,12 +179,32 @@ async function fetchNamesForAccounts(publicClient: PublicClient, addresses: read })), allowFailure: true, }); - return names.map((n) => { + return results.map((n) => { if (!n.result) return null; return n.result as string; }); } +async function fetchGroupLastSlashed(publicClient: PublicClient, addresses: readonly Address[]) { + // @ts-ignore Bug with viem 2.0 multicall types + const results: MulticallReturnType = await publicClient.multicall({ + contracts: addresses.map((addr) => ({ + address: Addresses.Validators, + abi: validatorsABI, + functionName: 'getValidatorGroup', + args: [addr], + })), + allowFailure: true, + }); + return results.map((n) => { + const result = n.result as + | [Address, bigint, bigint, bigint, bigint[], bigint, bigint] + | undefined; + if (!result || !Array.isArray(result) || result.length < 7) return null; + return Number(result[6]) * 1000; + }); +} + async function fetchVotesAndTotalLocked(publicClient: PublicClient) { const [votes, locked] = await publicClient.multicall({ contracts: [ diff --git a/src/features/validators/utils.ts b/src/features/validators/utils.ts index 0c372e4..bbda14b 100644 --- a/src/features/validators/utils.ts +++ b/src/features/validators/utils.ts @@ -1,4 +1,6 @@ import { ValidatorGroup, ValidatorStatus } from 'src/features/validators/types'; +import { fromWeiRounded } from 'src/utils/amount'; +import { bigIntMean } from 'src/utils/math'; import { toTitleCase } from 'src/utils/strings'; export function cleanGroupName(name: string) { @@ -13,3 +15,13 @@ export function cleanGroupName(name: string) { export function isElected(group: ValidatorGroup) { return Object.values(group.members).some((m) => m.status === ValidatorStatus.Elected); } + +export function getGroupStats(group?: ValidatorGroup) { + if (!group) return { numMembers: 0, numElected: 0, avgScore: 0 }; + const members = Object.values(group.members); + const electedMembers = members.filter((m) => m.status === ValidatorStatus.Elected); + const avgScore = electedMembers.length + ? parseFloat(fromWeiRounded(bigIntMean(electedMembers.map((m) => m.score)), 22, 0)) + : 0; + return { numMembers: members.length, numElected: electedMembers.length, avgScore }; +} diff --git a/src/features/wallet/WalletDropdown.tsx b/src/features/wallet/WalletDropdown.tsx index ee03b55..9b9372c 100644 --- a/src/features/wallet/WalletDropdown.tsx +++ b/src/features/wallet/WalletDropdown.tsx @@ -18,7 +18,7 @@ export function WalletDropdown() { const { disconnect } = useDisconnect(); return ( -
+
{address && isConnected ? ( ( @@ -49,8 +49,6 @@ function DropdownContent({ disconnect: () => void; close: () => void; }) { - // TODO only run if content is open: https://github.com/saadeghi/daisyui/discussions/2697 - // TODO update these hooks with a refetch interval after upgrading to wagmi v2 const { balance: walletBalance } = useBalance(address); const { lockedBalance } = useLockedBalance(address); const { groupToStake } = useStakingBalances(address); diff --git a/src/images/icons/avatar.svg b/src/images/icons/avatar.svg new file mode 100644 index 0000000..46d1a75 --- /dev/null +++ b/src/images/icons/avatar.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/images/icons/bridge.svg b/src/images/icons/bridge.svg new file mode 100644 index 0000000..bf24050 --- /dev/null +++ b/src/images/icons/bridge.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/images/icons/clipboard-plus-dark.svg b/src/images/icons/clipboard-plus-dark.svg deleted file mode 100644 index 33e2c2e..0000000 --- a/src/images/icons/clipboard-plus-dark.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/images/icons/clipboard-plus.svg b/src/images/icons/clipboard-plus.svg deleted file mode 100644 index a5d6ab8..0000000 --- a/src/images/icons/clipboard-plus.svg +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/src/images/icons/copy-stack copy.svg b/src/images/icons/copy-stack copy.svg deleted file mode 100644 index 140da76..0000000 --- a/src/images/icons/copy-stack copy.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/images/icons/dashboard.svg b/src/images/icons/dashboard.svg new file mode 100644 index 0000000..4beb5ef --- /dev/null +++ b/src/images/icons/dashboard.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/images/icons/gear.svg b/src/images/icons/gear.svg deleted file mode 100644 index ac27bae..0000000 --- a/src/images/icons/gear.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/src/images/icons/governance.svg b/src/images/icons/governance.svg new file mode 100644 index 0000000..447bcf6 --- /dev/null +++ b/src/images/icons/governance.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/images/icons/info-circle.svg b/src/images/icons/info-circle.svg index 0498cef..05606f4 100644 --- a/src/images/icons/info-circle.svg +++ b/src/images/icons/info-circle.svg @@ -1,4 +1 @@ - - - - \ No newline at end of file + \ No newline at end of file diff --git a/src/images/icons/lock.svg b/src/images/icons/lock.svg new file mode 100644 index 0000000..d8c1111 --- /dev/null +++ b/src/images/icons/lock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/images/icons/logout-dark.svg b/src/images/icons/logout-dark.svg deleted file mode 100644 index 7e0582c..0000000 --- a/src/images/icons/logout-dark.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/images/icons/moon.svg b/src/images/icons/moon.svg deleted file mode 100644 index 9076f61..0000000 --- a/src/images/icons/moon.svg +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - - - diff --git a/src/images/icons/sliders.svg b/src/images/icons/sliders.svg deleted file mode 100644 index 89c6b41..0000000 --- a/src/images/icons/sliders.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/src/images/icons/staking.svg b/src/images/icons/staking.svg new file mode 100644 index 0000000..426320c --- /dev/null +++ b/src/images/icons/staking.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/images/icons/sun.svg b/src/images/icons/sun.svg deleted file mode 100644 index 7d1e39c..0000000 --- a/src/images/icons/sun.svg +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - - - - diff --git a/src/images/icons/unlock.svg b/src/images/icons/unlock.svg new file mode 100644 index 0000000..d1c4cc8 --- /dev/null +++ b/src/images/icons/unlock.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/images/icons/withdraw.svg b/src/images/icons/withdraw.svg new file mode 100644 index 0000000..c117115 --- /dev/null +++ b/src/images/icons/withdraw.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/images/logos/celo-cube.webp b/src/images/logos/celo-cube.webp new file mode 100644 index 0000000..7b743b1 Binary files /dev/null and b/src/images/logos/celo-cube.webp differ diff --git a/src/styles/globals.css b/src/styles/globals.css index 902d402..9aa160d 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -32,28 +32,11 @@ Scrollbar Overrides =================== */ -html { - --scrollbarBG: rgba(255, 255, 255, 0); - --thumbBG: #dddddd; -} body { scroll-behavior: smooth; scrollbar-width: thin; - scrollbar-color: var(--thumbBG) var(--scrollbarBG); -} -::-webkit-scrollbar { - width: 8px; - height: 8px; - background-color: var(--scrollbarBG); -} -::-webkit-scrollbar-track { - background-color: var(--scrollbarBG); -} -::-webkit-scrollbar-thumb { - background-color: var(--thumbBG); - border-radius: 6px; - border: 3px solid var(--scrollbarBG); } + /* phones */ @media only screen and (max-width: 767px) { ::-webkit-scrollbar { @@ -101,65 +84,40 @@ Common animations } } -/* ---------------------------------------------- - * Generated by Animista on 2021-10-3 23:37:6 - * Licensed under FreeBSD License. - * See http://animista.net/license for more info. - * w: http://animista.net, t: @cssanimista - * ---------------------------------------------- */ - -/** - * ---------------------------------------- - * animation rotate-scale-up - * ---------------------------------------- - */ -.rotate-scale-up { - animation: rotate-scale-up 2.5s cubic-bezier(0.39, 0.575, 0.565, 1) infinite both; +.bounce-and-spin { + animation: bounce-and-spin 4s infinite; } -/* ---------------------------------------------- - * Generated by Animista on 2021-10-3 23:40:40 - * Licensed under FreeBSD License. - * See http://animista.net/license for more info. - * w: http://animista.net, t: @cssanimista - * ---------------------------------------------- */ - -/** - * ---------------------------------------- - * animation rotate-scale-up - * ---------------------------------------- - */ -/* ---------------------------------------------- - * Generated by Animista on 2021-10-3 23:45:10 - * Licensed under FreeBSD License. - * See http://animista.net/license for more info. - * w: http://animista.net, t: @cssanimista - * ---------------------------------------------- */ - -/** - * ---------------------------------------- - * animation rotate-scale-up - * ---------------------------------------- - */ -@keyframes rotate-scale-up { - 0% { - -webkit-transform: scale(1) rotateZ(0); - transform: scale(1) rotateZ(0); + +@keyframes bounce-and-spin { + 0%, + 100% { + translate: 0 0px 0; + animation-timing-function: cubic-bezier(0, 0, 0.2, 1); } - 25% { - -webkit-transform: scale(1.2) rotateZ(180deg); - transform: scale(1.2) rotateZ(180deg); + 0%, + 5% { + --webkit-transform: rotateX(0) rotateY(0deg) skewX(0) skewY(0); + transform: rotateX(0) rotateY(0deg) skewX(0) skewY(0); + opacity: 1; + animation-timing-function: ease-in; } 50% { - -webkit-transform: scale(1.2) rotateZ(180deg); - transform: scale(1.2) rotateZ(180deg); + translate: 0 -0.5rem 0; + opacity: 0.6; + animation-timing-function: cubic-bezier(0.8, 0, 1, 1); } - 75% { - -webkit-transform: scale(1) rotateZ(360deg); - transform: scale(1) rotateZ(360deg); + 50.1% { + --webkit-transform: rotateX(0) rotateY(-180deg) skewX(0) skewY(0); + transform: rotateX(0) rotateY(-180deg) skewX(0) skewY(0); + opacity: 0.6; + animation-timing-function: linear; } + 95%, 100% { - -webkit-transform: scale(1) rotateZ(360deg); - transform: scale(1) rotateZ(360deg); + --webkit-transform: rotateX(0) rotateY(-360deg) skewX(0) skewY(0); + transform: rotateX(0) rotateY(-360deg) skewX(0) skewY(0); + opacity: 1; + animation-timing-function: ease-out; } } diff --git a/src/utils/scroll.ts b/src/utils/scroll.ts new file mode 100644 index 0000000..6d9853a --- /dev/null +++ b/src/utils/scroll.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from 'react'; + +export function useScrollBelowListener(threshold: number) { + const [isScrolledBelow, setIsScrolledBelow] = useState(false); + useEffect(() => { + const listener = () => { + if (window.scrollY > threshold) setIsScrolledBelow(true); + else setIsScrolledBelow(false); + }; + window.addEventListener('scroll', listener); + return () => { + window.removeEventListener('scroll', listener); + }; + }, [threshold]); + return isScrolledBelow; +} diff --git a/src/utils/time.ts b/src/utils/time.ts index 343a188..12e78a4 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -9,3 +9,57 @@ export function areDatesSameDay(d1: Date, d2: Date) { export function getDaysBetween(timestamp1: number, timestamp2: number) { return Math.round((timestamp2 - timestamp1) / (1000 * 60 * 60 * 24)); } + +// Inspired by https://stackoverflow.com/questions/3177836/how-to-format-time-since-xxx-e-g-4-minutes-ago-similar-to-stack-exchange-site +export function getHumanReadableTimeString(timestamp: number) { + if (timestamp <= 0) return ''; + + const seconds = Math.floor((Date.now() - timestamp) / 1000); + + if (seconds <= 1) { + return 'Just now'; + } + if (seconds <= 60) { + return `${seconds} seconds ago`; + } + const minutes = Math.floor(seconds / 60); + if (minutes <= 1) { + return '1 minute ago'; + } + if (minutes < 60) { + return `${minutes} minutes ago`; + } + const hours = Math.floor(minutes / 60); + if (hours <= 1) { + return '1 hour ago'; + } + if (hours < 24) { + return `${hours} hours ago`; + } + + const date = new Date(timestamp); + return date.toLocaleDateString(); +} + +export function getHumanReadableDuration(ms: number, minSec?: number) { + let seconds = Math.round(ms / 1000); + + if (minSec) { + seconds = Math.max(seconds, minSec); + } + + if (seconds <= 60) { + return `${seconds} sec`; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes} min`; + } + const hours = Math.floor(minutes / 60); + return `${hours} hr`; +} + +export function getDateTimeString(timestamp: number) { + const date = new Date(timestamp); + return `${date.toLocaleTimeString()} ${date.toLocaleDateString()}`; +} diff --git a/yarn.lock b/yarn.lock index e6d3578..f0b8c16 100644 --- a/yarn.lock +++ b/yarn.lock @@ -492,7 +492,7 @@ __metadata: ts-node: "npm:^10.9.2" typescript: "npm:^5.3.3" viem: "npm:2.0.6" - wagmi: "npm:2.2.0" + wagmi: "npm:2.2.1" languageName: unknown linkType: soft @@ -2693,9 +2693,9 @@ __metadata: languageName: node linkType: hard -"@wagmi/connectors@npm:4.1.3": - version: 4.1.3 - resolution: "@wagmi/connectors@npm:4.1.3" +"@wagmi/connectors@npm:4.1.4": + version: 4.1.4 + resolution: "@wagmi/connectors@npm:4.1.4" dependencies: "@coinbase/wallet-sdk": "npm:3.9.1" "@metamask/sdk": "npm:0.14.1" @@ -2704,13 +2704,13 @@ __metadata: "@walletconnect/ethereum-provider": "npm:2.11.0" "@walletconnect/modal": "npm:2.6.2" peerDependencies: - "@wagmi/core": 2.2.0 + "@wagmi/core": 2.2.1 typescript: ">=5.0.4" viem: 2.x peerDependenciesMeta: typescript: optional: true - checksum: 0b56a3d55924802e50adc54e979c031d0d576a70cb17b7eb8a627e35f2f5b5de6d03536af50a4a8e7ea5f85364229c60a9e420aae662bdbc69b1378841db9f62 + checksum: 1d52d64ea41c242ffa055cb71e1eb054017be6c984c99b704c63993ecb4ae661995f90f7788bca7af7b3dfff4a1f5d6dbbfb34b288e699daf62b365c93fb8e67 languageName: node linkType: hard @@ -2737,9 +2737,9 @@ __metadata: languageName: node linkType: hard -"@wagmi/core@npm:2.2.0": - version: 2.2.0 - resolution: "@wagmi/core@npm:2.2.0" +"@wagmi/core@npm:2.2.1": + version: 2.2.1 + resolution: "@wagmi/core@npm:2.2.1" dependencies: eventemitter3: "npm:5.0.1" mipd: "npm:0.0.5" @@ -2753,7 +2753,7 @@ __metadata: optional: true typescript: optional: true - checksum: aa395b09464d5b946e17c811a37fdfa62c53ee6e3301d60af29b0df0d99358055c01087fc3b8fb0968a9dfe7ca2739d6577db97ec7a7928c54df1df07090c562 + checksum: 0e2da800a6922c0a4211f7958d4094fcab0db99251c7997febb0fa6a5be9af78492bac574b49047cfadb903f1a478ad9ee54de0325060d03e86cd64bf3bbcfa6 languageName: node linkType: hard @@ -10639,12 +10639,12 @@ __metadata: languageName: node linkType: hard -"wagmi@npm:2.2.0": - version: 2.2.0 - resolution: "wagmi@npm:2.2.0" +"wagmi@npm:2.2.1": + version: 2.2.1 + resolution: "wagmi@npm:2.2.1" dependencies: - "@wagmi/connectors": "npm:4.1.3" - "@wagmi/core": "npm:2.2.0" + "@wagmi/connectors": "npm:4.1.4" + "@wagmi/core": "npm:2.2.1" use-sync-external-store: "npm:1.2.0" peerDependencies: "@tanstack/react-query": ">=5.0.0" @@ -10654,7 +10654,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: e57e19e17142b09043160c9dffb095d43c437f4e1e9bd69a1dcba28f42e123577483200a8a22382bd16d1713492bb8ec727afab901e3843368a7ef0f8c70c062 + checksum: 82e0a07751f6e9d97b1474be6845aca3f707ed352eb09fb6099c73987385e7c8f415dbfa6a8bf017ca5d947acdf9a996208239649395592a204ef473ee986411 languageName: node linkType: hard