diff --git a/frontend/src/pages/EmployeePortal.tsx b/frontend/src/pages/EmployeePortal.tsx index e4ccf4da..ed3f9d3e 100644 --- a/frontend/src/pages/EmployeePortal.tsx +++ b/frontend/src/pages/EmployeePortal.tsx @@ -25,6 +25,16 @@ import { import { claimService, ClaimableBalance } from '../services/claimableBalance'; import styles from './EmployeePortal.module.css'; import { useWallet } from '../hooks/useWallet'; +import { + BASE_FEE, + Contract, + Networks, + rpc, + TransactionBuilder, + scValToNative, +} from '@stellar/stellar-sdk'; +import { useSorobanContract } from '../hooks/useSorobanContract'; +import { useNotification } from '../hooks/useNotification'; /* ── Pending Claims Section ──────── */ function PendingClaimsSection() { @@ -125,6 +135,188 @@ function PendingClaimsSection() { ); } +/* ── Vesting claim: module-level constants ── */ +const VESTING_CONTRACT_ID = + (import.meta.env.VITE_VESTING_ESCROW_CONTRACT_ID as string | undefined) ?? ''; +const SOROBAN_RPC_URL = + (import.meta.env.PUBLIC_STELLAR_RPC_URL as string | undefined)?.replace(/\/+$/, '') ?? + 'https://soroban-testnet.stellar.org'; +const VESTING_NETWORK_PASSPHRASE = + (import.meta.env.PUBLIC_STELLAR_NETWORK as string | undefined)?.toUpperCase() === 'MAINNET' + ? Networks.PUBLIC + : Networks.TESTNET; + +/** Formats a Soroban i128 token amount (7 decimal places per Stellar convention). */ +function formatVestingAmount(raw: bigint): string { + const units = Number(raw) / 1e7; + return units.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 7 }); +} + +/* ── Vesting Claim Section ────────── */ +function VestingClaimSection() { + const { address } = useWallet(); + const { notifySuccess } = useNotification(); + const { invoke: invokeClaim, loading: isClaiming } = + useSorobanContract(VESTING_CONTRACT_ID); + + const [claimableAmount, setClaimableAmount] = React.useState(null); + const [isLoadingAmount, setIsLoadingAmount] = React.useState(false); + const [claimHistory, setClaimHistory] = React.useState([]); + const [isLoadingHistory, setIsLoadingHistory] = React.useState(false); + + const fetchClaimableAmount = React.useCallback(async () => { + if (!address || !VESTING_CONTRACT_ID) return; + setIsLoadingAmount(true); + try { + const rpcServer = new rpc.Server(SOROBAN_RPC_URL, { + allowHttp: SOROBAN_RPC_URL.startsWith('http://'), + }); + const account = await rpcServer.getAccount(address); + const contract = new Contract(VESTING_CONTRACT_ID); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase: VESTING_NETWORK_PASSPHRASE, + }) + .addOperation(contract.call('get_claimable_amount')) + .setTimeout(60) + .build(); + const simResult = await rpcServer.simulateTransaction(tx); + if (rpc.Api.isSimulationSuccess(simResult) && simResult.result?.retval) { + setClaimableAmount(scValToNative(simResult.result.retval) as bigint); + } else { + setClaimableAmount(0n); + } + } catch { + setClaimableAmount(null); + } finally { + setIsLoadingAmount(false); + } + }, [address]); + + const fetchClaimHistory = React.useCallback(async () => { + if (!address) return; + setIsLoadingHistory(true); + try { + const result = await claimService.getEmployeeClaims(Number(address), { + status: 'claimed', + limit: 20, + }); + setClaimHistory(result.data.filter((c) => c.status === 'claimed')); + } catch { + setClaimHistory([]); + } finally { + setIsLoadingHistory(false); + } + }, [address]); + + React.useEffect(() => { + void fetchClaimableAmount(); + void fetchClaimHistory(); + }, [fetchClaimableAmount, fetchClaimHistory]); + + const handleClaim = async () => { + await invokeClaim({ method: 'claim', args: [] }); + notifySuccess('Tokens claimed!', 'Your vested tokens have been transferred to your wallet.'); + void fetchClaimableAmount(); + void fetchClaimHistory(); + }; + + // Don't render if vesting is not configured or wallet is disconnected + if (!VESTING_CONTRACT_ID || !address) return null; + + const isClaimDisabled = + isClaiming || isLoadingAmount || !claimableAmount || claimableAmount <= 0n; + + return ( +
+ {/* Header */} +
+
+ +

Vested Token Claim

+
+ +
+ + {/* Claimable balance row */} +
+
+

Claimable Now

+ {isLoadingAmount ? ( +
+ ) : ( +

+ {claimableAmount != null ? formatVestingAmount(claimableAmount) : '—'} +

+ )} +
+ +
+ + {/* Claim history */} + {(isLoadingHistory || claimHistory.length > 0) && ( +
+

+ Claim History +

+ {isLoadingHistory ? ( +
+ ) : ( +
+ {claimHistory.map((claim) => ( +
+
+ + + {claimService.formatClaimAmount(claim.amount, claim.asset_code)} + +
+ {claim.claimed_at && ( + + {new Date(claim.claimed_at).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + )} +
+ ))} +
+ )} +
+ )} +
+ ); +} + /* ── Helper: status badge ────────── */ function StatusBadge({ status }: { status: EmployeeTransaction['status'] }) { const map = { @@ -266,6 +458,9 @@ const EmployeePortal: React.FC = () => { {/* ── Pending Claims Section ─────── */} + {/* ── Vesting Claim Section ──────── */} + + {/* ── Stats Cards ──────────────── */}