(
marginBottom: '0.5rem',
}}
>
- {label} 2025
-
-
- Vest 2: {vest2.toLocaleString()}
+ {tooltipLabelFormatter ? tooltipLabelFormatter(label) : label}
+ {payload.map((p) => {
+ const value = Number(p.value) || 0;
+ const [formattedValue, formattedLabel] =
+ tooltipValuesFormatter
+ ? tooltipValuesFormatter(value, p.dataKey as string)
+ : [value.toLocaleString(), p.dataKey];
+
+ return (
+ (
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()}
@@ -264,8 +270,8 @@ export default function LineChart
(
const key = entry.dataKey.toString();
const isHidden = hiddenLines[key];
- const color =
- key === 'vest1' ? theme.palette.brandLight : '#D0BE75';
+ const color = entry.color || theme.palette.brandLight;
+ const label = legendFormatter ? legendFormatter(key) : key;
return (
(
color,
}}
>
- {key === 'vest1' ? 'VEST 1' : 'VEST 2'}
+ {label}
);
diff --git a/apps/dapp/src/components/Pages/TeamPayments/NonCash/DataTables/ClaimHistoryDataTable.tsx b/apps/dapp/src/components/Pages/TeamPayments/NonCash/DataTables/ClaimHistoryDataTable.tsx
index de4ea5250..672d9f6ac 100644
--- a/apps/dapp/src/components/Pages/TeamPayments/NonCash/DataTables/ClaimHistoryDataTable.tsx
+++ b/apps/dapp/src/components/Pages/TeamPayments/NonCash/DataTables/ClaimHistoryDataTable.tsx
@@ -89,7 +89,7 @@ export const DataTable: React.FC = ({
) : (
filteredTransactions.map((transaction) => (
-
+
{transaction.grantDate}
{transaction.claimedTgld}
@@ -218,7 +218,7 @@ const DataCell = styled.td`
}
&:last-child {
- padding: 20px 0px 20px 0px;
+ padding: 20px 0px 20px 16px;
}
`;
diff --git a/apps/dapp/src/components/Pages/TeamPayments/NonCash/DataTables/ClaimableDataTable.tsx b/apps/dapp/src/components/Pages/TeamPayments/NonCash/DataTables/ClaimableDataTable.tsx
index 84dc8bf80..4cfa94d66 100644
--- a/apps/dapp/src/components/Pages/TeamPayments/NonCash/DataTables/ClaimableDataTable.tsx
+++ b/apps/dapp/src/components/Pages/TeamPayments/NonCash/DataTables/ClaimableDataTable.tsx
@@ -24,6 +24,7 @@ type TableProps = {
title: string;
refetch?: () => void;
dataRefetching?: boolean;
+ claimTgld: (transactionId: string) => Promise;
};
export const DataTable: React.FC = ({
@@ -32,6 +33,7 @@ export const DataTable: React.FC = ({
loading,
title,
refetch,
+ claimTgld,
}) => {
const [filter, setFilter] = useState('Last 5 Shown');
const [filteredTransactions, setFilteredTransactions] =
@@ -39,9 +41,12 @@ export const DataTable: React.FC = ({
const filterOptions = ['Last 5 Shown', 'Show All'];
useEffect(() => {
- const sortedTransactions = [...transactions].sort(
- (a, b) => Number(b.id) - Number(a.id)
- );
+ 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));
@@ -99,9 +104,16 @@ export const DataTable: React.FC = ({
) : (
filteredTransactions.map((transaction) => {
+ const shortenedId =
+ transaction.id.length > 16
+ ? `${transaction.id.slice(0, 8)}...${transaction.id.slice(
+ -6
+ )}`
+ : transaction.id;
+
return (
- {transaction.id}
+ {shortenedId}
{transaction.grantStartDate}
{transaction.grantEndDate}
{transaction.cliff}
@@ -112,6 +124,9 @@ export const DataTable: React.FC = ({
{transaction.action === 'Claim' && (
{
+ await claimTgld(transaction.id);
+ }}
>
Claim
diff --git a/apps/dapp/src/components/Pages/TeamPayments/NonCash/IntroConnected.tsx b/apps/dapp/src/components/Pages/TeamPayments/NonCash/IntroConnected.tsx
index 875f489a2..881021e14 100644
--- a/apps/dapp/src/components/Pages/TeamPayments/NonCash/IntroConnected.tsx
+++ b/apps/dapp/src/components/Pages/TeamPayments/NonCash/IntroConnected.tsx
@@ -4,54 +4,83 @@ 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
-
- setActive('current')}
- >
+ {/* Disabled Filter for now */}
+ {/*
+ setActive("current")}>
Current
- setActive('previous')}
- >
+ setActive("previous")}>
Previous
-
+ */}
- 1,000,000
-
- Total TGLD Reward
-
-
+ {loading ? (
+
+ ) : (
+ <>
+ {totalAllocated.toLocaleString()}
+
+ Total TGLD Reward
+
+
+ >
+ )}
- 500,000
- Unvested TGLD
+ {loading ? (
+
+ ) : (
+ <>
+ {totalUnvested.toLocaleString()}
+ Unvested TGLD
+ >
+ )}
- 500,000
- Vested TGLD
+ {loading ? (
+
+ ) : (
+ <>
+ {totalVested.toLocaleString()}
+ Vested TGLD
+ >
+ )}
setHasClaimed(!hasClaimed)}>
- {hasClaimed ? '0' : '45,213'}
-
- Unclaimed TGLD
-
-
+ {loading ? (
+
+ ) : (
+ <>
+ {hasClaimed ? '0' : totalClaimable.toLocaleString()}
+
+ Unclaimed TGLD
+
+
+ >
+ )}
@@ -60,7 +89,7 @@ export default function IntroConnected() {
Your Vests
- {!hasClaimed && }
+ {totalClaimable > 0 && }
);
@@ -158,8 +187,8 @@ const ToggleButton = styled.button<{ active: boolean }>`
color: ${({ active, theme }) =>
active ? theme.palette.brandLight : theme.palette.brand};
text-decoration: ${({ active }) => (active ? 'underline' : 'none')};
-t ext-decoration-color: ${({ theme }) =>
- theme.palette.brandLight}; font-size: 16px;
+ text-decoration-color: ${({ theme }) => theme.palette.brandLight};
+ font-size: 16px;
line-height: 19px;
cursor: pointer;
padding: 0;
diff --git a/apps/dapp/src/components/Pages/TeamPayments/NonCash/Tables/ClaimHistoryTable.tsx b/apps/dapp/src/components/Pages/TeamPayments/NonCash/Tables/ClaimHistoryTable.tsx
index 65e78804f..c144735ce 100644
--- a/apps/dapp/src/components/Pages/TeamPayments/NonCash/Tables/ClaimHistoryTable.tsx
+++ b/apps/dapp/src/components/Pages/TeamPayments/NonCash/Tables/ClaimHistoryTable.tsx
@@ -1,5 +1,6 @@
import styled from 'styled-components';
import { DataTable } from '../DataTables/ClaimHistoryDataTable';
+import { useClaimHistory } from '../hooks/use-claim-history';
enum TableHeaders {
GrantDate = 'Grant Date',
@@ -13,22 +14,9 @@ const tableHeaders = [
{ name: TableHeaders.TransactionLink },
];
-const data = [
- {
- grantDate: 'July 2025',
- claimedTgld: '1222000',
- transactionLink: '0x192c453a2dbb0b...0e74a056',
- transactionHash: '0x192c453a2dbb0b...0e74a056',
- },
- {
- grantDate: 'Jan 2025',
- claimedTgld: '700000',
- transactionLink: '0x342c4535430979a...0b6b8b25',
- transactionHash: '0x342c4535430979a...0b6b8b25',
- },
-];
-
export const ClaimHistory = () => {
+ const { data } = useClaimHistory();
+
return (
{
+ const { data, loading, claimTgld } = useClaimableTGLD();
+
return (
);
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
index 87f7fdcec..e32dcb0f4 100644
--- a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Chart/BarChart.tsx
+++ b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Chart/BarChart.tsx
@@ -19,6 +19,7 @@ export type BarChartProps = {
tooltipLabelFormatter: (value: any) => string;
tooltipValuesFormatter?: (props: any) => string[];
yDomain?: AxisDomain;
+ yTicks?: number[];
series: { key: string; color: string }[];
lineDataKey?: DataKey;
};
@@ -31,11 +32,30 @@ export default function CustomBarChart({
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',
@@ -87,8 +107,9 @@ export default function CustomBarChart({
({
{
+ 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}
- style={{
- fontFamily: 'Caviar Dreams',
- fontSize: '12px',
- fontWeight: '400',
- fill: theme.palette.brandLight,
- }}
- ticks={[0, 500_000, 1_000_000, 1_500_000]}
/>
} offset={30} cursor={false} />
{series.map((serie, index) => {
diff --git a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Chart/Chart.tsx b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Chart/Chart.tsx
index 32f7252c2..884098af6 100644
--- a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Chart/Chart.tsx
+++ b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Chart/Chart.tsx
@@ -1,161 +1,178 @@
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;
- value1: number;
- value2: number;
- value3: number;
- value4: number;
- runway: number;
+ total?: number; // Total line above bars
+ [key: string]: number | undefined; // Dynamic keys for users
};
-const tickFormatter = (timestamp: number): string =>
- format(new Date(timestamp), 'MMM');
+const ChartContainer = ({ children }: { children: React.ReactNode }) => (
+
+
+ Projected TGLD Vesting By Month
+
+
+ {children}
+
+);
+
+const LoadingState = () => (
+
+
+
+
+
+);
+
+const EmptyState = () => (
+ No vesting data available
+);
+
+type ProjectedTGLDVestingProps = {
+ walletAddress?: string;
+};
-const series: { key: keyof Metric; color: string }[] = [
- { key: 'value1', color: '#FFDEC9' },
- { key: 'value2', color: '#D0BE75' },
- { key: 'value3', color: '#BD7B4F' },
- { key: 'value4', color: '#95613F' },
-];
-
-const vestedTGLDData = [
- {
- timestamp: 1704067200000,
- value1: 200000,
- value2: 250000,
- value3: 300000,
- value4: 246920,
- },
- {
- timestamp: 1706745600000,
- value1: 200000,
- value2: 250000,
- value3: 250000,
- value4: 200000,
- },
- {
- timestamp: 1709251200000,
- value1: 220000,
- value2: 270000,
- value3: 240000,
- value4: 210000,
- },
- {
- timestamp: 1711939200000,
- value1: 230000,
- value2: 150000,
- value3: 200000,
- value4: 120000,
- },
- {
- timestamp: 1714521600000,
- value1: 250000,
- value2: 100000,
- value3: 150000,
- value4: 100000,
- },
- {
- timestamp: 1717200000000,
- value1: 200000,
- value2: 230000,
- value3: 200000,
- value4: 180000,
- },
- {
- timestamp: 1719792000000,
- value1: 300000,
- value2: 250000,
- value3: 200000,
- value4: 150000,
- },
- {
- timestamp: 1722470400000,
- value1: 180000,
- value2: 240000,
- value3: 280000,
- value4: 210000,
- },
- {
- timestamp: 1725062400000,
- value1: 150000,
- value2: 130000,
- value3: 170000,
- value4: 120000,
- },
- {
- timestamp: 1727740800000,
- value1: 400000,
- value2: 330000,
- value3: 250000,
- value4: 180000,
- },
- {
- timestamp: 1730332800000,
- value1: 100000,
- value2: 200000,
- value3: 230000,
- value4: 180000,
- },
- {
- timestamp: 1733011200000,
- value1: 250000,
- value2: 180000,
- value3: 200000,
- value4: 150000,
- },
-];
-
-export const ProjectedTGLDVesting = () => {
- const metrics: Metric[] = vestedTGLDData.map((item) => ({
- ...item,
- runway: item.value1 + item.value2 + item.value3 + item.value4 + 200000,
- }));
-
- const totalPerMonth = metrics.map(
- (item) => item.value1 + item.value2 + item.value3 + item.value4
+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 tooltipFormatterFn = (data: Metric): string[] => {
- const grantValue = data.value4 ?? 0;
- const monthTotal = data.value1 + data.value2 + data.value3 + data.value4;
- const percent = monthTotal
- ? ((grantValue / monthTotal) * 100).toFixed(0)
- : '0';
- const dateLabel = tickFormatter(data.timestamp);
-
- return [
- `Amount to be vested: ${grantValue.toLocaleString()} TGLD`,
- `Percentage of total amount to be vested in ${dateLabel} 2024:`,
- `${grantValue.toLocaleString()} / ${monthTotal.toLocaleString()} = ${percent}%`,
- ];
- };
+ const tooltipLabelFormatter = useCallback(() => 'Grant ID', []);
- return (
-
-
- Projected TGLD Vesting By Month
-
-
+ 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={metrics}
+ chartData={chartData}
series={series}
xDataKey="timestamp"
xTickFormatter={tickFormatter}
- yTickFormatter={(val) => `${formatNumberAbbreviated(val).string} TGLD`}
- tooltipLabelFormatter={() => 'Grant ID'}
- tooltipValuesFormatter={tooltipFormatterFn}
- yDomain={[0, 1_500_000]}
- lineDataKey="runway"
+ yTickFormatter={yTickFormatter}
+ tooltipLabelFormatter={tooltipLabelFormatter}
+ tooltipValuesFormatter={tooltipValuesFormatter}
+ yDomain={yDomain}
+ yTicks={yTicks}
+ lineDataKey="total"
/>
-
+
);
};
@@ -196,3 +213,10 @@ 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
index 8224c175f..d0e699182 100644
--- a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/DataTables/ClaimHistoryDataTable.tsx
+++ b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/DataTables/ClaimHistoryDataTable.tsx
@@ -1,4 +1,3 @@
-import env from 'constants/env';
import React, { useEffect, useState } from 'react';
import styled from 'styled-components';
import * as breakpoints from 'styles/breakpoints';
@@ -12,6 +11,7 @@ export type Transaction = {
granteeAddress: string;
transactionLink: string;
transactionHash: string;
+ displayHash: string;
};
type TableHeader = { name: string };
@@ -104,9 +104,9 @@ export const DataTable: React.FC = ({
- {transaction.transactionLink}
+ {transaction.displayHash}
@@ -178,6 +178,7 @@ const TableHeader = styled.th`
font-weight: 700;
line-height: 20px;
text-align: left;
+ width: 25%;
color: ${({ theme }) => theme.palette.brand};
position: sticky;
top: 0;
@@ -208,7 +209,8 @@ const DataCell = styled.td`
font-weight: 700;
line-height: 20px;
text-align: left;
- width: 33%;
+ width: 25%;
+ white-space: nowrap;
color: ${({ theme }) => theme.palette.brandLight};
a {
@@ -226,7 +228,7 @@ const DataCell = styled.td`
}
&:last-child {
- padding: 20px 0px 20px 0px;
+ padding: 20px 0px 20px 16px;
}
`;
diff --git a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Tables/ClaimHistoryTable.tsx b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Tables/ClaimHistoryTable.tsx
index 544873327..28ad0ce5b 100644
--- a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Tables/ClaimHistoryTable.tsx
+++ b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/Tables/ClaimHistoryTable.tsx
@@ -1,5 +1,6 @@
import styled from 'styled-components';
import { DataTable } from '../DataTables/ClaimHistoryDataTable';
+import { useAllClaimHistory } from '../hooks/use-all-claim-history';
enum TableHeaders {
GrantDate = 'Grant Date',
@@ -15,30 +16,16 @@ const tableHeaders = [
{ name: TableHeaders.TransactionLink },
];
-const data = [
- {
- grantDate: 'July 2025',
- claimedTgld: '1222000',
- granteeAddress: 'x112938091',
- transactionLink: '0x192c453a2dbb0b...0e74a056',
- transactionHash: '0x192c453a2dbb0b...0e74a056',
- },
- {
- grantDate: 'Jan 2025',
- claimedTgld: '700000',
- granteeAddress: 'x817390910',
- transactionLink: '0x342c4535430979a...0b6b8b25',
- transactionHash: '0x342c4535430979a...0b6b8b25',
- },
-];
+export const ClaimHistory = ({ walletAddress }: { walletAddress?: string }) => {
+ // Fetch claim transactions (all users or filtered by walletAddress)
+ const { transactions, loading } = useAllClaimHistory(walletAddress);
-export const ClaimHistory = () => {
return (
);
diff --git a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/VestingDashboard.tsx b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/VestingDashboard.tsx
index 25c4d8111..1929a6ae7 100644
--- a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/VestingDashboard.tsx
+++ b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/VestingDashboard.tsx
@@ -4,31 +4,74 @@ 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
-
+ {/* */}
-
- 1,000,000
- Total TGLD Vested / Claimable
-
-
- 500,000
- Total TGLD Claimed
-
+
+
-
-
+
+
);
}
@@ -97,6 +140,6 @@ const Sum = styled.div`
const SearchBar = styled.div`
display: flex;
- flex-direction: row;
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
index 7222d73b3..15bf571e6 100644
--- a/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/components/Input.tsx
+++ b/apps/dapp/src/components/Pages/TeamPayments/VestingDashboard/components/Input.tsx
@@ -19,6 +19,7 @@ export const SearchInput: React.FC = ({
onChange(e.target.value)}
@@ -37,7 +38,6 @@ const SearchInputWrapper = styled.div<{ disabled?: boolean }>`
border-radius: 5px;
padding: 5px 10px;
gap: 10px;
- width: 220px;
box-sizing: border-box;
${({ disabled, theme }) =>
@@ -49,7 +49,7 @@ const SearchInputWrapper = styled.div<{ disabled?: boolean }>`
`;
const SearchIcon = styled(Search)`
- widht: 18px;
+ width: 18px;
height: 18px;
`;
@@ -59,12 +59,11 @@ const StyledInput = styled.input`
background-color: transparent;
border: none;
outline: none;
- width: 100%;
font-family: Caviar Dreams;
font-weight: 400;
font-size: 16px;
line-height: 120%;
- letter-spacing: 5%;
+ width: 165px;
&::placeholder {
color: ${({ theme }) => 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/constants/env/production.tsx b/apps/dapp/src/constants/env/production.tsx
index 2db423bc4..d1a1a23cd 100644
--- a/apps/dapp/src/constants/env/production.tsx
+++ b/apps/dapp/src/constants/env/production.tsx
@@ -204,18 +204,6 @@ const env: Environment = {
name: 'Epoch 29a',
address: '0xc5Db76bE904f73BA3094a43D7012d43e24768Ce0',
},
- {
- name: 'Epoch 29b',
- address: '0x8E6FE89bD760EED6045aDEE742253369453B1956',
- },
- {
- name: 'Epoch 29c',
- address: '0xb1A0434c8d542E286d6941Bb17290272E4Ca47Bd',
- },
- {
- name: 'Epoch 30a',
- address: '0xD7c7e600dC8D55c274d04d337A3b3F0D54886C71',
- },
],
temple: '0x470ebf5f030ed85fc1ed4c2d36b9dd02e77cf1b7',
templegold: '0x0E7B53dDe30754A94D4B10C9CdCaCA1C749ECd1b',
diff --git a/apps/dapp/src/constants/newenv/prod.ts b/apps/dapp/src/constants/newenv/prod.ts
index 7663e225b..7f766f20f 100644
--- a/apps/dapp/src/constants/newenv/prod.ts
+++ b/apps/dapp/src/constants/newenv/prod.ts
@@ -16,6 +16,8 @@ import {
TempleLineOfCredit__factory,
TreasuryReservesVault,
TreasuryReservesVault__factory,
+ VestingPayments,
+ VestingPayments__factory,
} from 'types/typechain';
import { TempleGoldStaking__factory } from 'types/typechain';
@@ -251,6 +253,12 @@ const prodEnv: AppConfig = {
trv: TRV_ON_ETH_MAINNET,
daiCircuitBreaker: DAICIRCUITBREAKER_ON_ETH_MAINNET,
templeCircuitBreaker: TEMPLECIRCUITBREAKER_ON_ETH_MAINNET,
+ vestingPayments: {
+ // TODO: Add production VestingPayments contract address
+ chainId: ETH_MAINNET.id,
+ address: '0x0000000000000000000000000000000000000000',
+ contractFactory: VestingPayments__factory,
+ },
},
spiceBazaar: {
spiceAuctions: [
@@ -271,6 +279,10 @@ const prodEnv: AppConfig = {
altchainDisplayName: 'Berachain',
},
},
+ vesting: {
+ // TODO: Add production vesting subgraph URL
+ subgraphUrl: '',
+ },
};
export function getProdAppConfig(): AppConfig {
diff --git a/apps/dapp/src/constants/newenv/test.ts b/apps/dapp/src/constants/newenv/test.ts
index 3ed4713d0..ca4aa328e 100644
--- a/apps/dapp/src/constants/newenv/test.ts
+++ b/apps/dapp/src/constants/newenv/test.ts
@@ -15,6 +15,8 @@ import {
TempleLineOfCredit__factory,
TreasuryReservesVault,
TreasuryReservesVault__factory,
+ VestingPayments,
+ VestingPayments__factory,
} from 'types/typechain';
import { AppConfig, Chain, ContractConfig, TokenConfig } from './types';
import { TICKER_SYMBOL } from 'enums/ticker-symbol';
@@ -29,6 +31,10 @@ const RPC_KEY = ENV_VARS.VITE_RPC_KEY;
const ARBIBRUM_SPICE_BAZAAR_SUBGRAPH_URL =
'https://subgraph.satsuma-prod.com/a912521dd162/templedao/spice-bazaar-arb-sepolia/api';
+// Vesting Payments Subgraph
+const VESTING_SUBGRAPH_URL =
+ 'https://subgraph.satsuma-prod.com/a912521dd162/templedao/spice-bazaar-sepolia/api';
+
const ETH_SEPOLIA: Chain = {
name: 'Ethereum Sepolia',
id: 11155111,
@@ -309,6 +315,12 @@ const SPICE_TOKEN_ON_ARBITRUM_SEPOLIA: TokenConfig = {
symbol: 'SPICE',
};
+const VESTING_PAYMENTS_ON_ETH_SEPOLIA: ContractConfig = {
+ chainId: ETH_SEPOLIA.id,
+ address: '0xD96595caDE5AADa556F34F546b2992E702aA43e3',
+ contractFactory: VestingPayments__factory,
+};
+
const testEnv: AppConfig = {
chains: [ETH_SEPOLIA, BERACHAIN_BEPOLIA, ARB_SEPOLIA],
tokens: {
@@ -340,6 +352,7 @@ const testEnv: AppConfig = {
trv: TREASURY_RESERVES_VAULT_ON_ETH_SEPOLIA,
daiCircuitBreaker: DAI_CIRCUIT_BREAKER_CONTRACT_ON_ETH_SEPOLIA,
templeCircuitBreaker: TEMPLE_CIRCUIT_BREAKER_CONTRACT_ON_ETH_SEPOLIA,
+ vestingPayments: VESTING_PAYMENTS_ON_ETH_SEPOLIA,
},
spiceBazaar: {
// TODO: For now, we only have one active spice auction. Eventually, could be many. So we use an array.
@@ -366,6 +379,9 @@ const testEnv: AppConfig = {
altchainDisplayName: 'Arbitrum',
},
},
+ vesting: {
+ subgraphUrl: VESTING_SUBGRAPH_URL,
+ },
};
export function getTestAppConfig(): AppConfig {
diff --git a/apps/dapp/src/constants/newenv/types.ts b/apps/dapp/src/constants/newenv/types.ts
index 749c83091..23c427312 100644
--- a/apps/dapp/src/constants/newenv/types.ts
+++ b/apps/dapp/src/constants/newenv/types.ts
@@ -69,6 +69,7 @@ export type AppConfig = {
trv: ContractConfig;
daiCircuitBreaker: ContractConfig;
templeCircuitBreaker: ContractConfig;
+ vestingPayments: ContractConfig;
};
spiceBazaar: {
spiceAuctions: SpiceAuctionConfig[];
@@ -82,6 +83,9 @@ export type AppConfig = {
altchainDisplayName: string;
};
};
+ vesting: {
+ subgraphUrl: string;
+ };
};
type contractFactory = {
diff --git a/apps/dapp/src/providers/SpiceBazaarProvider.tsx b/apps/dapp/src/providers/SpiceBazaarProvider.tsx
index a8f91e0a7..f4c075e38 100644
--- a/apps/dapp/src/providers/SpiceBazaarProvider.tsx
+++ b/apps/dapp/src/providers/SpiceBazaarProvider.tsx
@@ -28,7 +28,7 @@ export type StakePageMetrics = {
stakedTemple: number;
circulatingSupply: number;
totalEpochRewards: number;
- yourStake: BigNumber;
+ yourStake: number;
yourRewards: number;
};
@@ -49,7 +49,6 @@ interface SpiceBazaarContextValue {
stakePageMetrics: {
data: StakePageMetrics;
loading: boolean;
- error: boolean;
fetch: () => Promise;
};
staking: {
@@ -90,11 +89,10 @@ const INITIAL_STATE: SpiceBazaarContextValue = {
stakedTemple: 0,
circulatingSupply: 0,
totalEpochRewards: 0,
- yourStake: BigNumber.from(0),
+ yourStake: 0,
yourRewards: 0,
},
loading: false,
- error: false,
fetch: asyncNoop,
},
staking: {
@@ -219,7 +217,7 @@ export const SpiceBazaarProvider = ({ children }: PropsWithChildren) => {
const getYourStake = useCallback(async () => {
if (!wallet) {
- return BigNumber.from(0);
+ return 0;
}
try {
@@ -228,10 +226,10 @@ export const SpiceBazaarProvider = ({ children }: PropsWithChildren) => {
)) as TempleGoldStaking;
const balance = await templeGoldStakingContract.balanceOf(wallet);
- return balance;
+ return fromAtto(balance);
} catch (error) {
console.error('Error fetching stake balance:', error);
- return BigNumber.from(0);
+ return 0;
}
}, [wallet, papi]);
@@ -254,13 +252,7 @@ export const SpiceBazaarProvider = ({ children }: PropsWithChildren) => {
}, [wallet, papi]);
const fetchMetrics = useCallback(async (): Promise => {
- const [
- stakedTemple,
- circulatingSupply,
- totalEpochRewards,
- yourStake,
- yourRewards,
- ] = await Promise.all([
+ const allMetrics = await Promise.allSettled([
getStakedTemple(),
getCirculatingSupply(),
getTotalEpochRewards(),
@@ -268,12 +260,16 @@ export const SpiceBazaarProvider = ({ children }: PropsWithChildren) => {
getYourRewards(),
]);
+ const metricValues = allMetrics.map((metric) =>
+ metric.status === 'fulfilled' ? metric.value : 0
+ );
+
return {
- stakedTemple,
- circulatingSupply,
- totalEpochRewards,
- yourStake,
- yourRewards,
+ stakedTemple: metricValues[0],
+ circulatingSupply: metricValues[1],
+ totalEpochRewards: metricValues[2],
+ yourStake: metricValues[3],
+ yourRewards: metricValues[4],
};
}, [
getStakedTemple,
@@ -286,7 +282,6 @@ export const SpiceBazaarProvider = ({ children }: PropsWithChildren) => {
const {
data: stakePageMetricsData,
isLoading: stakePageMetricsLoading,
- isError: stakePageMetricsError,
refetch: refetchStakePageMetrics,
} = useQuery({
queryKey: ['stakePageMetrics', wallet ? wallet : 'no-wallet'],
@@ -977,7 +972,6 @@ export const SpiceBazaarProvider = ({ children }: PropsWithChildren) => {
stakePageMetrics: {
data: stakePageMetrics,
loading: stakePageMetricsLoading,
- error: stakePageMetricsError,
fetch: fetchStakePageMetrics,
},
staking: {
diff --git a/apps/dapp/src/utils/subgraph.ts b/apps/dapp/src/utils/subgraph.ts
index 00e4286fe..e45c8d749 100644
--- a/apps/dapp/src/utils/subgraph.ts
+++ b/apps/dapp/src/utils/subgraph.ts
@@ -629,8 +629,11 @@ async function _rawSubgraphQuery(
console.log('subgraph-response', label, rawResults);
}
if (rawResults.errors !== undefined) {
+ console.error(`Subgraph errors for ${label}:`, rawResults.errors);
throw new Error(
- `Unable to fetch ${label} from subgraph: ${rawResults.errors}`
+ `Unable to fetch ${label} from subgraph: ${JSON.stringify(
+ rawResults.errors
+ )}`
);
}
@@ -1046,6 +1049,254 @@ export type SpiceAuctionFactoriesResp = z.infer<
//----------------------------------------------------------------------------------------------------
+export function vestingSchedules(
+ walletAddress: string
+): SubGraphQuery {
+ const label = 'vestingSchedules';
+ const request = `
+ {
+ schedules(where: { recipient: "${walletAddress.toLowerCase()}" }) {
+ id
+ start
+ cliff
+ duration
+ vested
+ released
+ revoked
+ }
+ }`;
+ return {
+ label,
+ request,
+ parse: VestingSchedulesResp.parse,
+ };
+}
+
+const VestingSchedulesResp = z.object({
+ schedules: z.array(
+ z.object({
+ id: z.string(),
+ start: z.string(),
+ cliff: z.string(),
+ duration: z.string(),
+ vested: z.string(),
+ released: z.string(),
+ revoked: z.boolean(),
+ })
+ ),
+});
+
+export type VestingSchedulesResp = z.infer;
+
+//----------------------------------------------------------------------------------------------------
+
+export function vestingUser(
+ walletAddress: string
+): SubGraphQuery {
+ const label = 'vestingUser';
+ const request = `
+ {
+ user(id: "${walletAddress.toLowerCase()}") {
+ vestedAmount
+ releasedAmount
+ }
+ }`;
+ return {
+ label,
+ request,
+ parse: VestingUserResp.parse,
+ };
+}
+
+const VestingUserResp = z.object({
+ user: z
+ .object({
+ vestedAmount: z.string(),
+ releasedAmount: z.string(),
+ })
+ .nullable(),
+});
+
+export type VestingUserResp = z.infer;
+
+//----------------------------------------------------------------------------------------------------
+
+export function vestingMetrics(): SubGraphQuery {
+ const label = 'vestingMetrics';
+ const request = `
+ {
+ metrics {
+ totalVestedAndUnclaimed
+ totalReleased
+ }
+ }`;
+ return {
+ label,
+ request,
+ parse: VestingMetricsResp.parse,
+ };
+}
+
+const VestingMetricsResp = z.object({
+ metrics: z.array(
+ z.object({
+ totalVestedAndUnclaimed: z.string(),
+ totalReleased: z.string(),
+ })
+ ),
+});
+
+export type VestingMetricsResp = z.infer;
+
+//----------------------------------------------------------------------------------------------------
+
+export function allReleaseTransactions(): SubGraphQuery {
+ const label = 'allReleaseTransactions';
+ const request = `
+ {
+ releaseTransactions(orderBy: timestamp, orderDirection: desc, first: 100) {
+ id
+ timestamp
+ hash
+ releasedAmount
+ user {
+ id
+ }
+ schedule {
+ start
+ }
+ }
+ }`;
+ return {
+ label,
+ request,
+ parse: AllReleaseTransactionsResp.parse,
+ };
+}
+
+//----------------------------------------------------------------------------------------------------
+
+export function allVestingSchedules(): SubGraphQuery {
+ const label = 'allVestingSchedules';
+ const request = `
+ {
+ schedules(first: 1000, where: { revoked: false }) {
+ id
+ start
+ cliff
+ duration
+ vested
+ released
+ revoked
+ recipient {
+ id
+ }
+ }
+ }`;
+ return {
+ label,
+ request,
+ parse: AllVestingSchedulesResp.parse,
+ };
+}
+
+const AllVestingSchedulesResp = z.object({
+ schedules: z.array(
+ z.object({
+ id: z.string(),
+ start: z.string(),
+ cliff: z.string(),
+ duration: z.string(),
+ vested: z.string(),
+ released: z.string(),
+ revoked: z.boolean(),
+ recipient: z.object({
+ id: z.string(),
+ }),
+ })
+ ),
+});
+
+export type AllVestingSchedulesResp = z.infer;
+
+const AllReleaseTransactionsResp = z.object({
+ releaseTransactions: z.array(
+ z.object({
+ id: z.string(),
+ timestamp: z.string(),
+ hash: z.string(),
+ releasedAmount: z.string(),
+ user: z.object({
+ id: z.string(),
+ }),
+ schedule: z.object({
+ start: z.string(),
+ }),
+ })
+ ),
+});
+
+export type AllReleaseTransactionsResp = z.infer<
+ typeof AllReleaseTransactionsResp
+>;
+
+//----------------------------------------------------------------------------------------------------
+
+export function userReleaseTransactions(
+ walletAddress: string
+): SubGraphQuery {
+ const label = 'userReleaseTransactions';
+ const request = `
+ {
+ user(id: "${walletAddress.toLowerCase()}") {
+ transactions(orderBy: timestamp, orderDirection: desc) {
+ ... on ReleaseTransaction {
+ id
+ timestamp
+ name
+ hash
+ releasedAmount
+ schedule {
+ start
+ }
+ }
+ }
+ }
+ }`;
+ return {
+ label,
+ request,
+ parse: UserReleaseTransactionsResp.parse,
+ };
+}
+
+const UserReleaseTransactionsResp = z.object({
+ user: z
+ .object({
+ transactions: z.array(
+ z.object({
+ id: z.string().optional(),
+ timestamp: z.string().optional(),
+ name: z.string().optional(),
+ hash: z.string().optional(),
+ releasedAmount: z.string().optional(),
+ schedule: z
+ .object({
+ start: z.string(),
+ })
+ .optional(),
+ })
+ ),
+ })
+ .nullable(),
+});
+
+export type UserReleaseTransactionsResp = z.infer<
+ typeof UserReleaseTransactionsResp
+>;
+
+//----------------------------------------------------------------------------------------------------
+
export function userTransactionsSpiceAuctions(
id: string
): SubGraphQuery {
@@ -1361,6 +1612,7 @@ export type SpiceAuctionResp = z.infer;
//----------------------------------------------------------------------------------------------------
export const TTL_IN_SECONDS = 30;
+export const CACHE_TIME_IN_SECONDS = 5 * 60;
export const cachedSubgraphQuery = async (
subgraphUrl: string,
@@ -1372,7 +1624,7 @@ export const cachedSubgraphQuery = async (
queryKey: [subgraphUrl, query.label] as const,
queryFn: () => subgraphQuery(subgraphUrl, query),
staleTime: TTL_IN_SECONDS * 1000,
- cacheTime: TTL_IN_SECONDS * 1000,
+ cacheTime: CACHE_TIME_IN_SECONDS * 1000,
});
return result;
@@ -1381,12 +1633,15 @@ export const cachedSubgraphQuery = async (
// Hook version for use in React components
export const useCachedSubgraphQuery = (
subgraphUrl: string,
- query: SubGraphQuery
+ query: SubGraphQuery,
+ additionalKeys: readonly unknown[] = [],
+ options?: { enabled?: boolean }
) => {
return useQuery({
- queryKey: [subgraphUrl, query.label] as const,
+ queryKey: [subgraphUrl, query.label, ...additionalKeys] as const,
queryFn: () => subgraphQuery(subgraphUrl, query),
staleTime: TTL_IN_SECONDS * 1000,
- cacheTime: TTL_IN_SECONDS * 1000,
+ cacheTime: CACHE_TIME_IN_SECONDS * 1000,
+ enabled: options?.enabled !== false, // Default to true, only disable if explicitly false
});
};