diff --git a/apps/dapp/abi/contracts/admin/VestingPayments.sol/VestingPayments.json b/apps/dapp/abi/contracts/admin/VestingPayments.sol/VestingPayments.json new file mode 100644 index 000000000..89ec298c9 --- /dev/null +++ b/apps/dapp/abi/contracts/admin/VestingPayments.sol/VestingPayments.json @@ -0,0 +1 @@ +[{"inputs":[{"internalType":"address","name":"_rescuer","type":"address"},{"internalType":"address","name":"_executor","type":"address"},{"internalType":"address","name":"_fundsOwner","type":"address"},{"internalType":"address","name":"_paymentToken","type":"address"}],"stateMutability":"nonpayable","type":"constructor"},{"inputs":[{"internalType":"address","name":"target","type":"address"}],"name":"AddressEmptyCode","type":"error"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"AddressInsufficientBalance","type":"error"},{"inputs":[],"name":"CannotRelease","type":"error"},{"inputs":[],"name":"ExpectedNonZero","type":"error"},{"inputs":[],"name":"FailedInnerCall","type":"error"},{"inputs":[],"name":"FullyVested","type":"error"},{"inputs":[],"name":"InvalidAccess","type":"error"},{"inputs":[],"name":"InvalidAddress","type":"error"},{"inputs":[],"name":"InvalidParam","type":"error"},{"inputs":[],"name":"InvalidScheduleId","type":"error"},{"inputs":[{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"Overflow","type":"error"},{"inputs":[{"internalType":"uint256","name":"x","type":"uint256"},{"internalType":"uint256","name":"y","type":"uint256"},{"internalType":"uint256","name":"denominator","type":"uint256"}],"name":"PRBMath_MulDiv_Overflow","type":"error"},{"inputs":[{"internalType":"address","name":"token","type":"address"}],"name":"SafeERC20FailedOperation","type":"error"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"account","type":"address"},{"indexed":true,"internalType":"bytes4","name":"fnSelector","type":"bytes4"},{"indexed":true,"internalType":"bool","name":"value","type":"bool"}],"name":"ExplicitAccessSet","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"fundsOwner","type":"address"}],"name":"FundsOwnerSet","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"oldExecutor","type":"address"},{"indexed":true,"internalType":"address","name":"newExecutor","type":"address"}],"name":"NewExecutorAccepted","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"oldExecutor","type":"address"},{"indexed":true,"internalType":"address","name":"oldProposedExecutor","type":"address"},{"indexed":true,"internalType":"address","name":"newProposedExecutor","type":"address"}],"name":"NewExecutorProposed","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"oldRescuer","type":"address"},{"indexed":true,"internalType":"address","name":"newRescuer","type":"address"}],"name":"NewRescuerAccepted","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"oldRescuer","type":"address"},{"indexed":true,"internalType":"address","name":"oldProposedRescuer","type":"address"},{"indexed":true,"internalType":"address","name":"newProposedRescuer","type":"address"}],"name":"NewRescuerProposed","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"_id","type":"bytes32"},{"indexed":true,"internalType":"address","name":"_oldRecipient","type":"address"},{"indexed":true,"internalType":"address","name":"_recipient","type":"address"}],"name":"RecipientChanged","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"_id","type":"bytes32"},{"indexed":true,"internalType":"address","name":"_recipient","type":"address"},{"indexed":false,"internalType":"uint256","name":"_amount","type":"uint256"}],"name":"Released","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bool","name":"value","type":"bool"}],"name":"RescueModeSet","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"_id","type":"bytes32"},{"indexed":true,"internalType":"address","name":"_recipient","type":"address"},{"indexed":false,"internalType":"uint256","name":"_unreleased","type":"uint256"},{"indexed":false,"internalType":"uint256","name":"_totalVested","type":"uint256"}],"name":"Revoked","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"bytes32","name":"_id","type":"bytes32"},{"indexed":true,"internalType":"address","name":"_recipient","type":"address"},{"indexed":false,"internalType":"uint40","name":"_start","type":"uint40"},{"indexed":false,"internalType":"uint40","name":"_cliff","type":"uint40"},{"indexed":false,"internalType":"uint40","name":"_duration","type":"uint40"},{"indexed":false,"internalType":"uint128","name":"_amount","type":"uint128"}],"name":"ScheduleCreated","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":true,"internalType":"address","name":"token","type":"address"},{"indexed":false,"internalType":"uint256","name":"amount","type":"uint256"}],"name":"TokenRecovered","type":"event"},{"inputs":[],"name":"acceptExecutor","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"acceptRescuer","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"holder","type":"address"}],"name":"computeNextVestingScheduleIdForHolder","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"holder","type":"address"},{"internalType":"uint256","name":"index","type":"uint256"}],"name":"computeVestingScheduleIdForAddressAndIndex","outputs":[{"internalType":"bytes32","name":"","type":"bytes32"}],"stateMutability":"pure","type":"function"},{"inputs":[{"components":[{"internalType":"uint40","name":"cliff","type":"uint40"},{"internalType":"uint40","name":"start","type":"uint40"},{"internalType":"uint40","name":"duration","type":"uint40"},{"internalType":"uint128","name":"amount","type":"uint128"},{"internalType":"bool","name":"revoked","type":"bool"},{"internalType":"uint128","name":"distributed","type":"uint128"},{"internalType":"uint128","name":"revokedReleasable","type":"uint128"},{"internalType":"address","name":"recipient","type":"address"}],"internalType":"struct IVestingPayments.VestingSchedule[]","name":"_schedules","type":"tuple[]"}],"name":"createSchedules","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"executor","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"},{"internalType":"bytes4","name":"","type":"bytes4"}],"name":"explicitFunctionAccess","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"fundsOwner","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"_recipient","type":"address"}],"name":"getLastVestingScheduleForHolder","outputs":[{"components":[{"internalType":"uint40","name":"cliff","type":"uint40"},{"internalType":"uint40","name":"start","type":"uint40"},{"internalType":"uint40","name":"duration","type":"uint40"},{"internalType":"uint128","name":"amount","type":"uint128"},{"internalType":"bool","name":"revoked","type":"bool"},{"internalType":"uint128","name":"distributed","type":"uint128"},{"internalType":"uint128","name":"revokedReleasable","type":"uint128"},{"internalType":"address","name":"recipient","type":"address"}],"internalType":"struct IVestingPayments.VestingSchedule","name":"schedule","type":"tuple"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32","name":"_vestingId","type":"bytes32"}],"name":"getReleasableAmount","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32","name":"_vestingId","type":"bytes32"}],"name":"getSchedule","outputs":[{"components":[{"internalType":"uint40","name":"cliff","type":"uint40"},{"internalType":"uint40","name":"start","type":"uint40"},{"internalType":"uint40","name":"duration","type":"uint40"},{"internalType":"uint128","name":"amount","type":"uint128"},{"internalType":"bool","name":"revoked","type":"bool"},{"internalType":"uint128","name":"distributed","type":"uint128"},{"internalType":"uint128","name":"revokedReleasable","type":"uint128"},{"internalType":"address","name":"recipient","type":"address"}],"internalType":"struct IVestingPayments.VestingSchedule","name":"schedule","type":"tuple"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32","name":"_vestingId","type":"bytes32"},{"internalType":"uint40","name":"_at","type":"uint40"}],"name":"getTotalVestedAt","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32","name":"_vestingId","type":"bytes32"}],"name":"getTotalVestedAtCurrentTime","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"uint256","name":"_index","type":"uint256"}],"name":"getVestingIdAtIndex","outputs":[{"internalType":"bytes32","name":"id","type":"bytes32"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"getVestingIds","outputs":[{"internalType":"bytes32[]","name":"ids","type":"bytes32[]"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"index","type":"uint256"}],"name":"getVestingScheduleByAddressAndIndex","outputs":[{"components":[{"internalType":"uint40","name":"cliff","type":"uint40"},{"internalType":"uint40","name":"start","type":"uint40"},{"internalType":"uint40","name":"duration","type":"uint40"},{"internalType":"uint128","name":"amount","type":"uint128"},{"internalType":"bool","name":"revoked","type":"bool"},{"internalType":"uint128","name":"distributed","type":"uint128"},{"internalType":"uint128","name":"revokedReleasable","type":"uint128"},{"internalType":"address","name":"recipient","type":"address"}],"internalType":"struct IVestingPayments.VestingSchedule","name":"","type":"tuple"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32[]","name":"_ids","type":"bytes32[]"}],"name":"getVestingSummary","outputs":[{"components":[{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"distributed","type":"uint256"},{"internalType":"uint256","name":"vested","type":"uint256"}],"internalType":"struct IVestingPayments.VestingSummary[]","name":"summary","type":"tuple[]"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"","type":"address"}],"name":"holdersVestingCount","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"inRescueMode","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32","name":"_id","type":"bytes32"}],"name":"isActiveVestingId","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32","name":"_vestingId","type":"bytes32"}],"name":"isVestingRevoked","outputs":[{"internalType":"bool","name":"","type":"bool"}],"stateMutability":"view","type":"function"},{"inputs":[],"name":"paymentToken","outputs":[{"internalType":"contract IERC20","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"proposeNewExecutor","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"proposeNewRescuer","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"_token","type":"address"},{"internalType":"address","name":"_to","type":"address"},{"internalType":"uint256","name":"_amount","type":"uint256"}],"name":"recoverToken","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32","name":"_vestingId","type":"bytes32"}],"name":"release","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"rescuer","outputs":[{"internalType":"address","name":"","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"bytes32","name":"_vestingId","type":"bytes32"}],"name":"revokeVesting","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bytes32","name":"id","type":"bytes32"}],"name":"schedules","outputs":[{"internalType":"uint40","name":"cliff","type":"uint40"},{"internalType":"uint40","name":"start","type":"uint40"},{"internalType":"uint40","name":"duration","type":"uint40"},{"internalType":"uint128","name":"amount","type":"uint128"},{"internalType":"bool","name":"revoked","type":"bool"},{"internalType":"uint128","name":"distributed","type":"uint128"},{"internalType":"uint128","name":"revokedReleasable","type":"uint128"},{"internalType":"address","name":"recipient","type":"address"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"address","name":"allowedCaller","type":"address"},{"components":[{"internalType":"bytes4","name":"fnSelector","type":"bytes4"},{"internalType":"bool","name":"allowed","type":"bool"}],"internalType":"struct ITempleElevatedAccess.ExplicitAccess[]","name":"access","type":"tuple[]"}],"name":"setExplicitAccess","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"address","name":"_fundsOwner","type":"address"}],"name":"setFundsOwner","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"bool","name":"value","type":"bool"}],"name":"setRescueMode","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"totalVestedAndUnclaimed","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"}] diff --git a/apps/dapp/src/assets/icons/cash.svg b/apps/dapp/src/assets/icons/cash.svg new file mode 100644 index 000000000..98597c61e --- /dev/null +++ b/apps/dapp/src/assets/icons/cash.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/dapp/src/assets/icons/down-arrow.svg b/apps/dapp/src/assets/icons/down-arrow.svg new file mode 100644 index 000000000..5bc4c7e14 --- /dev/null +++ b/apps/dapp/src/assets/icons/down-arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/dapp/src/assets/icons/dropdown-arrow.svg b/apps/dapp/src/assets/icons/dropdown-arrow.svg new file mode 100644 index 000000000..a06cf36e3 --- /dev/null +++ b/apps/dapp/src/assets/icons/dropdown-arrow.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/dapp/src/assets/icons/infocircle.svg b/apps/dapp/src/assets/icons/infocircle.svg new file mode 100644 index 000000000..4882d59d5 --- /dev/null +++ b/apps/dapp/src/assets/icons/infocircle.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/dapp/src/assets/icons/non-cash.svg b/apps/dapp/src/assets/icons/non-cash.svg new file mode 100644 index 000000000..0edb51839 --- /dev/null +++ b/apps/dapp/src/assets/icons/non-cash.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/dapp/src/assets/icons/order.svg b/apps/dapp/src/assets/icons/order.svg new file mode 100644 index 000000000..16c293fcd --- /dev/null +++ b/apps/dapp/src/assets/icons/order.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/dapp/src/assets/icons/search.svg b/apps/dapp/src/assets/icons/search.svg new file mode 100644 index 000000000..42d0dc6f6 --- /dev/null +++ b/apps/dapp/src/assets/icons/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/dapp/src/assets/icons/vesting.svg b/apps/dapp/src/assets/icons/vesting.svg new file mode 100644 index 000000000..cce97be54 --- /dev/null +++ b/apps/dapp/src/assets/icons/vesting.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/dapp/src/components/Layouts/V2Layout/Nav/NavLinks.tsx b/apps/dapp/src/components/Layouts/V2Layout/Nav/NavLinks.tsx index dba777f6b..58df4a691 100644 --- a/apps/dapp/src/components/Layouts/V2Layout/Nav/NavLinks.tsx +++ b/apps/dapp/src/components/Layouts/V2Layout/Nav/NavLinks.tsx @@ -108,6 +108,8 @@ const NavLinkText = styled.span` text-decoration: underline; } font-size: ${(props) => (props.small ? '0.75rem' : '1rem')}; + white-space: pre; + display: inline-block; `; const NavLinkCell = styled.div` diff --git a/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Earn/StakeTemple/Delegate.tsx b/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Earn/StakeTemple/Delegate.tsx index add1fd7f2..30d39a6cc 100644 --- a/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Earn/StakeTemple/Delegate.tsx +++ b/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Earn/StakeTemple/Delegate.tsx @@ -9,7 +9,6 @@ import { useSpiceBazaar } from 'providers/SpiceBazaarProvider'; import Loader from 'components/Loader/Loader'; import { TradeButton } from './Stake'; import { useNavigate } from 'react-router-dom'; -import { fromAtto } from 'utils/bigNumber'; export const Delegate = () => { const [inputValue, setInputValue] = useState(''); @@ -130,7 +129,7 @@ export const Delegate = () => { return ( - {!stakePageMetrics.data.yourStake.isZero() && ( + {stakePageMetrics.data.yourStake > 0 && ( <> {isLoading ? ( @@ -140,9 +139,7 @@ export const Delegate = () => { - {formatNumberWithCommas( - fromAtto(stakePageMetrics.data.yourStake) - )}{' '} + {formatNumberWithCommas(stakePageMetrics.data.yourStake)}{' '} STAKED TEMPLE @@ -229,10 +226,8 @@ export const Delegate = () => { - {formatNumberWithCommas( - fromAtto(stakePageMetrics.data.yourStake) - )}{' '} - STAKED TEMPLE + {formatNumberWithCommas(stakePageMetrics.data.yourStake)} STAKED + TEMPLE @@ -270,16 +265,14 @@ export const Delegate = () => { )} - {stakePageMetrics.data.yourStake.isZero() && ( + {stakePageMetrics.data.yourStake === 0 && ( <> - {formatNumberWithCommas( - fromAtto(stakePageMetrics.data.yourStake) - )}{' '} - STAKED TEMPLE + {formatNumberWithCommas(stakePageMetrics.data.yourStake)} STAKED + TEMPLE YOU CURRENTLY HAVE NO STAKED TEMPLE diff --git a/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Earn/StakeTemple/Unstake.tsx b/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Earn/StakeTemple/Unstake.tsx index a671e2dbf..4fc84bf48 100644 --- a/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Earn/StakeTemple/Unstake.tsx +++ b/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Earn/StakeTemple/Unstake.tsx @@ -13,8 +13,6 @@ import { useMediaQuery } from 'react-responsive'; import { queryPhone } from 'styles/breakpoints'; import Loader from 'components/Loader/Loader'; import { useUnstakeTime } from 'hooks/spicebazaar/use-unstake-time'; -import { formatEther } from 'ethers/lib/utils'; -import { fromAtto } from 'utils/bigNumber'; export const Unstake = () => { const isPhoneOrAbove = useMediaQuery({ @@ -34,10 +32,10 @@ export const Unstake = () => { }; const handleHintClick = () => { - // Use the BigNumber directly to avoid precision loss - const amount = stakePageMetrics.data.yourStake.isZero() - ? '' - : formatEther(stakePageMetrics.data.yourStake); + const amount = + stakePageMetrics.data.yourStake === 0 + ? '' + : String(stakePageMetrics.data.yourStake); setInputValue(amount); }; @@ -66,9 +64,7 @@ export const Unstake = () => { - {formatNumberWithCommas( - fromAtto(stakePageMetrics.data.yourStake) - )}{' '} + {formatNumberWithCommas(stakePageMetrics.data.yourStake)}{' '} TEMPLE STAKED @@ -84,7 +80,7 @@ export const Unstake = () => { value: TICKER_SYMBOL.TEMPLE_TOKEN, }} hint={`Max amount: ${formatNumberWithCommas( - fromAtto(stakePageMetrics.data.yourStake) + stakePageMetrics.data.yourStake )}`} handleChange={handleInputChange} onHintClick={handleHintClick} diff --git a/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Earn/StakeTemple/index.tsx b/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Earn/StakeTemple/index.tsx index 8438cfe7e..8d2945481 100644 --- a/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Earn/StakeTemple/index.tsx +++ b/apps/dapp/src/components/Pages/Core/DappPages/SpiceBazaar/Earn/StakeTemple/index.tsx @@ -9,7 +9,6 @@ import wallet from 'assets/icons/wallet.svg?react'; import { useSpiceBazaar } from 'providers/SpiceBazaarProvider'; import Loader from 'components/Loader/Loader'; import { formatNumberWithCommas } from 'utils/formatter'; -import { fromAtto } from 'utils/bigNumber'; import * as breakpoints from 'styles/breakpoints'; import { useMediaQuery } from 'react-responsive'; import { queryPhone } from 'styles/breakpoints'; @@ -63,7 +62,6 @@ export const StakeTemple = () => { const { data: stakePageMetricsData, loading: stakePageMetricsLoading, - error: stakePageMetricsError, fetch: fetchStakePageMetrics, } = stakePageMetrics; @@ -120,7 +118,7 @@ export const StakeTemple = () => { - {stakePageMetricsLoading || stakePageMetricsError ? ( + {stakePageMetricsLoading ? ( ) : ( <> @@ -135,7 +133,7 @@ export const StakeTemple = () => { )} - {stakePageMetricsLoading || stakePageMetricsError ? ( + {stakePageMetricsLoading ? ( ) : ( <> @@ -150,7 +148,7 @@ export const StakeTemple = () => { )} - {stakePageMetricsLoading || stakePageMetricsError ? ( + {stakePageMetricsLoading ? ( ) : ( <> @@ -167,23 +165,21 @@ export const StakeTemple = () => { - {stakePageMetricsLoading || stakePageMetricsError ? ( + {stakePageMetricsLoading ? ( ) : ( <> - {formatNumberWithCommas( - fromAtto(stakePageMetricsData.yourStake) - )} + {formatNumberWithCommas(stakePageMetricsData.yourStake)}  TEMPLE Your Stake{' '} - {!stakePageMetricsData.yourStake.isZero() && ( + {stakePageMetricsData.yourStake > 0 && ( <> ( {( - (fromAtto(stakePageMetricsData.yourStake) / + (stakePageMetricsData.yourStake / stakePageMetricsData.stakedTemple) * 100 ).toFixed(2)} @@ -210,7 +206,7 @@ export const StakeTemple = () => { )} </Box> <Box> - {stakePageMetricsLoading || stakePageMetricsError ? ( + {stakePageMetricsLoading ? ( <Loader iconSize={32} /> ) : ( <> diff --git a/apps/dapp/src/components/Pages/TeamPayments.tsx b/apps/dapp/src/components/Pages/TeamPayments/Cash.tsx similarity index 99% rename from apps/dapp/src/components/Pages/TeamPayments.tsx rename to apps/dapp/src/components/Pages/TeamPayments/Cash.tsx index 49cfeab80..27a386194 100644 --- a/apps/dapp/src/components/Pages/TeamPayments.tsx +++ b/apps/dapp/src/components/Pages/TeamPayments/Cash.tsx @@ -45,7 +45,7 @@ const reducerInitialState: ReducerState = { labelFixed: `COLLECT ${PAYMENT_TOKEN} FOR IMPACT`, }; -const TeamPayments = () => { +const TeamPaymentsCash = () => { const { collectingFixed, labelFixed, @@ -330,4 +330,4 @@ const EpochDropdownContainer = styled.div` width: 12rem; `; -export default TeamPayments; +export default TeamPaymentsCash; diff --git a/apps/dapp/src/components/Pages/TeamPayments/NonCash/Chart/Chart.tsx b/apps/dapp/src/components/Pages/TeamPayments/NonCash/Chart/Chart.tsx new file mode 100644 index 000000000..a327ec9bc --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/NonCash/Chart/Chart.tsx @@ -0,0 +1,181 @@ +import { useMemo, useState } from 'react'; +import styled, { useTheme } from 'styled-components'; +import LineChart from './LineChart'; +import { useVestingChart } from '../hooks/use-vesting-chart'; +import { useVestingMetrics } from '../hooks/use-vesting-metrics'; +import { useDummyVestingSchedules } from '../hooks/use-dummy-vesting-data'; +import Loader from 'components/Loader/Loader'; +import { Button } from 'components/Button/Button'; +import { getYAxisDomainAndTicks } from 'components/Pages/Core/DappPages/SpiceBazaar/components/GetYAxisDomainAndTicks'; + +// Additional colors for vests 3, 4, 5 +const ADDITIONAL_COLORS = ['#FF6B6B', '#4ECDC4', '#95E1D3']; + +export const Chart = () => { + const theme = useTheme(); + const [useDummyData, setUseDummyData] = useState(false); + + // Get schedules from either real or dummy source + const realSchedules = useVestingMetrics(); + const dummySchedules = useDummyVestingSchedules(); + + // Select which schedules to use + const selectedSchedules = useDummyData + ? dummySchedules.schedules + : realSchedules.schedules; + + // Process schedules into chart data + const { data, loading, error } = useVestingChart({ + schedules: selectedSchedules, + }); + + // Generate colors for each vest line + const colors = [theme.palette.brandLight, '#D0BE75', ...ADDITIONAL_COLORS]; + + // Dynamically generate lines based on data + const lines = useMemo(() => { + if (!data || data.length === 0) return []; + + // Get all vest keys (vest1, vest2, etc.) from first data point + const vestKeys = Object.keys(data[0]).filter((key) => + key.startsWith('vest') + ); + + return vestKeys.map((key, index) => ({ + series: key, + color: colors[index % colors.length], + })); + }, [data, colors]); + + // Calculate y-axis domain using standard utility + const { yDomain, yTicks } = useMemo(() => { + if (!data || data.length === 0) { + return { + yDomain: [0, 200] as [number, number], + yTicks: [0, 50, 100, 150, 200], + }; + } + + const values: number[] = []; + data.forEach((point) => { + Object.keys(point).forEach((key) => { + if (key !== 'month' && typeof point[key] === 'number') { + values.push(point[key] as number); + } + }); + }); + + return getYAxisDomainAndTicks(values); + }, [data]); + + if (loading) { + return ( + <PageContainer> + <LoaderContainer> + <Loader iconSize={48} /> + </LoaderContainer> + </PageContainer> + ); + } + + if (error || !data || data.length === 0) { + return ( + <PageContainer> + <ErrorMessage>{error || 'No vesting data available'}</ErrorMessage> + </PageContainer> + ); + } + + return ( + <PageContainer> + <ChartHeader> + <ToggleDataButton + onClick={() => setUseDummyData(!useDummyData)} + isActive={useDummyData} + > + {useDummyData ? 'Dummy Data' : 'Subgraph Data'} + </ToggleDataButton> + </ChartHeader> + <LineChart + chartData={data} + xDataKey="month" + lines={lines} + xTickFormatter={(val: string) => { + // Extract first letter of month (J, F, M, etc.) + const month = val.split(' ')[0]; + return month.charAt(0); + }} + yTickFormatter={(val: any) => { + // Format as "40 TGLD" or "0.5 M TGLD" depending on scale + if (val >= 1_000_000) { + const num = val / 1_000_000; + return `${num.toFixed(1)} M\nTGLD`; + } + return `${val.toLocaleString()}\nTGLD`; + }} + tooltipLabelFormatter={(month: string) => month} + tooltipValuesFormatter={(value: number, name: string) => { + // Convert vest1 -> Vest 1, vest2 -> Vest 2, etc. + const vestNum = name.replace('vest', ''); + const label = `Vest ${vestNum}`; + return [`${value.toLocaleString()}`, label]; + }} + legendFormatter={(value: any) => { + // Convert vest1 -> VEST 1, vest2 -> VEST 2, etc. + if (value.startsWith('vest')) { + const num = value.replace('vest', ''); + return `VEST ${num}`; + } + return value; + }} + yDomain={yDomain} + yTicks={yTicks} + /> + </PageContainer> + ); +}; + +const PageContainer = styled.div` + height: 100%; +`; + +const ChartHeader = styled.div` + display: flex; + justify-content: flex-end; + padding: 0 0 12px 0; +`; + +const ToggleDataButton = styled(Button)` + padding: 10px 20px; + width: 150px; + height: min-content; + background: ${({ theme }) => theme.palette.gradients.dark}; + border: ${({ disabled, theme }) => + disabled ? 'none' : `1px solid ${theme.palette.brandDark}`}; + box-shadow: ${({ disabled }) => + disabled ? 'none' : '0px 0px 20px 0px rgba(222, 92, 6, 0.4)'}; + border-radius: 10px; + font-weight: 700; + font-size: 12px; + line-height: 20px; + text-transform: uppercase; + color: ${({ theme }) => theme.palette.brandLight}; +`; + +const LoaderContainer = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-height: 300px; +`; + +const ErrorMessage = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 100%; + min-height: 300px; + color: ${({ theme }) => theme.palette.brand}; + font-size: 16px; +`; diff --git a/apps/dapp/src/components/Pages/TeamPayments/NonCash/Chart/LineChart.tsx b/apps/dapp/src/components/Pages/TeamPayments/NonCash/Chart/LineChart.tsx new file mode 100644 index 000000000..2c92f08d1 --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/NonCash/Chart/LineChart.tsx @@ -0,0 +1,318 @@ +import type { DataKey, AxisDomain } from 'recharts/types/util/types'; +import React, { useState } from 'react'; +import styled, { useTheme } from 'styled-components'; +import { + CartesianGrid, + ResponsiveContainer, + Line, + XAxis, + YAxis, + Tooltip, + Legend, + ComposedChart, +} from 'recharts'; +import { useMediaQuery } from 'react-responsive'; +import { queryPhone } from 'styles/breakpoints'; +import box from 'assets/icons/box.svg?react'; +import checkbox from 'assets/icons/checkmark-in-box.svg?react'; +import { createGlobalStyle } from 'styled-components'; + +export const GlobalChartStyles = createGlobalStyle` + .recharts-tooltip-cursor { + stroke: ${({ theme }) => theme.palette.brand}; + stroke-width: 2; + } +`; + +type LineChartProps<T> = { + chartData: T[]; + xDataKey: DataKey<keyof T>; + lines: { series: DataKey<keyof T>; color: string; hide?: boolean }[]; + xTickFormatter: (xValue: any, index: number) => string; + yTickFormatter?: (yValue: any, index: number) => string; + tooltipLabelFormatter: (value: any) => string; + tooltipValuesFormatter?: (value: number, name: string) => string[]; + legendFormatter?: (value: string) => string; + yDomain?: AxisDomain; + yTicks?: number[]; +}; + +export default function LineChart<T>( + props: React.PropsWithChildren<LineChartProps<T>> +) { + const { + chartData, + xDataKey, + lines, + xTickFormatter, + yTickFormatter, + tooltipLabelFormatter, + tooltipValuesFormatter, + legendFormatter, + yDomain, + yTicks, + } = props; + + const theme = useTheme(); + + const [hiddenLines, setHiddenLines] = useState( + Object.fromEntries(lines.map((l) => [l.series.toString(), l.hide || false])) + ); + + const toggleLineVisibility = (key: string) => { + const nextState = { + ...hiddenLines, + [key]: !hiddenLines[key], + }; + + const totalLines = Object.values(hiddenLines).length; + const hiddenCount = Object.values(nextState).filter(Boolean).length; + + if (totalLines - hiddenCount === 0) return; + setHiddenLines(nextState); + }; + + const isPhoneOrAbove = useMediaQuery({ query: queryPhone }); + + return ( + <ResponsiveContainer minHeight={200} minWidth={250} height={350}> + <ComposedChart + data={chartData} + margin={{ + left: isPhoneOrAbove ? 30 : 5, + top: 20, + right: 30, + bottom: 40, + }} + > + <CartesianGrid + horizontal={true} + vertical={false} + stroke={theme.palette.brandDarker} + /> + {lines.map((line) => ( + <Line + key={line.series.toString()} + type="linear" + dataKey={line.series} + stroke={line.color} + strokeWidth={2} + hide={hiddenLines[line.series.toString()]} + connectNulls={true} + dot={false} + activeDot={{ + r: 6, + fill: theme.palette.brand, + stroke: theme.palette.brand, + strokeWidth: 2, + }} + isAnimationActive={false} + /> + ))} + <XAxis + axisLine={false} + tickLine={false} + dataKey={xDataKey} + tickFormatter={xTickFormatter} + tick={{ + fill: theme.palette.brandLight, + style: { + fontFamily: 'Caviar Dreams', + fontSize: '12px', + fontWeight: '700', + lineHeight: '18px', + letterSpacing: '0.05em', + }, + }} + minTickGap={10} + tickMargin={isPhoneOrAbove ? 30 : 15} + padding={{ right: 20 }} + label={{ + value: 'Month', + position: 'insideBottom', + offset: -40, + style: { + fontFamily: 'Caviar Dreams', + fontSize: '12px', + fontWeight: '700', + fill: theme.palette.brandLight, + }, + }} + /> + <YAxis + axisLine={false} + tickLine={false} + tick={({ x, y, payload }) => { + const formatted = yTickFormatter + ? yTickFormatter(payload.value, payload.index) + : `${(payload.value / 1_000_000).toFixed(1)} M TGLD`; + + const lines = formatted.split('\n'); + return ( + <text + x={x} + y={y} + dy={10} + textAnchor={isPhoneOrAbove ? 'end' : 'middle'} + transform={isPhoneOrAbove ? '' : `rotate(-90, ${x}, ${y})`} + style={{ + fontSize: '12px', + fontWeight: '700', + lineHeight: '18px', + letterSpacing: '0.05em', + fill: theme.palette.brandLight, + fontFamily: 'Caviar Dreams', + }} + > + {lines.map((line, i) => ( + // eslint-disable-next-line react/no-array-index-key + <tspan key={i} x={x} dy={i === 0 ? 0 : 15}> + {line} + </tspan> + ))} + </text> + ); + }} + offset={10} + domain={yDomain} + ticks={yTicks} + tickMargin={20} + /> + <Tooltip + wrapperStyle={{ outline: 'none' }} + content={({ active, payload, label }) => { + if (!active || !payload || !payload.length) return null; + + // Calculate total from all vests + const total = payload.reduce( + (sum, p) => sum + (Number(p.value) || 0), + 0 + ); + + return ( + <div + style={{ + background: + 'linear-gradient(180deg, #353535 45.25%, #101010 87.55%)', + boxShadow: '3px 6px 5.5px 0px #00000080', + color: theme.palette.brand, + borderRadius: '15px', + padding: '1rem', + minWidth: '200px', + }} + > + <div + style={{ + fontWeight: 700, + fontSize: '14px', + color: theme.palette.brand, + marginBottom: '0.5rem', + }} + > + {tooltipLabelFormatter ? tooltipLabelFormatter(label) : label} + </div> + {payload.map((p) => { + const value = Number(p.value) || 0; + const [formattedValue, formattedLabel] = + tooltipValuesFormatter + ? tooltipValuesFormatter(value, p.dataKey as string) + : [value.toLocaleString(), p.dataKey]; + + return ( + <div + key={p.dataKey} + style={{ + background: 'transparent', + fontWeight: 700, + fontSize: '12px', + lineHeight: '18px', + color: theme.palette.brandLight, + }} + > + {formattedLabel}: {formattedValue} + </div> + ); + })} + <div + style={{ + background: 'transparent', + fontWeight: 700, + fontSize: '12px', + lineHeight: '18px', + color: theme.palette.brandLight, + marginTop: '0.5rem', + paddingTop: '0.5rem', + borderTop: `1px solid ${theme.palette.brand}`, + }} + > + Total TGLD Vested: {total.toLocaleString()} + </div> + </div> + ); + }} + /> + {legendFormatter && ( + <Legend + verticalAlign="top" + align="right" + content={({ payload }) => ( + <div + style={{ + display: 'flex', + justifyContent: 'flex-end', + gap: '1.5rem', + paddingBottom: '30px', + marginTop: '10px', + }} + > + {payload?.map((entry) => { + if (!entry.dataKey) return null; + + const key = entry.dataKey.toString(); + const isHidden = hiddenLines[key]; + const color = entry.color || theme.palette.brandLight; + const label = legendFormatter ? legendFormatter(key) : key; + + return ( + <div + key={key} + onClick={() => toggleLineVisibility(key)} + style={{ + display: 'flex', + alignItems: 'center', + gap: '0.5rem', + cursor: 'pointer', + userSelect: 'none', + }} + > + {isHidden ? <EmptyboxIcon /> : <CheckboxIcon />} + <span + style={{ + fontFamily: 'Caviar Dreams', + fontSize: '12px', + fontWeight: '700', + color, + }} + > + {label} + </span> + </div> + ); + })} + </div> + )} + /> + )} + </ComposedChart> + </ResponsiveContainer> + ); +} + +const EmptyboxIcon = styled(box)` + width: 18px; + height: 18px; +`; +const CheckboxIcon = styled(checkbox)` + width: 18px; + height: 18px; +`; diff --git a/apps/dapp/src/components/Pages/TeamPayments/NonCash/DataTables/ClaimHistoryDataTable.tsx b/apps/dapp/src/components/Pages/TeamPayments/NonCash/DataTables/ClaimHistoryDataTable.tsx new file mode 100644 index 000000000..672d9f6ac --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/NonCash/DataTables/ClaimHistoryDataTable.tsx @@ -0,0 +1,225 @@ +import env from 'constants/env'; +import React, { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import * as breakpoints from 'styles/breakpoints'; +import { ScrollBar } from 'components/Pages/Core/DappPages/SpiceBazaar/components/CustomScrollBar'; +import DownArrow from 'assets/icons/down-arrow.svg?react'; + +export type Transaction = { + grantDate: string; + claimedTgld: string; + transactionLink: string; + transactionHash: string; +}; + +type TableHeader = { name: string }; + +type TableProps = { + tableHeaders: TableHeader[]; + transactions: Transaction[]; + loading: boolean; + refetch?: () => void; + dataRefetching?: boolean; +}; + +export const DataTable: React.FC<TableProps> = ({ + tableHeaders, + transactions, + loading, +}) => { + const [filter, setFilter] = useState('Last 5 Shown'); + const [filteredTransactions, setFilteredTransactions] = + useState<Transaction[]>(transactions); + const filterOptions = ['Last 5 Shown', 'Show All']; + + useEffect(() => { + const sortedTransactions = [...transactions].sort( + (a, b) => Number(b.grantDate) - Number(a.grantDate) + ); + + if (filter === 'Last 5 Shown') { + setFilteredTransactions(sortedTransactions.slice(0, 5)); + } else { + setFilteredTransactions(sortedTransactions); + } + }, [filter, transactions]); + + return ( + <PageContainer> + <Header> + <Title>Claim History + + {filterOptions.map((option) => ( + setFilter(option)} + selected={filter === option} + > + {option} + + ))} + + + + + + + {tableHeaders.map((h) => ( + + {h.name === 'Grant Date' ? ( + + {h.name} + + + ) : ( + <>{h.name} + )} + + ))} + + + + {loading ? ( + + Loading... + + ) : filteredTransactions.length === 0 ? ( + + No data available + + ) : ( + filteredTransactions.map((transaction) => ( + + {transaction.grantDate} + {transaction.claimedTgld} + + + {transaction.transactionLink} + + + + )) + )} + + + + + ); +}; + +const PageContainer = styled.div` + display: flex; + flex-direction: column; + gap: 20px; +`; + +const Header = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + flex-direction: column; + gap: 10px; + + ${breakpoints.phoneAndAbove(` + flex-direction: row; + `)} +`; + +const Title = styled.h3` + color: ${({ theme }) => theme.palette.brandLight}; + font-size: 24px; + line-height: 44px; + margin: 0; +`; + +const FilterContainer = styled.div` + display: flex; + flex-direction: row; + gap: 20px; + padding-right: 16px; +`; + +const FilterButton = styled.button<{ selected: boolean }>` + font-size: 16px; + line-height: 19px; + background: none; + color: ${({ selected, theme }) => + selected ? theme.palette.brandLight : theme.palette.brand}; + border: none; + cursor: pointer; +`; + +const TableData = styled.table` + border-spacing: 10px + width: 100%; + border-collapse: collapse; + min-width: 500px; + width: 100%; +`; + +const HeaderRow = styled.tr` + border-bottom: 1px solid ${({ theme }) => theme.palette.brand}; +`; + +const TableHeader = styled.th` + font-size: 13px; + font-weight: 700; + line-height: 20px; + text-align: left; + color: ${({ theme }) => theme.palette.brand}; + position: sticky; + top: 0; + z-index: 1; + padding: 10px 16px; + + &:first-child { + padding: 10px 0px 10px 0px; + } + + &:last-child { + padding: 10px 0px 10px 16px; + } +`; + +const TableHeaderWithIcon = styled.div` + display: flex; + align-items: center; + gap: 5px; +`; + +const DataRow = styled.tr` + border-bottom: 1px solid ${({ theme }) => theme.palette.brand}; +`; + +const DataCell = styled.td` + font-size: 13px; + font-weight: 700; + line-height: 20px; + text-align: left; + width: 33%; + color: ${({ theme }) => theme.palette.brandLight}; + + a { + color: ${({ theme }) => theme.palette.brandLight}; + } + + a:hover { + color: ${({ theme }) => theme.palette.brand}; + } + + padding: 20px 16px; + + &:first-child { + padding: 20px 0px 20px 0px; + } + + &:last-child { + padding: 20px 0px 20px 16px; + } +`; + +const DownArrowIcon = styled(DownArrow)``; diff --git a/apps/dapp/src/components/Pages/TeamPayments/NonCash/DataTables/ClaimableDataTable.tsx b/apps/dapp/src/components/Pages/TeamPayments/NonCash/DataTables/ClaimableDataTable.tsx new file mode 100644 index 000000000..4cfa94d66 --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/NonCash/DataTables/ClaimableDataTable.tsx @@ -0,0 +1,278 @@ +import React, { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import { Button } from 'components/Button/Button'; +import * as breakpoints from 'styles/breakpoints'; +import { ScrollBar } from 'components/Pages/Core/DappPages/SpiceBazaar/components/CustomScrollBar'; +import DownArrow from 'assets/icons/down-arrow.svg?react'; + +export type Transaction = { + id: string; + grantStartDate: string; + grantEndDate: string; + cliff: string; + vestedAmount: number; + claimableAmount: number; + action: string; +}; + +type TableHeader = { name: string }; + +type TableProps = { + tableHeaders: TableHeader[]; + transactions: Transaction[]; + loading: boolean; + title: string; + refetch?: () => void; + dataRefetching?: boolean; + claimTgld: (transactionId: string) => Promise; +}; + +export const DataTable: React.FC = ({ + tableHeaders, + transactions, + loading, + title, + refetch, + claimTgld, +}) => { + const [filter, setFilter] = useState('Last 5 Shown'); + const [filteredTransactions, setFilteredTransactions] = + useState(transactions); + const filterOptions = ['Last 5 Shown', 'Show All']; + + useEffect(() => { + const sortedTransactions = [...transactions].sort((a, b) => { + // Sort by grant start date (most recent first) + const dateA = new Date(a.grantStartDate).getTime(); + const dateB = new Date(b.grantStartDate).getTime(); + return dateB - dateA; + }); + + if (filter === 'Last 5 Shown') { + setFilteredTransactions(sortedTransactions.slice(0, 5)); + } else { + setFilteredTransactions(sortedTransactions); + } + }, [filter, transactions]); + + return ( + <> + +
+ + {title} + + + {filterOptions.map((option) => ( + setFilter(option)} + selected={filter === option} + > + {option} + + ))} + +
+ + + + + {tableHeaders.map((h) => ( + + {h.name === 'Grant Start Date' || + h.name === 'Grant End Date' ? ( + + {h.name} + + + ) : ( + <>{h.name} + )} + + ))} + + + + {loading ? ( + + Loading... + + ) : filteredTransactions.length === 0 ? ( + + No data available + + ) : ( + filteredTransactions.map((transaction) => { + const shortenedId = + transaction.id.length > 16 + ? `${transaction.id.slice(0, 8)}...${transaction.id.slice( + -6 + )}` + : transaction.id; + + return ( + + {shortenedId} + {transaction.grantStartDate} + {transaction.grantEndDate} + {transaction.cliff} + {transaction.vestedAmount} + {transaction.claimableAmount} + + + {transaction.action === 'Claim' && ( + { + await claimTgld(transaction.id); + }} + > + Claim + + )} + + + + ); + }) + )} + + + +
+ + ); +}; + +const PageContainer = styled.div` + display: flex; + flex-direction: column; + gap: 20px; +`; + +const Header = styled.div` + display: flex; + align-items: center; + flex-direction: column; + gap: 10px; + + ${breakpoints.phoneAndAbove(` + flex-direction: row; + justify-content: space-between; + `)} +`; + +const HeaderLeft = styled.div` + display: flex; + flex-direction: column; + align-items: center; + + ${breakpoints.phoneAndAbove(` + flex-direction: row; + gap: 40px; + `)} +`; + +const Title = styled.h3` + color: ${({ theme }) => theme.palette.brandLight}; + font-size: 24px; + line-height: 44px; + margin: 0; +`; + +const FilterContainer = styled.div` + display: flex; + flex-direction: row; + gap: 20px; + padding-right: 16px; +`; + +const FilterButton = styled.button<{ selected: boolean }>` + font-size: 16px; + line-height: 19px; + background: none; + color: ${({ selected, theme }) => + selected ? theme.palette.brandLight : theme.palette.brand}; + border: none; + cursor: pointer; +`; + +const TableData = styled.table` + border-spacing: 10px; + min-width: 800px; + border-collapse: collapse; + width: 100%; +`; + +const HeaderRow = styled.tr` + border-bottom: 1px solid ${({ theme }) => theme.palette.brand}; +`; + +const TableHeader = styled.th<{ name: string }>` + padding: 10px 25px; + font-size: 13px; + font-weight: 700; + line-height: 20px; + text-align: left; + white-space: nowrap; + color: ${({ theme }) => theme.palette.brand}; + position: sticky; + top: 0; + z-index: 1; + + &:first-child { + padding: 10px 25px 10px 0px; + } +`; + +const TableHeaderWithIcon = styled.div` + display: flex; + align-items: center; + gap: 5px; +`; + +const DataRow = styled.tr` + border-bottom: 1px solid ${({ theme }) => theme.palette.brand}; +`; + +const DataCell = styled.td` + font-size: 13px; + font-weight: 700; + line-height: 20px; + text-align: left; + white-space: nowrap; + color: ${({ theme }) => theme.palette.brandLight}; + padding: 20px 25px; + + &:first-child { + padding: 20px 25px 20px 0px; + } +`; + +const ButtonContainer = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; + width: 100%; +`; + +const TradeButton = styled(Button)` + padding: 10px 20px; + width: ${(props) => props.width || '120px'}; + height: min-content; + background: ${({ theme }) => theme.palette.gradients.dark}; + border: ${({ disabled, theme }) => + disabled ? 'none' : `1px solid ${theme.palette.brandDark}`}; + box-shadow: ${({ disabled }) => + disabled ? 'none' : '0px 0px 20px 0px rgba(222, 92, 6, 0.4)'}; + border-radius: 10px; + font-weight: 700; + font-size: 12px; + line-height: 20px; + text-transform: uppercase; + color: ${({ theme }) => theme.palette.brandLight}; +`; + +const DownArrowIcon = styled(DownArrow)``; diff --git a/apps/dapp/src/components/Pages/TeamPayments/NonCash/IntroConnected.tsx b/apps/dapp/src/components/Pages/TeamPayments/NonCash/IntroConnected.tsx new file mode 100644 index 000000000..881021e14 --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/NonCash/IntroConnected.tsx @@ -0,0 +1,196 @@ +import { useState } from 'react'; +import styled from 'styled-components'; +import { Chart } from 'components/Pages/TeamPayments/NonCash/Chart/Chart'; +import { ClaimableTGLD } from 'components/Pages/TeamPayments/NonCash/Tables/ClaimableTable'; +import { ClaimHistory } from 'components/Pages/TeamPayments/NonCash/Tables/ClaimHistoryTable'; +import { GlobalChartStyles } from 'components/Pages/TeamPayments/NonCash/Chart/LineChart'; +import { useVestingMetrics } from 'components/Pages/TeamPayments/NonCash/hooks/use-vesting-metrics'; +import Loader from 'components/Loader/Loader'; +import InfoCircle from 'assets/icons/infocircle.svg?react'; + +export default function IntroConnected() { + const [hasClaimed, setHasClaimed] = useState(false); + const [active, setActive] = useState<'current' | 'previous'>('current'); + + // Fetch all vesting data from subgraph + const { schedules, totalAllocated, totalVested, totalReleased, loading } = + useVestingMetrics(); + + // Calculate metrics from subgraph data + const totalUnvested = Math.max(0, totalAllocated - totalVested); + const totalClaimable = Math.max(0, totalVested - totalReleased); + + return ( + + Non-Cash Compensation + {/* Disabled Filter for now */} + {/* + setActive("current")}> + Current + + setActive("previous")}> + Previous + + */} + + + + {loading ? ( + + ) : ( + <> + {totalAllocated.toLocaleString()} + + Total TGLD Reward + <InfoIcon /> + + + )} + + + {loading ? ( + + ) : ( + <> + {totalUnvested.toLocaleString()} + Unvested TGLD + + )} + + + + + {loading ? ( + + ) : ( + <> + {totalVested.toLocaleString()} + Vested TGLD + + )} + + setHasClaimed(!hasClaimed)}> + {loading ? ( + + ) : ( + <> + {hasClaimed ? '0' : totalClaimable.toLocaleString()} + + Unclaimed TGLD + <InfoIcon /> + + + )} + + + + + + Your Vests + + + {totalClaimable > 0 && } + + + ); +} + +const PageContainer = styled.div` + margin-top: -40px; + display: flex; + flex-direction: column; + gap: 40px; + padding: 40px 0px 0px 0px; +`; + +const HeaderTitle = styled.h2` + display: flex; + align-items: center; + text-align: center; + color: ${({ theme }) => theme.palette.brandLight}; + gap: 15px; + margin: 0px; + font-size: 36px; +`; + +const StatusContainer = styled.div` + display: flex; + flex-direction: column; + gap: 20px; +`; + +const BoxContainer = styled.div` + display: flex; + flex-direction: row; + gap: 20px; +`; + +const Box = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex-grow: 1; + flex-basis: 0; + min-height: 136px; + gap: 12px; + padding: 10px; + border: 1px solid ${({ theme }) => theme.palette.brand}; + border-radius: 10px; + background: linear-gradient(to bottom, #0b0a0a, #1d1a1a); +`; + +const Title = styled.div` + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + font-size: 16px; + line-height: 19px; + color: ${({ theme }) => theme.palette.brand}; +`; + +const Sum = styled.div` + font-size: 24px; + font-weight: 700; + line-height: 29px; + color: ${({ theme }) => theme.palette.brandLight}; +`; + +const ChartContainer = styled.div` + display: flex; + flex-direction: column; +`; + +const ChartTitle = styled.div` + color: ${({ theme }) => theme.palette.brandLight}; + font-size: 24px; +`; + +const InfoIcon = styled(InfoCircle)` + width: 24px; + height: 24px; +`; + +const Filter = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 40px; + padding: 0px 20px; +`; + +const ToggleButton = styled.button<{ active: boolean }>` + background: none; + border: none; + color: ${({ active, theme }) => + active ? theme.palette.brandLight : theme.palette.brand}; + text-decoration: ${({ active }) => (active ? 'underline' : 'none')}; + text-decoration-color: ${({ theme }) => theme.palette.brandLight}; + font-size: 16px; + line-height: 19px; + cursor: pointer; + padding: 0; + outline: none; +`; diff --git a/apps/dapp/src/components/Pages/TeamPayments/NonCash/IntroNotConnected.tsx b/apps/dapp/src/components/Pages/TeamPayments/NonCash/IntroNotConnected.tsx new file mode 100644 index 000000000..0f1f9902d --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/NonCash/IntroNotConnected.tsx @@ -0,0 +1,151 @@ +import React, { useState, useRef } from 'react'; +import styled from 'styled-components'; +import Image from 'components/Image/Image'; +import { Flex } from 'components/Layout/Flex'; +import eyeImage from 'assets/images/no-pupil-eye.png'; +import { Button } from 'components/Button/Button'; +import wallet from 'assets/icons/wallet.svg?react'; +import { useConnectWallet } from '@web3-onboard/react'; + +export default function IntroNotConnected() { + const [cursorCoords, setCursorCoords] = useState([0, 0]); + const imageRef = useRef(null); + const [{}, connect] = useConnectWallet(); + + const pupilStyle = { + transform: getPupilTransform(imageRef, cursorCoords), + }; + + function getPupilTransform( + imageRef: React.RefObject, + cursorCoords: number[] + ) { + const headerHeight = 80; + + if (imageRef.current) { + const x = 0 - window.innerWidth / 2 + cursorCoords[0]; + const y = + 0 - + window.innerHeight / 2 + + imageRef.current.offsetTop + + cursorCoords[1] - + headerHeight; + + const values = normalizeTransform(x, y); + return `translate(${values[0]}px, ${values[1]}px)`; + } + } + + function normalizeTransform(x: number, y: number) { + if (Math.abs(x) + Math.abs(y) <= 10) { + return [x, y]; + } + + const multipleOfLength = (Math.abs(x) + Math.abs(y)) / 10; + + return [x / multipleOfLength, y / multipleOfLength]; + } + + return ( + setCursorCoords([e.clientX, e.clientY])}> + + + Templar, you are seen. +
+
+ With each stone you lay the Temple stands taller. +
+
+ Now the Temple gives back to you. +
+ +
+ + + + +
+
+ { + connect(); + }} + style={{ margin: 'auto', whiteSpace: 'nowrap' }} + > + + Connect Wallet + +
+ ); +} + +const PageContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 40px; +`; + +const Copy = styled.p` + text-align: center; + margin-bottom: 3rem; +`; + +const EyeArea = styled(Flex)` + width: 18.75rem; + height: 18.75rem; +`; + +const Pupil = styled.div` + position: absolute; + height: 1rem; + width: 1rem; + background-color: ${({ theme }) => theme.palette.brand}; + border-radius: 1rem; +`; + +const WalletIcon = styled(wallet)` + min-width: 24px; + min-height: 24px; +`; + +const TradeButton = styled(Button)` + padding: 12px 20px 12px 20px; + width: ${(props) => props.width || 'min-content'}; + height: min-content; + background: linear-gradient(90deg, #58321a 20%, #95613f 84.5%); + border: 1px solid ${({ theme }) => theme.palette.brandDark}; + box-shadow: 0px 0px 20px 0px #de5c0666; + border-radius: 10px; + font-size: 16px; + line-height: 20px; + font-weight: 700; + text-transform: uppercase; + color: ${({ theme }) => theme.palette.brandLight}; + + /* Flex settings for centering button content */ + display: flex; + align-items: center; + justify-content: center; + + /* Flex settings for the span inside the button */ + & > span { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + } +`; diff --git a/apps/dapp/src/components/Pages/TeamPayments/NonCash/NonCash.tsx b/apps/dapp/src/components/Pages/TeamPayments/NonCash/NonCash.tsx new file mode 100644 index 000000000..22b7af88f --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/NonCash/NonCash.tsx @@ -0,0 +1,8 @@ +import IntroNotConnected from './IntroNotConnected'; +import { useWallet } from 'providers/WalletProvider'; +import IntroConnected from './IntroConnected'; +export default function TeamPaymentsNonCash() { + const { wallet } = useWallet(); + + return <>{wallet ? : }; +} diff --git a/apps/dapp/src/components/Pages/TeamPayments/NonCash/Tables/ClaimHistoryTable.tsx b/apps/dapp/src/components/Pages/TeamPayments/NonCash/Tables/ClaimHistoryTable.tsx new file mode 100644 index 000000000..c144735ce --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/NonCash/Tables/ClaimHistoryTable.tsx @@ -0,0 +1,34 @@ +import styled from 'styled-components'; +import { DataTable } from '../DataTables/ClaimHistoryDataTable'; +import { useClaimHistory } from '../hooks/use-claim-history'; + +enum TableHeaders { + GrantDate = 'Grant Date', + ClaimedTgld = 'Claimed TGLD', + TransactionLink = 'Transaction Link', +} + +const tableHeaders = [ + { name: TableHeaders.GrantDate }, + { name: TableHeaders.ClaimedTgld }, + { name: TableHeaders.TransactionLink }, +]; + +export const ClaimHistory = () => { + const { data } = useClaimHistory(); + + return ( + + + + ); +}; + +const AuctionsHistoryContainer = styled.div` + display: flex; + flex-direction: column; +`; diff --git a/apps/dapp/src/components/Pages/TeamPayments/NonCash/Tables/ClaimableTable.tsx b/apps/dapp/src/components/Pages/TeamPayments/NonCash/Tables/ClaimableTable.tsx new file mode 100644 index 000000000..4a3aa4876 --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/NonCash/Tables/ClaimableTable.tsx @@ -0,0 +1,44 @@ +import styled from 'styled-components'; +import { DataTable } from '../DataTables/ClaimableDataTable'; +import { useClaimableTGLD } from '../hooks/use-claimable-tgld'; + +enum TableHeaders { + Id = 'ID', + GrantStartDate = 'Grant Start Date', + GrantEndDate = 'Grant End Date', + Cliff = 'Cliff', + VestedAmount = 'Vested Amount', + ClaimableAmount = 'Claimable Amount', + Action = '', +} + +const tableHeaders = [ + { name: TableHeaders.Id }, + { name: TableHeaders.GrantStartDate }, + { name: TableHeaders.GrantEndDate }, + { name: TableHeaders.Cliff }, + { name: TableHeaders.VestedAmount }, + { name: TableHeaders.ClaimableAmount }, + { name: TableHeaders.Action }, +]; + +export const ClaimableTGLD = () => { + const { data, loading, claimTgld } = useClaimableTGLD(); + + return ( + + + + ); +}; + +const AuctionsHistoryContainer = styled.div` + display: flex; + flex-direction: column; +`; diff --git a/apps/dapp/src/components/Pages/TeamPayments/NonCash/hooks/use-claim-history.ts b/apps/dapp/src/components/Pages/TeamPayments/NonCash/hooks/use-claim-history.ts new file mode 100644 index 000000000..036c28faf --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/NonCash/hooks/use-claim-history.ts @@ -0,0 +1,139 @@ +import { useMemo } from 'react'; +import { useWallet } from 'providers/WalletProvider'; +import { getAppConfig } from 'constants/newenv'; +import { + userReleaseTransactions, + useCachedSubgraphQuery, +} from 'utils/subgraph'; +import type { Transaction } from '../DataTables/ClaimHistoryDataTable'; + +// --- Constants --- +const HASH_PREFIX_LENGTH = 16; +const HASH_SUFFIX_LENGTH = 8; +const DEFAULT_LOCALE = 'en-US'; + +type UseClaimHistoryReturn = { + data: Transaction[] | null; + loading: boolean; + error: string | null; + refetch: () => void; +}; + +// --- Pure helpers (no side-effects) ---- +function shortenTxnHash(hash: string): string { + if (!hash || hash.length < HASH_PREFIX_LENGTH + HASH_SUFFIX_LENGTH) + return hash; + return `${hash.slice(0, HASH_PREFIX_LENGTH)}...${hash.slice( + -HASH_SUFFIX_LENGTH + )}`; +} + +function formatDate(tsSec: string | number, locale = DEFAULT_LOCALE): string { + const n = typeof tsSec === 'string' ? Number(tsSec) : tsSec; + if (!Number.isFinite(n)) return '-'; + return new Date(n * 1000).toLocaleDateString(locale, { + month: 'short', + year: 'numeric', + }); +} + +function toNumber(value: string | undefined | null): number { + if (!value) return 0; + const v = value.trim(); + try { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + } catch { + return 0; + } +} + +function isValidReleaseTransaction(tx: any): tx is { + id: string; + timestamp: string; + name: string; + hash: string; + releasedAmount: string; + schedule: { start: string }; +} { + return !!( + tx && + typeof tx.id === 'string' && + tx.id.length > 0 && + typeof tx.timestamp === 'string' && + tx.timestamp.length > 0 && + typeof tx.name === 'string' && + tx.name.length > 0 && + typeof tx.hash === 'string' && + tx.hash.length > 0 && + typeof tx.releasedAmount === 'string' && + tx.releasedAmount.length > 0 && + tx.schedule && + typeof tx.schedule.start === 'string' && + tx.schedule.start.length > 0 + ); +} + +/** + * Custom hook to fetch and manage user claim history + * Fetches release transactions from the vesting subgraph + * + * @returns Claim history transactions, loading state, and refetch function + */ +export const useClaimHistory = (): UseClaimHistoryReturn => { + const { wallet } = useWallet(); + const subgraphUrl = getAppConfig().vesting.subgraphUrl; + + const normalizedWallet = wallet?.toLowerCase() || ''; + + const { + data: response, + isLoading: loading, + error, + refetch, + } = useCachedSubgraphQuery( + subgraphUrl, + userReleaseTransactions(normalizedWallet), + [normalizedWallet], + { enabled: Boolean(wallet) } + ); + + const data = useMemo((): Transaction[] | null => { + if (!wallet) { + return null; // null = unknown/not loaded + } + + // Early exit for no transactions + if (!response?.user?.transactions?.length) { + return []; // [] = loaded/empty + } + + // Filter, map, and sort transactions (newest first for stable UI) + const transactions: Transaction[] = response.user.transactions + .filter(isValidReleaseTransaction) + .map((tx) => { + const amount = toNumber(tx.releasedAmount); + return { + grantDate: formatDate(tx.schedule.start), + claimedTgld: amount.toLocaleString(DEFAULT_LOCALE), + transactionLink: shortenTxnHash(tx.hash), + transactionHash: tx.hash, + }; + }) + .sort((a, b) => { + // Sort by grant date descending (newest first) + return ( + new Date(b.grantDate).getTime() - new Date(a.grantDate).getTime() + ); + }); + + return transactions; + }, [wallet, response]); + + return { + data, + loading: wallet ? loading : false, + error: error ? 'Failed to fetch claim history.' : null, + refetch: () => refetch(), + }; +}; diff --git a/apps/dapp/src/components/Pages/TeamPayments/NonCash/hooks/use-claimable-tgld.ts b/apps/dapp/src/components/Pages/TeamPayments/NonCash/hooks/use-claimable-tgld.ts new file mode 100644 index 000000000..122aae42d --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/NonCash/hooks/use-claimable-tgld.ts @@ -0,0 +1,213 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useWallet } from 'providers/WalletProvider'; +import { useNotification } from 'providers/NotificationProvider'; +import { useApiManager } from 'hooks/use-api-manager'; +import { getAppConfig } from 'constants/newenv'; +import { fromAtto } from 'utils/bigNumber'; +import { VestingPayments } from 'types/typechain'; +import { useVestingMetrics } from './use-vesting-metrics'; +import type { Transaction } from '../DataTables/ClaimableDataTable'; + +const ENV = import.meta.env; + +const getChainId = () => { + if (ENV.VITE_ENV === 'production') { + return 1; + } else if (ENV.VITE_ENV === 'preview') { + return 11155111; + } else { + throw new Error('Invalid environment'); + } +}; + +export type UseClaimableTGLDReturn = { + data: Transaction[] | null; + loading: boolean; + error: string | null; + refetch: () => void; + claimTgld: (transactionId: string) => Promise; +}; + +// ---- Constants ---- +const SECONDS_PER_DAY = 86400; +const DAYS_PER_MONTH = 30; +const DEFAULT_LOCALE = 'en-US'; + +// ---- Pure helpers (no side-effects) ---- +function formatDate(tsSec: string | number, locale = DEFAULT_LOCALE): string { + const n = typeof tsSec === 'string' ? Number(tsSec) : tsSec; + if (!Number.isFinite(n)) return '-'; + return new Date(n * 1000).toLocaleDateString(locale, { + month: 'short', + year: 'numeric', + }); +} + +function formatCliffDuration(startSec: string, cliffSec: string): string { + const s = Number(startSec); + const c = Number(cliffSec); + if (!Number.isFinite(s) || !Number.isFinite(c) || c <= s) return '0 days'; + const seconds = c - s; + const days = Math.floor(seconds / SECONDS_PER_DAY); + const months = Math.floor(days / DAYS_PER_MONTH); + return months > 0 + ? `${months} month${months > 1 ? 's' : ''}` + : `${days} day${days > 1 ? 's' : ''}`; +} + +async function fetchScheduleData( + schedule: any, + vestingContract: VestingPayments +): Promise { + const vestingId = schedule.id; + + // Query vested & claimable in parallel + const [vestedAmount, claimableAmount] = await Promise.all([ + vestingContract.getTotalVestedAtCurrentTime(vestingId), + vestingContract.getReleasableAmount(vestingId), + ]); + + if (!claimableAmount || claimableAmount.lte(0)) return null; + + const start = Number(schedule.start); + const end = start + Number(schedule.duration); + + return { + id: vestingId, + grantStartDate: formatDate(schedule.start), + grantEndDate: formatDate(end), + cliff: formatCliffDuration(schedule.start, schedule.cliff), + vestedAmount: fromAtto(vestedAmount), + claimableAmount: fromAtto(claimableAmount), + action: 'Claim', + }; +} + +export const useClaimableTGLD = (): UseClaimableTGLDReturn => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const { wallet, getConnectedSigner, switchNetwork } = useWallet(); + const { openNotification } = useNotification(); + const { papi } = useApiManager(); + + const { schedules } = useVestingMetrics(); + + // request guard to avoid setState on stale results + const reqIdRef = useRef(0); + + // Remove unnecessary useMemo - getAppConfig is already memoized + const vestingConfig = getAppConfig().contracts.vestingPayments; + + const fetchData = useCallback(async () => { + const myReqId = ++reqIdRef.current; + + // Early returns for edge cases + if (!wallet) { + setError(null); + setData(Array.isArray(schedules) ? [] : null); + return; + } + + if (!schedules) { + setError(null); + setData(null); + return; + } + + setLoading(true); + setError(null); + + try { + const vestingContract = (await papi.getContract( + vestingConfig + )) as VestingPayments; + + // Build parallel calls per schedule with fallback handling + const results = await Promise.allSettled( + schedules.map((schedule) => + fetchScheduleData(schedule, vestingContract) + ) + ); + + // If this request is stale, ignore everything + if (myReqId !== reqIdRef.current) return; + + // Collect successful, non-null items using flatMap + const transactions = results + .filter( + (r): r is PromiseFulfilledResult => + r.status === 'fulfilled' && r.value !== null + ) + .map((r) => r.value); + + setData(transactions); + setError(null); + } catch (e) { + if (myReqId !== reqIdRef.current) return; + console.error('Error fetching claimable TGLD:', e); + setError('Failed to fetch claimable TGLD.'); + } finally { + if (myReqId === reqIdRef.current) setLoading(false); + } + }, [wallet, schedules, papi, vestingConfig]); + + const claimTgld = useCallback( + async (vestingId: string) => { + try { + await switchNetwork(getChainId()); + const connectedSigner = await getConnectedSigner(); + const vestingContract = (await papi.getConnectedContract( + vestingConfig, + connectedSigner + )) as VestingPayments; + + const tx = await vestingContract.release(vestingId); + const receipt = await tx.wait(); + + openNotification({ + title: 'Claimed TGLD', + hash: receipt.transactionHash, + }); + + // Optimistic update: remove the claimed item immediately + setData((prev) => + prev ? prev.filter((t) => t.id !== vestingId) : prev + ); + + // Re-sync with chain/subgraph + await fetchData(); + } catch (err: any) { + console.error('Error claiming TGLD:', err); + const errorMessage = + err?.reason || err?.message || 'Failed to claim TGLD.'; + setError(errorMessage); + openNotification({ + title: 'Error claiming TGLD', + hash: '', + }); + } + }, + [ + papi, + getConnectedSigner, + switchNetwork, + openNotification, + fetchData, + vestingConfig, + ] + ); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { + data, + loading, + error, + refetch: fetchData, + claimTgld, + }; +}; diff --git a/apps/dapp/src/components/Pages/TeamPayments/NonCash/hooks/use-dummy-vesting-data.ts b/apps/dapp/src/components/Pages/TeamPayments/NonCash/hooks/use-dummy-vesting-data.ts new file mode 100644 index 000000000..6bdd5fbfb --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/NonCash/hooks/use-dummy-vesting-data.ts @@ -0,0 +1,129 @@ +/** + * Dummy vesting schedules for testing chart visuals + * Mimics the structure from the subgraph query + */ + +// --- Constants --- +const MS_TO_SECONDS = 1000; +const SECONDS_PER_DAY = 24 * 60 * 60; +const SECONDS_PER_MONTH = 30 * SECONDS_PER_DAY; + +export type Schedule = { + id: string; + start: string; // timestamp in seconds + cliff: string; // timestamp in seconds + duration: string; // duration in seconds + vested: string; // total amount allocated (in token units) + released: string; // amount already claimed + revoked: boolean; +}; + +// --- Helpers --- +function createTimestamp(date: Date): string { + return Math.floor(date.getTime() / MS_TO_SECONDS).toString(); +} + +function addMonthsToDate(date: Date, months: number): Date { + const result = new Date(date); + result.setMonth(result.getMonth() + months); + return result; +} + +function createDummySchedule( + id: string, + startDate: Date, + cliffMonths: number, + durationMonths: number, + amount: number, + released = 0 +): Schedule { + const cliffDate = addMonthsToDate(startDate, cliffMonths); + return { + id, + start: createTimestamp(startDate), + cliff: createTimestamp(cliffDate), + duration: (durationMonths * SECONDS_PER_MONTH).toString(), + vested: amount.toString(), + released: released.toString(), + revoked: false, + }; +} + +// --- Dummy Data --- +// Create realistic vesting schedules with different scenarios +const now = new Date(); +const baseDate = new Date(now.getFullYear(), now.getMonth(), 15); // 15th of current month + +export const DUMMY_SCHEDULES: Schedule[] = [ + // Schedule 1: Immediate vesting (no cliff) + createDummySchedule( + '0xdummy0000000000000000000000000000000000000000000000000000000001', + baseDate, + 0, // no cliff + 8, // 8 months duration + 40000, // 40k TGLD + 0 + ), + + // Schedule 2: 1-month cliff + createDummySchedule( + '0xdummy0000000000000000000000000000000000000000000000000000000002', + addMonthsToDate(baseDate, 1), // Feb + 1, // 1-month cliff + 7, // 7 months duration + 35000, // 35k TGLD + 0 + ), + + // Schedule 3: 2-month cliff with some released + createDummySchedule( + '0xdummy0000000000000000000000000000000000000000000000000000000003', + addMonthsToDate(baseDate, 2), // Mar + 2, // 2-month cliff + 6, // 6 months duration + 30000, // 30k TGLD + 5000 // 5k already released + ), + + // Schedule 4: 3-month cliff + createDummySchedule( + '0xdummy0000000000000000000000000000000000000000000000000000000004', + addMonthsToDate(baseDate, 3), // Apr + 3, // 3-month cliff + 5, // 5 months duration + 25000, // 25k TGLD + 0 + ), +]; + +/** + * Custom hook to provide dummy vesting data for testing + * Returns schedules and calculated metrics matching the real data structure + * + * @returns Dummy vesting schedules, metrics, and loading state + */ +export const useDummyVestingSchedules = () => { + // Calculate totals from dummy schedules + const totalAllocated = DUMMY_SCHEDULES.reduce( + (sum, s) => sum + parseFloat(s.vested), + 0 + ); + + const totalReleased = DUMMY_SCHEDULES.reduce( + (sum, s) => sum + parseFloat(s.released), + 0 + ); + + // For demo purposes, assume all allocated amounts are vested + // In real scenarios, this would be calculated based on current time vs vesting schedule + const totalVested = totalAllocated; + + return { + schedules: DUMMY_SCHEDULES, + totalAllocated, + totalVested, + totalReleased, + loading: false, + error: null, + }; +}; diff --git a/apps/dapp/src/components/Pages/TeamPayments/NonCash/hooks/use-vesting-chart.ts b/apps/dapp/src/components/Pages/TeamPayments/NonCash/hooks/use-vesting-chart.ts new file mode 100644 index 000000000..09a3a5d7d --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/NonCash/hooks/use-vesting-chart.ts @@ -0,0 +1,241 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useApiManager } from 'hooks/use-api-manager'; +import { getAppConfig } from 'constants/newenv'; +import { fromAtto } from 'utils/bigNumber'; +import { VestingPayments } from 'types/typechain'; + +type Schedule = { + id: string; + start: string; + cliff: string; + duration: string; + vested: string; + released: string; + revoked: boolean; +}; + +type ChartDataPoint = { + month: string; + [key: string]: string | number | null; // vest1, vest2, etc. +}; + +type UseVestingChartReturn = { + data: ChartDataPoint[] | null; + loading: boolean; + error: string | null; +}; + +type UseVestingChartProps = { + schedules: Schedule[] | null; +}; + +// --- Pure helpers (no side-effects) ---- +function calculateVestedAmount( + currentTime: number, + start: number, + cliff: number, + duration: number, + totalAmount: number +): number { + // Before cliff, nothing is vested + if (currentTime < cliff) return 0; + + // After vesting period, everything is vested + const end = start + duration; + if (currentTime >= end) return totalAmount; + + // Linear vesting between cliff and end + const vestingPeriod = end - cliff; + const timeSinceCliff = currentTime - cliff; + return (timeSinceCliff / vestingPeriod) * totalAmount; +} + +function isDummySchedule(schedule: Schedule): boolean { + return schedule.id.startsWith('0xdummy'); +} + +async function fetchScheduleAmounts( + schedules: Schedule[], + papi: any, + vestingAddress: string +): Promise> { + if (!vestingAddress) { + throw new Error('Vesting contract address is undefined'); + } + + try { + const vestingContract = (await papi.getContract( + vestingAddress + )) as VestingPayments; + + const contractResults = await Promise.all( + schedules.map(async (schedule) => { + const scheduleDetails = await vestingContract.getSchedule(schedule.id); + return { + schedule, + amount: fromAtto(scheduleDetails.amount), + }; + }) + ); + + return contractResults.map(({ schedule, amount }) => ({ + ...schedule, + amount, + })); + } catch (error) { + console.warn( + 'Failed to load contract, falling back to subgraph data:', + error + ); + // Fallback: use the vested field from subgraph (already in token units) + return schedules.map((schedule) => ({ + ...schedule, + amount: parseFloat(schedule.vested), + })); + } +} + +function generateMonthlyDataPoints( + schedules: Array +): ChartDataPoint[] { + if (!schedules.length) return []; + + // Calculate time range + const starts = schedules.map((s) => Number(s.start)); + const ends = schedules.map((s) => Number(s.start) + Number(s.duration)); + const minStart = Math.min(...starts); + const maxEnd = Math.max(...ends); + + // Generate monthly data points + const dataPoints: ChartDataPoint[] = []; + const startDate = new Date(minStart * 1000); + const endDate = new Date(maxEnd * 1000); + + let currentDate = new Date(startDate.getFullYear(), startDate.getMonth(), 1); + + while (currentDate <= endDate) { + const timestamp = Math.floor(currentDate.getTime() / 1000); + const monthName = currentDate.toLocaleDateString('en-US', { + month: 'long', + }); + const year = currentDate.getFullYear(); + + const dataPoint: ChartDataPoint = { + month: `${monthName} ${year}`, + }; + + // Calculate vested amount for each schedule + schedules.forEach((schedule, index) => { + const vestedAmount = calculateVestedAmount( + timestamp, + Number(schedule.start), + Number(schedule.cliff), + Number(schedule.duration), + schedule.amount + ); + + const scheduleKey = `vest${index + 1}`; + dataPoint[scheduleKey] = vestedAmount || null; + }); + + dataPoints.push(dataPoint); + currentDate = new Date( + currentDate.getFullYear(), + currentDate.getMonth() + 1, + 1 + ); + } + + return dataPoints; +} + +/** + * Custom hook to generate vesting chart data + * Processes schedules and calculates vested amounts over time for chart display + * + * @param schedules - Array of vesting schedules to process + * @returns Chart data points, loading state, and error state + */ +export const useVestingChart = ({ + schedules, +}: UseVestingChartProps): UseVestingChartReturn => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const { papi } = useApiManager(); + const reqIdRef = useRef(0); // anti-race guard + + const fetchData = useCallback(async () => { + const myReqId = ++reqIdRef.current; + + // Early returns for edge cases + if (!schedules?.length) { + setData(null); + return; + } + + setLoading(true); + setError(null); + + try { + const vestingConfig = getAppConfig().contracts.vestingPayments; + + if (!vestingConfig.address) { + console.warn( + 'Vesting contract address is not configured, falling back to dummy data' + ); + // Force dummy data mode when contract is not configured + const schedulesWithAmounts = schedules.map((schedule) => ({ + ...schedule, + amount: parseFloat(schedule.vested), + })); + + if (myReqId !== reqIdRef.current) return; + const chartData = generateMonthlyDataPoints(schedulesWithAmounts); + setData(chartData); + return; + } + + let schedulesWithAmounts: Array; + + // Handle dummy vs real data + if (isDummySchedule(schedules[0])) { + // Dummy data: use vested field directly + schedulesWithAmounts = schedules.map((schedule) => ({ + ...schedule, + amount: parseFloat(schedule.vested), + })); + } else { + // Real data: fetch from contract + schedulesWithAmounts = await fetchScheduleAmounts( + schedules, + papi, + vestingConfig.address + ); + } + + // Check if request is still current + if (myReqId !== reqIdRef.current) return; + + const chartData = generateMonthlyDataPoints(schedulesWithAmounts); + setData(chartData); + } catch (err) { + if (myReqId !== reqIdRef.current) return; + console.error('Error generating vesting chart data:', err); + setError('Failed to generate vesting chart data.'); + } finally { + if (myReqId === reqIdRef.current) setLoading(false); + } + }, [schedules, papi]); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return { + data, + loading, + error, + }; +}; diff --git a/apps/dapp/src/components/Pages/TeamPayments/NonCash/hooks/use-vesting-metrics.ts b/apps/dapp/src/components/Pages/TeamPayments/NonCash/hooks/use-vesting-metrics.ts new file mode 100644 index 000000000..d5258dc7e --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/NonCash/hooks/use-vesting-metrics.ts @@ -0,0 +1,142 @@ +import { useMemo } from 'react'; +import { useWallet } from 'providers/WalletProvider'; +import { getAppConfig } from 'constants/newenv'; +import { + vestingSchedules, + vestingUser, + useCachedSubgraphQuery, +} from 'utils/subgraph'; + +// --- Defaults & Types --- +const DEFAULT_METRICS = { + totalAllocated: 0 as number, + totalVested: 0 as number, + totalReleased: 0 as number, +}; + +export type VestingSchedule = { + id: string; + start: string; + cliff: string; + duration: string; + vested: string; // subgraph string (base units or human units) + released: string; // subgraph string + revoked: boolean; +}; + +type UseVestingMetricsReturn = { + schedules: VestingSchedule[] | null; + totalAllocated: number; + totalVested: number; + totalReleased: number; + loading: boolean; + error: string | null; + refetch: () => void; +}; + +// --- Helpers --- +function toNumber(value?: string | null): number { + if (!value) return 0; + const v = value.trim(); + try { + // If it already has a decimal, assume human units + if (v.includes('.')) { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + } + // For this subgraph, amounts are already in human-readable format (not wei) + // So we can directly convert to number + const n = Number(v); + return Number.isFinite(n) ? n : 0; + } catch { + const n = Number(v); + return Number.isFinite(n) ? n : 0; + } +} + +// Removed pickAllocatedField since subgraph doesn't have explicit allocation fields + +// --- Hook --- +export const useVestingMetrics = (): UseVestingMetricsReturn => { + const { wallet } = useWallet(); + const subgraphUrl = getAppConfig().vesting.subgraphUrl; + + const normalizedWallet = wallet?.toLowerCase() || ''; + + const { + data: userResponse, + isLoading: userLoading, + error: userError, + refetch: refetchUser, + } = useCachedSubgraphQuery( + subgraphUrl, + vestingUser(normalizedWallet), + [normalizedWallet], + { enabled: Boolean(wallet) } + ); + + const { + data: schedulesResponse, + isLoading: schedulesLoading, + error: schedulesError, + refetch: refetchSchedules, + } = useCachedSubgraphQuery( + subgraphUrl, + vestingSchedules(normalizedWallet), + [normalizedWallet], + { enabled: Boolean(wallet) } + ); + + const { schedules, totalAllocated, totalVested, totalReleased } = + useMemo(() => { + if (!wallet) { + return { + schedules: null, + totalAllocated: DEFAULT_METRICS.totalAllocated, + totalVested: DEFAULT_METRICS.totalVested, + totalReleased: DEFAULT_METRICS.totalReleased, + }; + } + + const fetched: VestingSchedule[] = schedulesResponse?.schedules ?? []; + const activeSchedules = fetched.filter((s) => !s.revoked); + + const user = userResponse?.user; + const vested = user + ? toNumber(user.vestedAmount) + : DEFAULT_METRICS.totalVested; + const released = user + ? toNumber(user.releasedAmount) + : DEFAULT_METRICS.totalReleased; + + // Allocated: use vested as the best available proxy for total allocation + const allocated = activeSchedules.reduce((sum, s) => { + return sum + toNumber(s.vested); + }, 0); + + return { + schedules: activeSchedules, + totalAllocated: allocated, + totalVested: vested, + totalReleased: released, + }; + }, [wallet, userResponse, schedulesResponse]); + + const loading = wallet ? userLoading || schedulesLoading : false; + const error = userError || schedulesError; + + const refetch = () => { + refetchUser(); + refetchSchedules(); + }; + + return { + schedules, + totalAllocated, + totalVested, + totalReleased, + loading, + error: error ? 'Failed to fetch vesting schedules and user metrics.' : null, + refetch, + }; +}; diff --git a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Chart/BarChart.tsx b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Chart/BarChart.tsx new file mode 100644 index 000000000..e32dcb0f4 --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Chart/BarChart.tsx @@ -0,0 +1,206 @@ +import type { DataKey, AxisDomain } from 'recharts/types/util/types'; +import { + ComposedChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, + Line, +} from 'recharts'; +import { useTheme } from 'styled-components'; + +export type BarChartProps = { + chartData: T[]; + xDataKey: DataKey; + xTickFormatter: (xValue: any, index: number) => string; + yTickFormatter?: (yValue: any, index: number) => string; + tooltipLabelFormatter: (value: any) => string; + tooltipValuesFormatter?: (props: any) => string[]; + yDomain?: AxisDomain; + yTicks?: number[]; + series: { key: string; color: string }[]; + lineDataKey?: DataKey; +}; + +export default function CustomBarChart({ + chartData, + xDataKey, + xTickFormatter, + yTickFormatter, + tooltipLabelFormatter, + tooltipValuesFormatter, + yDomain, + yTicks, + series, + lineDataKey, +}: React.PropsWithChildren>) { + const theme = useTheme(); + + // Calculate responsive bar size + // Max 60px, but shrink if there are many bars or on smaller screens + const dataPointCount = chartData.length; + const maxBarSize = 60; + const minBarSize = 20; + + // Estimate available width (accounting for margins and gaps) + // On mobile: ~320px, desktop: wider + const estimatedAvailableWidth = + typeof window !== 'undefined' + ? Math.min(window.innerWidth - 100, 800) // Max 800px chart width + : 600; + + const calculatedBarSize = Math.max( + minBarSize, + Math.min(maxBarSize, (estimatedAvailableWidth - 100) / dataPointCount) + ); + + const activeBarStyle = { + fill: series[series.length - 1].color, + stroke: '#FFFFFF', + strokeWidth: 1, + }; + + const CustomTooltip = ({ payload }: any) => { + if (!tooltipValuesFormatter || !payload || payload.length === 0) + return null; + + const rawData = payload[0]?.payload; + if (!rawData) return null; + + const lines = tooltipValuesFormatter(rawData); + + return ( +
+
+ {tooltipLabelFormatter('')} +
+ {lines.map((line, i) => ( + // eslint-disable-next-line react/no-array-index-key +
{line}
+ ))} +
+ ); + }; + + return ( + + + + + { + const formatted = yTickFormatter + ? yTickFormatter(payload.value, payload.index) + : String(payload.value); + + const lines = formatted.split('\n'); + return ( + + {lines.map((line, i) => ( + // eslint-disable-next-line react/no-array-index-key + + {line} + + ))} + + ); + }} + domain={yDomain} + ticks={yTicks} + tickMargin={16} + /> + } offset={30} cursor={false} /> + {series.map((serie, index) => { + const isTopBar = index === series.length - 1; + const isBottomBar = index === 0; + return ( + + ); + })} + {lineDataKey && ( + + )} + + + ); +} diff --git a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Chart/Chart.tsx b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Chart/Chart.tsx new file mode 100644 index 000000000..884098af6 --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Chart/Chart.tsx @@ -0,0 +1,222 @@ +import styled from 'styled-components'; +import { format } from 'date-fns'; +import { useCallback, useMemo } from 'react'; +import { formatNumberAbbreviated } from 'utils/formatter'; +import CustomBarChart from './BarChart'; +import { useAdminVestingChart } from '../hooks/use-admin-vesting-chart'; +import Loader from 'components/Loader/Loader'; +import { getYAxisDomainAndTicks } from 'components/Pages/Core/DappPages/SpiceBazaar/components/GetYAxisDomainAndTicks'; + +const LOADER_ICON_SIZE = 48; +const MIN_CHART_HEIGHT = 300; +const ERROR_PADDING = 40; +const USER_COLORS = ['#FFDEC9', '#D0BE75', '#BD7B4F', '#95613F']; + +interface UserSeries { + key: string; +} + +// ---- Pure helpers (no side-effects) ---- +const tickFormatter = (timestamp: number): string => + format(new Date(timestamp), 'MMM'); + +function calculateMonthTotal(data: Metric, userSeries: UserSeries[]): number { + return userSeries.reduce((sum, user) => { + return sum + (data[user.key] || 0); + }, 0); +} + +function findHoveredUser( + data: Metric, + userSeries: UserSeries[] +): { user: UserSeries; value: number } { + let hoveredUser = userSeries[0]; + let hoveredValue = 0; + + userSeries.forEach((user) => { + const value = data[user.key] || 0; + if (value > hoveredValue) { + hoveredValue = value; + hoveredUser = user; + } + }); + + return { user: hoveredUser, value: hoveredValue }; +} + +function calculatePercentage(value: number, total: number): string { + return total > 0 ? ((value / total) * 100).toFixed(0) : '0'; +} + +function createTooltipContent( + data: Metric, + userSeries: UserSeries[] +): string[] { + const dateLabel = tickFormatter(data.timestamp); + + // Safety check: if no user series, return simple message + if (userSeries.length === 0) { + return [`No vesting data for ${dateLabel}`]; + } + + const monthTotal = calculateMonthTotal(data, userSeries); + const { value: hoveredValue } = findHoveredUser(data, userSeries); + const percent = calculatePercentage(hoveredValue, monthTotal); + + return [ + `Amount to be vested: ${hoveredValue.toLocaleString()} TGLD`, + `Percentage of total amount to be vested in ${dateLabel}:`, + `${hoveredValue.toLocaleString()} / ${monthTotal.toLocaleString()} = ${percent}%`, + ]; +} + +function calculateChartValues( + chartData: Metric[], + userSeries: UserSeries[] +): number[] { + if (userSeries.length === 0) return [0]; + + return chartData.map((dataPoint) => { + return userSeries.reduce((sum, user) => { + return sum + (dataPoint[user.key] || 0); + }, 0); + }); +} + +export type Metric = { + timestamp: number; + total?: number; // Total line above bars + [key: string]: number | undefined; // Dynamic keys for users +}; + +const ChartContainer = ({ children }: { children: React.ReactNode }) => ( + + + Projected TGLD Vesting By Month + + + TGLD balance runway + + + {children} + +); + +const LoadingState = () => ( + + + + + +); + +const EmptyState = () => ( + No vesting data available +); + +type ProjectedTGLDVestingProps = { + walletAddress?: string; +}; + +export const ProjectedTGLDVesting = ({ + walletAddress, +}: ProjectedTGLDVestingProps) => { + const { chartData, userSeries, loading, error } = + useAdminVestingChart(walletAddress); + + // Memoized formatters to prevent recreation on every render + // MUST be called before any early returns (Rules of Hooks) + const yTickFormatter = useCallback( + (val: number) => `${formatNumberAbbreviated(val).string}\nTGLD`, + [] + ); + + const tooltipLabelFormatter = useCallback(() => 'Grant ID', []); + + const tooltipValuesFormatter = useCallback( + (data: Metric) => createTooltipContent(data, userSeries), + [userSeries] + ); + + // Generate series dynamically from userSeries (memoized) + const series = useMemo( + () => + userSeries.map((user, index) => ({ + key: user.key, + color: USER_COLORS[index % USER_COLORS.length], + })), + [userSeries] + ); + + // Calculate y-axis domain using standard utility (memoized) + const { yDomain, yTicks } = useMemo(() => { + const values = calculateChartValues(chartData, userSeries); + return getYAxisDomainAndTicks(values); + }, [chartData, userSeries]); + + // Early returns for edge cases (AFTER all hooks) + if (loading) return ; + if (chartData.length === 0) return ; + + return ( + + + chartData={chartData} + series={series} + xDataKey="timestamp" + xTickFormatter={tickFormatter} + yTickFormatter={yTickFormatter} + tooltipLabelFormatter={tooltipLabelFormatter} + tooltipValuesFormatter={tooltipValuesFormatter} + yDomain={yDomain} + yTicks={yTicks} + lineDataKey="total" + /> + + ); +}; + +const PageContainer = styled.div` + height: 100%; + display: flex; + flex-direction: column; + gap: 20px; + width: 100%; +`; + +const HeaderContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const Title = styled.h3` + line-height: 45px; + font-size: 24px; + font-weight: 400; + color: ${({ theme }) => theme.palette.brandLight}; + margin: 0; +`; + +const Legend = styled.div` + display: flex; + align-items: center; + gap: 10px; +`; + +const DashedLine = styled.div` + width: 30px; + border-top: 2px dashed ${({ theme }) => theme.palette.brandLight}; +`; + +const LegendText = styled.span` + font-size: 12px; + color: ${({ theme }) => theme.palette.brandLight}; +`; + +const LoaderContainer = styled.div` + display: flex; + justify-content: center; + align-items: center; + min-height: ${MIN_CHART_HEIGHT}px; +`; diff --git a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/DataTables/ClaimHistoryDataTable.tsx b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/DataTables/ClaimHistoryDataTable.tsx new file mode 100644 index 000000000..d0e699182 --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/DataTables/ClaimHistoryDataTable.tsx @@ -0,0 +1,237 @@ +import React, { useEffect, useState } from 'react'; +import styled from 'styled-components'; +import * as breakpoints from 'styles/breakpoints'; +import { ScrollBar } from 'components/Pages/Core/DappPages/SpiceBazaar/components/CustomScrollBar'; +import DownArrow from 'assets/icons/down-arrow.svg?react'; +import Order from 'assets/icons/order.svg?react'; + +export type Transaction = { + grantDate: string; + claimedTgld: string; + granteeAddress: string; + transactionLink: string; + transactionHash: string; + displayHash: string; +}; + +type TableHeader = { name: string }; + +type TableProps = { + tableHeaders: TableHeader[]; + transactions: Transaction[]; + loading: boolean; + refetch?: () => void; + dataRefetching?: boolean; +}; + +export const DataTable: React.FC = ({ + tableHeaders, + transactions, + loading, +}) => { + const [filter, setFilter] = useState('Last 5 Shown'); + const [filteredTransactions, setFilteredTransactions] = + useState(transactions); + const filterOptions = ['Last 5 Shown', 'Show All']; + + useEffect(() => { + const sortedTransactions = [...transactions].sort( + (a, b) => Number(b.grantDate) - Number(a.grantDate) + ); + + if (filter === 'Last 5 Shown') { + setFilteredTransactions(sortedTransactions.slice(0, 5)); + } else { + setFilteredTransactions(sortedTransactions); + } + }, [filter, transactions]); + + return ( + +
+ Claim History + + {filterOptions.map((option) => ( + setFilter(option)} + selected={filter === option} + > + {option} + + ))} + +
+ + + + + {tableHeaders.map((h) => ( + + {h.name === 'Grant Date' ? ( + + {h.name} + + + ) : h.name === 'Grantee Address' ? ( + + {h.name} + + + ) : ( + <>{h.name} + )} + + ))} + + + + {loading ? ( + + Loading... + + ) : filteredTransactions.length === 0 ? ( + + No data available + + ) : ( + filteredTransactions.map((transaction) => ( + + {transaction.grantDate} + {transaction.claimedTgld} + {transaction.granteeAddress} + + + {transaction.displayHash} + + + + )) + )} + + + +
+ ); +}; + +const PageContainer = styled.div` + display: flex; + flex-direction: column; + gap: 20px; +`; + +const Header = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + flex-direction: column; + gap: 10px; + + ${breakpoints.phoneAndAbove(` + flex-direction: row; + `)} +`; + +const Title = styled.h3` + color: ${({ theme }) => theme.palette.brandLight}; + font-size: 24px; + line-height: 44px; + margin: 0; +`; + +const FilterContainer = styled.div` + display: flex; + flex-direction: row; + gap: 20px; + padding-right: 16px; +`; + +const FilterButton = styled.button<{ selected: boolean }>` + font-size: 16px; + line-height: 19px; + background: none; + color: ${({ selected, theme }) => + selected ? theme.palette.brandLight : theme.palette.brand}; + border: none; + cursor: pointer; +`; + +const TableData = styled.table` + border-spacing: 10px + width: 100%; + border-collapse: collapse; + min-width: 500px; + width: 100%; +`; + +const HeaderRow = styled.tr` + border-bottom: 1px solid ${({ theme }) => theme.palette.brand}; +`; + +const TableHeader = styled.th` + font-size: 13px; + font-weight: 700; + line-height: 20px; + text-align: left; + width: 25%; + color: ${({ theme }) => theme.palette.brand}; + position: sticky; + top: 0; + z-index: 1; + padding: 10px 16px; + + &:first-child { + padding: 10px 0px 10px 0px; + } + + &:last-child { + padding: 10px 0px 10px 16px; + } +`; + +const TableHeaderWithIcon = styled.div` + display: flex; + align-items: center; + gap: 5px; +`; + +const DataRow = styled.tr` + border-bottom: 1px solid ${({ theme }) => theme.palette.brand}; +`; + +const DataCell = styled.td` + font-size: 13px; + font-weight: 700; + line-height: 20px; + text-align: left; + width: 25%; + white-space: nowrap; + color: ${({ theme }) => theme.palette.brandLight}; + + a { + color: ${({ theme }) => theme.palette.brandLight}; + } + + a:hover { + color: ${({ theme }) => theme.palette.brand}; + } + + padding: 20px 16px; + + &:first-child { + padding: 20px 0px 20px 0px; + } + + &:last-child { + padding: 20px 0px 20px 16px; + } +`; + +const DownArrowIcon = styled(DownArrow)``; + +const OrderIcon = styled(Order)``; diff --git a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Tables/ClaimHistoryTable.tsx b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Tables/ClaimHistoryTable.tsx new file mode 100644 index 000000000..28ad0ce5b --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Tables/ClaimHistoryTable.tsx @@ -0,0 +1,37 @@ +import styled from 'styled-components'; +import { DataTable } from '../DataTables/ClaimHistoryDataTable'; +import { useAllClaimHistory } from '../hooks/use-all-claim-history'; + +enum TableHeaders { + GrantDate = 'Grant Date', + ClaimedTgld = 'Claimed TGLD', + GranteeAddress = 'Grantee Address', + TransactionLink = 'Transaction Link', +} + +const tableHeaders = [ + { name: TableHeaders.GrantDate }, + { name: TableHeaders.ClaimedTgld }, + { name: TableHeaders.GranteeAddress }, + { name: TableHeaders.TransactionLink }, +]; + +export const ClaimHistory = ({ walletAddress }: { walletAddress?: string }) => { + // Fetch claim transactions (all users or filtered by walletAddress) + const { transactions, loading } = useAllClaimHistory(walletAddress); + + return ( + + + + ); +}; + +const AuctionsHistoryContainer = styled.div` + display: flex; + flex-direction: column; +`; diff --git a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/VestingDashboard.tsx b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/VestingDashboard.tsx new file mode 100644 index 000000000..1929a6ae7 --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/VestingDashboard.tsx @@ -0,0 +1,145 @@ +import { ProjectedTGLDVesting } from 'components/Pages/TeamPayments/VestingDashboard/Chart/Chart'; +import { ClaimHistory } from 'components/Pages/TeamPayments/VestingDashboard/Tables/ClaimHistoryTable'; +import styled from 'styled-components'; +import { SearchInput } from './components/Input'; +import { useState } from 'react'; +import { AllDatesDropdown } from './components/InputSelect'; +import { useVestingMetrics } from './hooks/use-vesting-metrics'; +import { useAddressValidation } from './hooks/use-address-validation'; +import Loader from 'components/Loader/Loader'; + +interface MetricBoxProps { + value: number; + title: string; + loading: boolean; +} + +function MetricBox({ value, title, loading }: MetricBoxProps) { + return ( + + {loading ? ( + + ) : ( + <> + {value.toLocaleString()} + {title} + + )} + + ); +} + +export default function VestingDashboard() { + const [searchValue, setSearchValue] = useState(''); + + // Validate search address + const addressValidation = useAddressValidation(searchValue); + + // Fetch vesting metrics (global or filtered by validated address) + const { + data: metrics, + loading, + error, + } = useVestingMetrics(addressValidation.validatedAddress ?? undefined); + + const totalClaimable = metrics?.totalVestedAndUnclaimed || 0; + const totalClaimed = metrics?.totalReleased || 0; + + return ( + + + + + Vesting Dashboard + {/* */} + + + + + + + + + + ); +} + +const PageContainer = styled.div` + margin-top: -40px; + display: flex; + flex-direction: column; + gap: 40px; + padding: 40px 0px 0px 0px; +`; + +const HeaderTitle = styled.h2` + display: flex; + align-items: center; + text-align: center; + color: ${({ theme }) => theme.palette.brandLight}; + gap: 15px; + margin: 0px; + font-size: 36px; +`; + +const StatusContainer = styled.div` + display: flex; + flex-direction: column; + gap: 20px; +`; + +const BoxContainer = styled.div` + display: flex; + flex-direction: row; + gap: 20px; +`; + +const Box = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex-grow: 1; + flex-basis: 0; + min-height: 136px; + gap: 12px; + padding: 10px; + border: 1px solid ${({ theme }) => theme.palette.brand}; + border-radius: 10px; + background: linear-gradient(to bottom, #0b0a0a, #1d1a1a); +`; + +const Title = styled.div` + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + font-size: 16px; + line-height: 19px; + color: ${({ theme }) => theme.palette.brand}; +`; + +const Sum = styled.div` + font-size: 24px; + font-weight: 700; + line-height: 29px; + color: ${({ theme }) => theme.palette.brandLight}; +`; + +const SearchBar = styled.div` + display: flex; + justify-content: flex-end; + width: 100%; +`; diff --git a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/components/Input.tsx b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/components/Input.tsx new file mode 100644 index 000000000..15bf571e6 --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/components/Input.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import styled from 'styled-components'; +import Search from 'assets/icons/search.svg?react'; + +interface SearchInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; + disabled?: boolean; +} + +export const SearchInput: React.FC = ({ + value, + onChange, + placeholder = 'Search wallet address', + disabled = false, +}) => { + return ( + + + onChange(e.target.value)} + placeholder={placeholder} + disabled={disabled} + /> + + ); +}; + +const SearchInputWrapper = styled.div<{ disabled?: boolean }>` + display: flex; + align-items: center; + background-color: ${({ theme }) => theme.palette.dark}; + border: 1px solid ${({ theme }) => theme.palette.brand}; + border-radius: 5px; + padding: 5px 10px; + gap: 10px; + box-sizing: border-box; + + ${({ disabled, theme }) => + disabled && + ` + background-color: ${theme.palette.brand25}; + cursor: not-allowed; + `} +`; + +const SearchIcon = styled(Search)` + width: 18px; + height: 18px; +`; + +const StyledInput = styled.input` + ${({ theme }) => theme.typography.fonts.fontHeading}; + color: ${({ theme }) => theme.palette.brand}; + background-color: transparent; + border: none; + outline: none; + font-family: Caviar Dreams; + font-weight: 400; + font-size: 16px; + line-height: 120%; + width: 165px; + + &::placeholder { + color: ${({ theme }) => theme.palette.brand}; + opacity: 0.8; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + } +`; diff --git a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/components/InputSelect.tsx b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/components/InputSelect.tsx new file mode 100644 index 000000000..c23dc61bd --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/components/InputSelect.tsx @@ -0,0 +1,207 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import DropdownArrow from 'assets/icons/dropdown-arrow.svg?react'; +import { theme } from 'styles/theme'; + +const months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +const years = Array.from({ length: 11 }, (_, i) => 2020 + i); + +export const AllDatesDropdown = () => { + const [open, setOpen] = useState(false); + + return ( + + {!open && ( + setOpen(true)}> + All dates + + + )} + + {open && ( + + setOpen(false)}> + All dates + + + + + From + + + + + + + + + + + + To + + + + + + + + + + + + + )} + + ); +}; + +const Wrapper = styled.div` + position: relative; + width: 320px; + z-index: 10; + min-height: 34px; +`; + +const FloatingPanel = styled.div` + position: absolute; + top: 0; + left: 0; + width: 100%; + background-color: ${theme.palette.black}; + border: 1px solid ${theme.palette.brand}; + border-radius: 5px; + color: ${theme.palette.brandLight}; + z-index: 100; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +`; + +const DropdownHeaderBase = styled.div` + box-sizing: border-box; + background-color: ${theme.palette.black}; + color: ${theme.palette.brandLight}; + padding: 5px 10px; + border-radius: 5px; + height: 34px; + font-size: 16px; + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; +`; + +const Trigger = styled(DropdownHeaderBase)` + border: 1px solid ${theme.palette.brand}; + + &:hover { + border-color: ${theme.palette.brandLight}; + } +`; + +const DropdownHeader = styled(DropdownHeaderBase)` + height: 32px; +`; + +const DropdownContent = styled.div` + display: flex; + padding: 10px; + flex-direction: column; + gap: 10px; +`; + +const FromTo = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const FromToLabel = styled.div` + color: ${theme.palette.brandLight}; + font-size: 16px; + font-size: 16px; + line-height: 100%; + letter-spacing: -2%; +`; + +const MonthYearRow = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; +`; + +const SelectDropdown = styled.div` + display: flex; + + select { + background-color: ${theme.palette.black}; + color: ${theme.palette.brand}; + font-family: 'Caviar Dreams'; + font-weight: 700; + font-size: 16px; + letter-spacing: 5%; + border: 1px solid ${theme.palette.black}; + } +`; + +const DateInput = styled.input` + width: 100%; + background-color: ${theme.palette.black}; + color: ${theme.palette.brand}; + border: 1px solid ${theme.palette.brand}; + padding: 8px 10px; + border-radius: 5px; + font-family: Caviar Dreams; + font-weight: 700; + font-size: 12px; + line-height: 150%; + letter-spacing: 5%; + + &::placeholder { + color: ${theme.palette.brand}; + } +`; + +const Arrow = styled(DropdownArrow)<{ open: boolean }>` + transition: transform 200ms ease, color 200ms ease; + transform: ${({ open }) => (open ? 'rotate(180deg)' : 'rotate(0deg)')}; + color: ${({ open }) => + open ? theme.palette.brandLight : theme.palette.brand}; +`; diff --git a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/hooks/use-address-validation.ts b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/hooks/use-address-validation.ts new file mode 100644 index 000000000..8e6e67c5a --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/hooks/use-address-validation.ts @@ -0,0 +1,89 @@ +import { useState, useEffect, useMemo } from 'react'; +import { getAppConfig } from 'constants/newenv'; +import { allVestingSchedules, useCachedSubgraphQuery } from 'utils/subgraph'; + +// Helper function to validate Ethereum address format +function isValidEthereumAddress(address: string): boolean { + return /^0x[a-fA-F0-9]{40}$/.test(address.trim()); +} + +// Custom debounce hook +function useDebounce(value: string, delay: number): string { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} + +type UseAddressValidationReturn = { + isValid: boolean; // true if both format + existence pass + validatedAddress: string | null; // the clean address or null + isChecking: boolean; // true while validating existence +}; + +export const useAddressValidation = ( + searchInput: string +): UseAddressValidationReturn => { + const subgraphUrl = getAppConfig().vesting.subgraphUrl; + + // Debounce the search input + const debouncedInput = useDebounce(searchInput, 300); + + // Fetch all vesting schedules to get valid addresses + const { data: schedulesResponse, isLoading: isLoadingSchedules } = + useCachedSubgraphQuery(subgraphUrl, allVestingSchedules()); + + // Process validation + const validation = useMemo(() => { + // If input is empty, return not valid + if (!debouncedInput.trim()) { + return { + isValid: false, + validatedAddress: null, + isChecking: false, + }; + } + + // Check format first + const cleanAddress = debouncedInput.trim().toLowerCase(); + if (!isValidEthereumAddress(cleanAddress)) { + return { + isValid: false, + validatedAddress: null, + isChecking: false, + }; + } + + // If still loading schedules, show checking state + if (isLoadingSchedules) { + return { + isValid: false, + validatedAddress: null, + isChecking: true, + }; + } + + // Check if address exists in vesting schedules + const schedules = schedulesResponse?.schedules || []; + const addressExists = schedules.some( + (schedule) => schedule.recipient.id.toLowerCase() === cleanAddress + ); + + return { + isValid: addressExists, + validatedAddress: addressExists ? cleanAddress : null, + isChecking: false, + }; + }, [debouncedInput, schedulesResponse, isLoadingSchedules]); + + return validation; +}; diff --git a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/hooks/use-admin-vesting-chart.ts b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/hooks/use-admin-vesting-chart.ts new file mode 100644 index 000000000..3913b1215 --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/hooks/use-admin-vesting-chart.ts @@ -0,0 +1,275 @@ +import { useMemo } from 'react'; +import { getAppConfig } from 'constants/newenv'; +import { allVestingSchedules, useCachedSubgraphQuery } from 'utils/subgraph'; + +const TOP_N_USERS = 10; +const DAYS_IN_MONTH_MS = 30 * 24 * 60 * 60 * 1000; +const TOTAL_LINE_OFFSET = 1.1; // 10% above bars +const MS_TO_SECONDS = 1000; + +type Schedule = { + id: string; + start: string; + cliff: string; + duration: string; + vested: string; + released: string; + revoked: boolean; + recipient: { + id: string; + }; +}; + +export type AdminChartDataPoint = { + timestamp: number; + total?: number; // Total vesting for the month (dotted line) + [key: string]: number | undefined; // Dynamic keys for each user (user1, user2, etc.) +}; + +export type UserSeries = { + key: string; + address: string; + totalAllocation: number; +}; + +type UseAdminVestingChartReturn = { + chartData: AdminChartDataPoint[]; + userSeries: UserSeries[]; + loading: boolean; + error: string | null; +}; + +/** + * Calculate vested amount for a schedule at a given timestamp + */ +function calculateVestedAtTime( + schedule: Schedule, + targetTimestamp: number +): number { + const start = parseInt(schedule.start); + const cliff = parseInt(schedule.cliff); // This is an absolute timestamp, not a duration + const duration = parseInt(schedule.duration); + const totalVested = parseFloat(schedule.vested); + + // Convert targetTimestamp from ms to seconds + const targetTime = Math.floor(targetTimestamp / 1000); + + // If before cliff, nothing vested + if (targetTime < cliff) { + return 0; + } + + // If after full vesting period, return total vested + const end = start + duration; + if (targetTime >= end) { + return totalVested; + } + + // Linear vesting between cliff and end + const vestingPeriod = end - cliff; + const elapsedSinceCliff = targetTime - cliff; + const vestedAmount = (totalVested * elapsedSinceCliff) / vestingPeriod; + + return vestedAmount; +} + +// ---- Pure helpers (no side-effects) ---- +function groupSchedulesByUser(schedules: Schedule[]): { + userSchedules: Map; + userAllocations: Map; +} { + const userSchedules = new Map(); + const userAllocations = new Map(); + + schedules.forEach((schedule) => { + const recipient = schedule.recipient.id.toLowerCase(); + if (!userSchedules.has(recipient)) { + userSchedules.set(recipient, []); + userAllocations.set(recipient, 0); + } + // Safe to use non-null assertion here since we just checked and set above + const userScheduleList = userSchedules.get(recipient)!; + userScheduleList.push(schedule); + userAllocations.set( + recipient, + userAllocations.get(recipient)! + parseFloat(schedule.vested) + ); + }); + + return { userSchedules, userAllocations }; +} + +function calculateTimeRange(schedules: Schedule[]): { + earliestStart: number; + latestEnd: number; +} { + let earliestStart = Infinity; + let latestEnd = 0; + + schedules.forEach((schedule) => { + const start = parseInt(schedule.start); + const end = start + parseInt(schedule.duration); + + if (start < earliestStart) earliestStart = start; + if (end > latestEnd) latestEnd = end; + }); + + return { earliestStart, latestEnd }; +} + +function generateMonthlyTimestamps( + earliestStart: number, + latestEnd: number +): number[] { + const monthlyTimestamps: number[] = []; + const startDate = new Date(earliestStart * MS_TO_SECONDS); + const endDate = new Date(latestEnd * MS_TO_SECONDS); + + // Start from the beginning of the first month + const currentDate = new Date( + startDate.getFullYear(), + startDate.getMonth(), + 1 + ); + + while (currentDate <= endDate) { + monthlyTimestamps.push(currentDate.getTime()); + currentDate.setMonth(currentDate.getMonth() + 1); + } + + return monthlyTimestamps; +} + +function calculateIncrementalVesting( + userSchedules: Schedule[], + timestamp: number, + nextMonthTimestamp: number +): number { + // Calculate vested at start of month + const monthStartVested = userSchedules.reduce((sum, schedule) => { + return sum + calculateVestedAtTime(schedule, timestamp); + }, 0); + + // Calculate vested at end of month + const monthEndVested = userSchedules.reduce((sum, schedule) => { + return sum + calculateVestedAtTime(schedule, nextMonthTimestamp); + }, 0); + + // Incremental vesting for this month + const incrementalVesting = Math.max(0, monthEndVested - monthStartVested); + return Math.round(incrementalVesting); +} + +function generateChartDataPoints( + monthlyTimestamps: number[], + userSeriesData: UserSeries[], + userSchedules: Map +): AdminChartDataPoint[] { + return monthlyTimestamps.map((timestamp, monthIndex) => { + const dataPoint: AdminChartDataPoint = { timestamp }; + let monthTotal = 0; + + // For each top user, calculate incremental vesting this month + userSeriesData.forEach((userSeries) => { + const userScheds = userSchedules.get(userSeries.address) || []; + + // Calculate next month timestamp + const nextMonthTimestamp = + monthIndex < monthlyTimestamps.length - 1 + ? monthlyTimestamps[monthIndex + 1] + : timestamp + DAYS_IN_MONTH_MS; + + const incrementalVesting = calculateIncrementalVesting( + userScheds, + timestamp, + nextMonthTimestamp + ); + + dataPoint[userSeries.key] = incrementalVesting; + monthTotal += incrementalVesting; + }); + + // Add total line (dotted line above bars) - offset to sit above bars + dataPoint.total = monthTotal * TOTAL_LINE_OFFSET; + + return dataPoint; + }); +} + +/** + * Hook to fetch all vesting schedules and calculate monthly aggregated vesting amounts + */ +export const useAdminVestingChart = ( + walletAddress?: string +): UseAdminVestingChartReturn => { + const subgraphUrl = getAppConfig().vesting.subgraphUrl; + + const { + data: response, + isLoading: loading, + error, + } = useCachedSubgraphQuery(subgraphUrl, allVestingSchedules()); + + // Filter schedules by recipient if walletAddress is provided + const schedules = useMemo(() => { + const allSchedules = response?.schedules ?? []; + + // Treat empty string as no filter + if (!walletAddress?.trim()) { + return allSchedules; + } + + const normalizedAddress = walletAddress.toLowerCase(); + return allSchedules.filter( + (schedule) => schedule.recipient.id.toLowerCase() === normalizedAddress + ); + }, [response, walletAddress]); + + // Calculate user series and monthly aggregated data + const { chartData, userSeries } = useMemo(() => { + // Early return for empty schedules + if (schedules.length === 0) { + return { chartData: [], userSeries: [] }; + } + + // Group schedules by recipient and calculate total allocation per user + const { userSchedules, userAllocations } = groupSchedulesByUser(schedules); + + // Sort users by total allocation and take top N + const sortedUsers = Array.from(userAllocations.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, TOP_N_USERS); + + // Create user series metadata + const userSeriesData: UserSeries[] = sortedUsers.map( + ([address, allocation], index) => ({ + key: `user${index + 1}`, + address, + totalAllocation: allocation, + }) + ); + + // Calculate time range and generate monthly timestamps + const { earliestStart, latestEnd } = calculateTimeRange(schedules); + const monthlyTimestamps = generateMonthlyTimestamps( + earliestStart, + latestEnd + ); + + // Generate chart data points + const dataPoints = generateChartDataPoints( + monthlyTimestamps, + userSeriesData, + userSchedules + ); + + return { chartData: dataPoints, userSeries: userSeriesData }; + }, [schedules]); + + return { + chartData, + userSeries, + loading, + error: error ? 'Failed to fetch vesting schedules.' : null, + }; +}; diff --git a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/hooks/use-all-claim-history.ts b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/hooks/use-all-claim-history.ts new file mode 100644 index 000000000..06f892f3f --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/hooks/use-all-claim-history.ts @@ -0,0 +1,78 @@ +import { useMemo } from 'react'; +import { getAppConfig } from 'constants/newenv'; +import { allReleaseTransactions, useCachedSubgraphQuery } from 'utils/subgraph'; +import env from 'constants/env'; + +export type ClaimTransaction = { + grantDate: string; + claimedTgld: string; + granteeAddress: string; + transactionLink: string; + transactionHash: string; + displayHash: string; +}; + +type UseAllClaimHistoryReturn = { + transactions: ClaimTransaction[]; + loading: boolean; + error: string | null; + refetch: () => void; +}; + +const shortenAddress = (address: string): string => { + if (!address || address.length < 10) return address; + return `${address.slice(0, 6)}...${address.slice(-4)}`; +}; + +const shortenHash = (hash: string): string => { + if (!hash || hash.length < 10) return hash; + return `${hash.slice(0, 16)}...${hash.slice(-6)}`; +}; + +export const useAllClaimHistory = ( + walletAddress?: string +): UseAllClaimHistoryReturn => { + const subgraphUrl = getAppConfig().vesting.subgraphUrl; + + const { + data: response, + isLoading: loading, + error, + refetch, + } = useCachedSubgraphQuery(subgraphUrl, allReleaseTransactions()); + + const transactions = useMemo((): ClaimTransaction[] => { + if (!response?.releaseTransactions) { + return []; + } + + // Filter by wallet address if provided (treat empty string as no filter) + let filteredTransactions = response.releaseTransactions; + if (walletAddress?.trim()) { + const normalizedAddress = walletAddress.toLowerCase(); + filteredTransactions = filteredTransactions.filter( + (tx) => tx.user.id.toLowerCase() === normalizedAddress + ); + } + + return filteredTransactions.map((tx) => { + const txLink = `${env.etherscan}/tx/${tx.hash}`; + + return { + grantDate: tx.timestamp, // Keep as timestamp for sorting + claimedTgld: parseFloat(tx.releasedAmount).toLocaleString(), + granteeAddress: shortenAddress(tx.user.id), + transactionLink: txLink, + transactionHash: tx.hash, + displayHash: shortenHash(tx.hash), + }; + }); + }, [response, walletAddress]); + + return { + transactions, + loading, + error: error ? 'Failed to fetch claim history.' : null, + refetch, + }; +}; diff --git a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/hooks/use-vesting-metrics.ts b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/hooks/use-vesting-metrics.ts new file mode 100644 index 000000000..6ba4141e5 --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/hooks/use-vesting-metrics.ts @@ -0,0 +1,162 @@ +import { useMemo } from 'react'; +import { getAppConfig } from 'constants/newenv'; +import { + vestingMetrics, + vestingUser, + vestingSchedules, + useCachedSubgraphQuery, +} from 'utils/subgraph'; + +const DEFAULT_METRICS = { + totalVestedAndUnclaimed: 0, + totalReleased: 0, +} as const; + +// ---- Pure helpers (no side-effects) ---- +function parseMetricsData(metrics: any): VestingMetricsData { + return { + totalVestedAndUnclaimed: parseFloat(metrics.totalVestedAndUnclaimed), + totalReleased: parseFloat(metrics.totalReleased), + }; +} + +function parseUserMetrics( + userResponse: any, + schedulesResponse: any +): VestingMetricsData { + const schedules = schedulesResponse?.schedules || []; + const activeSchedules = schedules.filter((s: any) => !s.revoked); + + // If we have user data, use it (more accurate) + if (userResponse?.user) { + const user = userResponse.user; + const totalVested = parseFloat(user.vestedAmount || '0'); + const totalReleased = parseFloat(user.releasedAmount || '0'); + const totalVestedAndUnclaimed = Math.max(0, totalVested - totalReleased); + + return { + totalVestedAndUnclaimed, + totalReleased, + }; + } + + // Fallback: calculate from schedules if user record doesn't exist + if (activeSchedules.length > 0) { + const totalVested = activeSchedules.reduce( + (sum: number, s: any) => sum + parseFloat(s.vested || '0'), + 0 + ); + const totalReleased = activeSchedules.reduce( + (sum: number, s: any) => sum + parseFloat(s.released || '0'), + 0 + ); + const totalVestedAndUnclaimed = Math.max(0, totalVested - totalReleased); + + return { + totalVestedAndUnclaimed, + totalReleased, + }; + } + + // No data available + return DEFAULT_METRICS; +} + +type VestingMetricsData = { + totalVestedAndUnclaimed: number; + totalReleased: number; +}; + +type UseVestingMetricsReturn = { + data: VestingMetricsData | null; + loading: boolean; + error: string | null; + refetch: () => void; +}; + +export const useVestingMetrics = ( + walletAddress?: string +): UseVestingMetricsReturn => { + const subgraphUrl = getAppConfig().vesting.subgraphUrl; + + // Determine if we should filter by user + const shouldFilterByUser = Boolean(walletAddress); + + // Global metrics query (always fetch for fallback) + const { + data: globalResponse, + isLoading: globalLoading, + error: globalError, + refetch: refetchGlobal, + } = useCachedSubgraphQuery(subgraphUrl, vestingMetrics()); + + // User-specific queries (include address in cache key to avoid collisions) + const normalizedAddress = walletAddress?.toLowerCase() || ''; + + const { + data: userResponse, + isLoading: userLoading, + error: userError, + refetch: refetchUser, + } = useCachedSubgraphQuery( + subgraphUrl, + vestingUser(normalizedAddress), + [normalizedAddress], + { enabled: shouldFilterByUser } + ); + + const { + data: schedulesResponse, + isLoading: schedulesLoading, + error: schedulesError, + refetch: refetchSchedules, + } = useCachedSubgraphQuery( + subgraphUrl, + vestingSchedules(normalizedAddress), + [normalizedAddress], + { enabled: shouldFilterByUser } + ); + + const data = useMemo((): VestingMetricsData | null => { + if (shouldFilterByUser) { + // Return user-specific metrics + if (userLoading || schedulesLoading) return null; + return parseUserMetrics(userResponse, schedulesResponse); + } else { + // Return global metrics + if (!globalResponse?.metrics?.length) { + return DEFAULT_METRICS; + } + const metrics = globalResponse.metrics[0]; + return parseMetricsData(metrics); + } + }, [ + shouldFilterByUser, + globalResponse, + userResponse, + schedulesResponse, + userLoading, + schedulesLoading, + ]); + + const loading = shouldFilterByUser + ? userLoading || schedulesLoading + : globalLoading; + + const error = shouldFilterByUser ? userError || schedulesError : globalError; + + const refetch = () => { + refetchGlobal(); + if (shouldFilterByUser) { + refetchUser(); + refetchSchedules(); + } + }; + + return { + data, + loading, + error: error ? 'Failed to fetch vesting metrics.' : null, + refetch, + }; +}; diff --git a/apps/dapp/src/components/Pages/TeamPayments/index.tsx b/apps/dapp/src/components/Pages/TeamPayments/index.tsx new file mode 100644 index 000000000..f748d0de1 --- /dev/null +++ b/apps/dapp/src/components/Pages/TeamPayments/index.tsx @@ -0,0 +1,124 @@ +import { FunctionComponent, SVGProps, useState } from 'react'; +import * as breakpoints from 'styles/breakpoints'; +import { useMediaQuery } from 'react-responsive'; +import { Outlet, useLocation } from 'react-router-dom'; +import styled from 'styled-components'; +import { queryMinTablet } from 'styles/breakpoints'; +import Footer from '../../Layouts/V2Layout/Footer'; +import LeftNav from '../../Layouts/V2Layout/Nav/LeftNav'; +import MobileNav from '../../Layouts/V2Layout/Nav/MobileNav'; +import Cash from 'assets/icons/cash.svg?react'; +import NonCash from 'assets/icons/non-cash.svg?react'; +import Vesting from 'assets/icons/vesting.svg?react'; + +export type MenuNavItem = { + label: string; + linkTo: string; + Logo: FunctionComponent< + SVGProps & { title?: string | undefined } + >; + selected: boolean; +}; + +export type MenuNavItems = Array; + +enum DashboardLocPaths { + Cash = '/team-payments/cash', + NonCash = '/team-payments/non-cash', + VestingDashboard = '/team-payments/vesting-dashboard', +} + +const TeamPayments = () => { + const isTabletOrAbove = useMediaQuery({ query: queryMinTablet }); + const loc = useLocation(); + + const [isAdmin, setIsAdmin] = useState(false); //For manual swap between admin and contributor view + + const [selectedPath, setSelectedPath] = useState(loc.pathname); + + const onSelectMenuNavItems = (selectedMenuItem: MenuNavItem) => { + setSelectedPath(selectedMenuItem.linkTo); + }; + + const menuNavItems: MenuNavItem[] = isAdmin + ? [ + { + label: 'Vesting\nDashboard', + linkTo: DashboardLocPaths.VestingDashboard, + Logo: Vesting, + selected: selectedPath === DashboardLocPaths.VestingDashboard, + }, + ] + : [ + { + label: 'Cash', + linkTo: DashboardLocPaths.Cash, + Logo: Cash, + selected: selectedPath === DashboardLocPaths.Cash, + }, + { + label: 'Non-Cash', + linkTo: DashboardLocPaths.NonCash, + Logo: NonCash, + selected: selectedPath === DashboardLocPaths.NonCash, + }, + ]; + + return ( + + + {isTabletOrAbove ? ( + + ) : ( + + )} + + + + + +