row.toggleSelected(!!value)}
+ // aria-label="Select row"
+ // className="mr-2"
+ // />
+ // ),
+ // enableSorting: false,
+ // enableHiding: false,
+ // },
+ {
+ accessorKey: 'validatorId',
+ header: ({ column }) => ,
+ cell: ({ row }) => row.original.validatorId,
+ size: 100,
+ },
+ {
+ accessorKey: 'balance',
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+
+ ),
+ },
+ {
+ accessorKey: 'totalRewarded',
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+
+ ),
+ },
+ {
+ accessorKey: 'rewardTokenBalance',
+ header: ({ column }) => (
+
+ ),
+ cell: ({ row }) => {
+ const validator = validators.find((v) => v.id === row.original.validatorId)
+ const { rewardTokenId } = validator?.config || {}
+ if (!rewardTokenId || Number(rewardTokenId) === 0) return '--'
+ return {row.original.rewardTokenBalance || 0}
+ },
+ },
+ {
+ accessorKey: 'entryTime',
+ header: ({ column }) => ,
+ cell: ({ row }) => (
+
+ {dayjs.unix(row.original.entryTime).format('lll')}
+
+ ),
+ },
+ {
+ id: 'actions',
+ cell: ({ row }) => {
+ const validatorId = row.original.validatorId
+ const validator = validators.find((v) => v.id === validatorId)
+
+ if (!validator || !activeAddress) return null
+
+ const stakingDisabled = isStakingDisabled(validator, constraints)
+ const unstakingDisabled = isUnstakingDisabled(validator, stakesByValidator)
+ const canManage = canManageValidator(validator, activeAddress)
+
+ const isDevelopment = process.env.NODE_ENV === 'development'
+ const canSimulateEpoch = isDevelopment && canManage
+
+ return (
+
+ setAddStakeValidator(validator)}
+ disabled={stakingDisabled}
+ >
+ Stake
+
+ setUnstakeValidator(validator)}>
+ Unstake
+
+
+
+
+
+ Open menu
+
+
+
+
+
+ setAddStakeValidator(validator)}
+ disabled={stakingDisabled}
+ >
+ Stake
+
+ setUnstakeValidator(validator)}
+ disabled={unstakingDisabled}
+ >
+ Unstake
+
+
+
+ {canSimulateEpoch && (
+ <>
+
+
+
+ await simulateEpoch(
+ validator,
+ row.original.pools,
+ 100,
+ transactionSigner,
+ activeAddress,
+ queryClient,
+ router,
+ )
+ }
+ disabled={unstakingDisabled}
+ >
+
+ Simulate Epoch
+
+
+ >
+ )}
+
+
+
+ )
+ },
+ },
+ ]
+
+ const table = useReactTable({
+ data: stakesByValidator,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ onSortingChange: setSorting,
+ getSortedRowModel: getSortedRowModel(),
+ onColumnFiltersChange: setColumnFilters,
+ getFilteredRowModel: getFilteredRowModel(),
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ },
+ })
+
+ return (
+ <>
+
+
+
My Stakes
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(header.column.columnDef.header, header.getContext())}
+
+ )
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ {isLoading ? 'Loading...' : 'No results'}
+
+
+ )}
+
+
+
+
+ {/* {table.getFilteredRowModel().rows.length > 0 && (
+
+
+ {table.getFilteredSelectedRowModel().rows.length} of{' '}
+ {table.getFilteredRowModel().rows.length} row(s) selected.
+
+
+ )} */}
+
+
+
+
+ >
+ )
+}
diff --git a/ui/src/components/UnstakeModal.tsx b/ui/src/components/UnstakeModal.tsx
new file mode 100644
index 00000000..291c667e
--- /dev/null
+++ b/ui/src/components/UnstakeModal.tsx
@@ -0,0 +1,400 @@
+import { AlgoAmount } from '@algorandfoundation/algokit-utils/types/amount'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { useQueryClient } from '@tanstack/react-query'
+import { useRouter } from '@tanstack/react-router'
+import { useWallet } from '@txnlab/use-wallet-react'
+import { ArrowDownLeft } from 'lucide-react'
+import * as React from 'react'
+import { useForm } from 'react-hook-form'
+import { toast } from 'sonner'
+import { z } from 'zod'
+import { removeStake } from '@/api/contracts'
+import { AlgoDisplayAmount } from '@/components/AlgoDisplayAmount'
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import { StakerPoolData, StakerValidatorData } from '@/interfaces/staking'
+import { Validator } from '@/interfaces/validator'
+import { formatAlgoAmount } from '@/utils/format'
+
+interface UnstakeModalProps {
+ validator: Validator | null
+ setValidator: React.Dispatch>
+ stakesByValidator: StakerValidatorData[]
+}
+
+export function UnstakeModal({ validator, setValidator, stakesByValidator }: UnstakeModalProps) {
+ const [isSigning, setIsSigning] = React.useState(false)
+ const [selectedPoolId, setSelectedPoolId] = React.useState('')
+
+ const stakerPoolsData = React.useMemo(
+ () => stakesByValidator.find((data) => data.validatorId === validator?.id)?.pools || [],
+ [stakesByValidator, validator],
+ )
+
+ React.useEffect(() => {
+ if (stakerPoolsData.length > 0 && selectedPoolId === '') {
+ setSelectedPoolId(stakerPoolsData[0].poolKey.poolId.toString())
+ }
+ }, [stakerPoolsData])
+
+ const queryClient = useQueryClient()
+ const router = useRouter()
+ const { transactionSigner, activeAddress } = useWallet()
+
+ const formSchema = z.object({
+ amountToUnstake: z
+ .string()
+ .refine((val) => val !== '', {
+ message: 'Required field',
+ })
+ .refine((val) => !isNaN(Number(val)) && parseFloat(val) > 0, {
+ message: 'Invalid amount',
+ })
+ .superRefine((val, ctx) => {
+ const algoAmount = parseFloat(val)
+ const amountToUnstake = AlgoAmount.Algos(algoAmount).microAlgos
+ const stakerPoolData = stakerPoolsData.find(
+ (p) => p.poolKey.poolId === Number(selectedPoolId),
+ )
+
+ if (stakerPoolData && validator) {
+ const currentBalance = stakerPoolData.balance
+ const minimumStake = Number(validator.config.minEntryStake)
+
+ if (amountToUnstake > currentBalance) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.too_big,
+ maximum: currentBalance,
+ type: 'number',
+ inclusive: true,
+ message: 'Cannot exceed current stake',
+ })
+ }
+
+ if (amountToUnstake !== currentBalance) {
+ // Not removing all stake in pool, must maintain minimum stake
+ if (currentBalance - amountToUnstake < minimumStake) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.too_big,
+ maximum: currentBalance - minimumStake,
+ type: 'number',
+ inclusive: true,
+ message: `Minimum stake is ${formatAlgoAmount(AlgoAmount.MicroAlgos(minimumStake).algos)} ALGO`,
+ })
+ }
+ }
+ }
+ }),
+ })
+
+ const form = useForm>({
+ resolver: zodResolver(formSchema),
+ mode: 'onChange',
+ defaultValues: {
+ amountToUnstake: '',
+ },
+ })
+
+ const { errors, isValid } = form.formState
+
+ const handleOpenChange = (open: boolean) => {
+ if (!open) {
+ setValidator(null)
+ setSelectedPoolId('')
+ form.reset()
+ }
+ }
+
+ const handleSetSelectedPool = (poolId: string) => {
+ setSelectedPoolId(poolId)
+ form.reset()
+ }
+
+ const handleSetMaxAmount = (event: React.MouseEvent) => {
+ event.preventDefault()
+
+ const pool = stakerPoolsData.find((p) => p.poolKey.poolId === Number(selectedPoolId))
+
+ if (!pool) {
+ return
+ }
+
+ form.setValue('amountToUnstake', AlgoAmount.MicroAlgos(pool.balance).algos.toString(), {
+ shouldValidate: true,
+ })
+ }
+
+ const toastIdRef = React.useRef(`toast-${Date.now()}-${Math.random()}`)
+ const TOAST_ID = toastIdRef.current
+
+ const onSubmit = async (data: z.infer) => {
+ const toastId = `${TOAST_ID}-unstake`
+
+ try {
+ setIsSigning(true)
+
+ if (!activeAddress) {
+ throw new Error('No active address')
+ }
+
+ const pool = stakerPoolsData.find((p) => p.poolKey.poolId === Number(selectedPoolId))
+
+ if (!pool) {
+ throw new Error('Invalid pool')
+ }
+
+ const amountToUnstake = AlgoAmount.Algos(parseFloat(data.amountToUnstake)).microAlgos
+
+ toast.loading('Sign transactions to remove stake...', { id: toastId })
+
+ await removeStake(pool.poolKey.poolAppId, amountToUnstake, transactionSigner, activeAddress)
+
+ toast.success(
+
+
+
+ Removed {' '}
+ from Pool {pool.poolKey.poolId} on Validator {pool.poolKey.validatorId}
+
+
,
+ {
+ id: toastId,
+ duration: 5000,
+ },
+ )
+
+ const allStakerData = queryClient.getQueryData([
+ 'stakes',
+ { staker: activeAddress },
+ ])
+
+ const stakerValidatorData = allStakerData?.find(
+ (data) => data.validatorId === pool.poolKey.validatorId,
+ )
+
+ if (stakerValidatorData) {
+ const updatedPool = stakerValidatorData.pools.find(
+ (p) => p.poolKey.poolId === pool.poolKey.poolId,
+ )
+
+ if (updatedPool) {
+ const newBalance = updatedPool.balance - amountToUnstake
+
+ const newPools =
+ newBalance === 0
+ ? stakerValidatorData.pools.filter((p) => p.poolKey.poolId !== pool.poolKey.poolId)
+ : stakerValidatorData.pools.map((p) => {
+ if (p.poolKey.poolId === pool.poolKey.poolId) {
+ return {
+ ...p,
+ balance: newBalance,
+ }
+ }
+
+ return p
+ })
+
+ const allStakeRemoved = newPools.length === 0
+
+ queryClient.setQueryData(
+ ['stakes', { staker: activeAddress }],
+ (prevData) => {
+ if (!prevData) {
+ return prevData
+ }
+
+ if (allStakeRemoved) {
+ return prevData.filter((d) => d.validatorId !== pool.poolKey.validatorId)
+ }
+
+ return prevData.map((data) => {
+ if (data.validatorId === pool.poolKey.validatorId) {
+ return {
+ ...data,
+ balance: data.balance - amountToUnstake,
+ pools: newPools,
+ }
+ }
+
+ return data
+ })
+ },
+ )
+
+ queryClient.setQueryData(
+ ['validator', String(pool.poolKey.validatorId)],
+ (prevData) => {
+ if (!prevData) {
+ return prevData
+ }
+
+ return {
+ ...prevData,
+ state: {
+ ...prevData.state,
+ totalStakers: allStakeRemoved
+ ? prevData.state.totalStakers - 1
+ : prevData.state.totalStakers,
+ totalAlgoStaked: prevData.state.totalAlgoStaked - BigInt(amountToUnstake),
+ },
+ }
+ },
+ )
+
+ queryClient.setQueryData(['validators'], (prevData) => {
+ if (!prevData) {
+ return prevData
+ }
+
+ return prevData.map((v: Validator) => {
+ if (v.id === pool.poolKey.validatorId) {
+ return {
+ ...v,
+ state: {
+ ...v.state,
+ totalStakers: allStakeRemoved ? v.state.totalStakers - 1 : v.state.totalStakers,
+ totalAlgoStaked: v.state.totalAlgoStaked - BigInt(amountToUnstake),
+ },
+ }
+ }
+
+ return v
+ })
+ })
+ }
+ }
+
+ router.invalidate()
+ } catch (error) {
+ toast.error('Failed to remove stake from pool', { id: toastId })
+ console.error(error)
+ } finally {
+ setIsSigning(false)
+ setValidator(null)
+ }
+ }
+
+ return (
+
+
+
+ Remove Stake from Validator {validator?.id}
+
+ This will remove your ALGO stake from{' '}
+ {stakerPoolsData.length === 1
+ ? `Pool ${stakerPoolsData[0].poolKey.poolId}`
+ : 'the selected pool'}
+
+
+
+
+
+ )
+}
diff --git a/ui/src/components/ValidatorDetails.tsx b/ui/src/components/ValidatorDetails.tsx
new file mode 100644
index 00000000..3222397e
--- /dev/null
+++ b/ui/src/components/ValidatorDetails.tsx
@@ -0,0 +1,230 @@
+import { useQuery } from '@tanstack/react-query'
+import { useWallet } from '@txnlab/use-wallet-react'
+import { Coins, Pencil, Percent, Users, Waves } from 'lucide-react'
+import * as React from 'react'
+import { poolAssignmentQueryOptions } from '@/api/queries'
+import { Overview } from '@/components/_Overview'
+import { AddPoolModal } from '@/components/AddPoolModal'
+import { AlgoDisplayAmount } from '@/components/AlgoDisplayAmount'
+import { Button } from '@/components/ui/button'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
+import { Validator } from '@/interfaces/validator'
+import { validatorHasAvailableSlots } from '@/utils/contracts'
+import { formatDuration } from '@/utils/dayjs'
+import { ellipseAddress } from '@/utils/ellipseAddress'
+
+interface ValidatorDetailsProps {
+ validator: Validator
+}
+
+export function ValidatorDetails({ validator }: ValidatorDetailsProps) {
+ const [addPoolValidator, setAddPoolValidator] = React.useState(null)
+
+ const { activeAddress } = useWallet()
+
+ const isManager = validator.config.manager === activeAddress
+ const isOwner = validator.config.owner === activeAddress
+ const canEdit = isManager || isOwner
+
+ const { data: poolAssignment } = useQuery(poolAssignmentQueryOptions(validator.id, canEdit))
+
+ const hasSlots = React.useMemo(() => {
+ return poolAssignment
+ ? validatorHasAvailableSlots(poolAssignment, validator.config.poolsPerNode)
+ : false
+ }, [poolAssignment, validator.config.poolsPerNode])
+
+ const canAddPool = canEdit && hasSlots
+
+ return (
+ <>
+
+
+
+
+ Total Staked
+
+
+
+
+ {/* +20.1% from last month
*/}
+
+
+
+
+ Stakers
+
+
+
+
+ {validator.state.totalStakers}
+
+ {/* +180.1% from last month
*/}
+
+
+
+
+ Pools
+
+
+
+
+ {Number(validator.state.numPools)}
+ {canAddPool && (
+
setAddPoolValidator(validator)}
+ >
+
+
+ )}
+
+ {/* +201 since last hour
*/}
+
+
+
+
+ Commission
+
+
+
+
+ {`${validator.config.percentToValidator / 10000}%`}
+
+ {/* +19% from last month
*/}
+
+
+
+
+
+
+ Analytics
+
+
+
+
+
+
+
+ Validator Details
+ {/* You made 265 sales this month. */}
+
+
+
+
+
+
Owner
+
+ {ellipseAddress(validator.config.owner)}
+
+
+
+
Manager
+
+ {ellipseAddress(validator.config.manager)}
+ {canEdit && (
+
+
+
+
+
+
+
+
+ Edit manager
+
+
+
+ )}
+
+
+
+
+ Commission Account
+
+
+ {ellipseAddress(validator.config.validatorCommissionAddress)}
+ {canEdit && (
+
+
+
+
+
+
+
+
+ Edit commission account
+
+
+
+ )}
+
+
+
+
+ Payout Frequency
+
+
+
+ {formatDuration(validator.config.payoutEveryXMins)}
+
+ {canEdit && (
+
+
+
+
+
+
+
+
+ Edit payout frequency
+
+
+
+ )}
+
+
+
+
+ Minimum Entry Stake
+
+
+
+
+
+
+
+ Maximum Stake Per Pool
+
+
+
+
+
+
+
+
+
+
+
+
+ {poolAssignment && (
+
+ )}
+ >
+ )
+}
diff --git a/ui/src/components/ValidatorTable.tsx b/ui/src/components/ValidatorTable.tsx
new file mode 100644
index 00000000..8629cf61
--- /dev/null
+++ b/ui/src/components/ValidatorTable.tsx
@@ -0,0 +1,351 @@
+import { AlgoAmount } from '@algorandfoundation/algokit-utils/types/amount'
+import { useQuery } from '@tanstack/react-query'
+import { Link } from '@tanstack/react-router'
+import {
+ ColumnDef,
+ ColumnFiltersState,
+ SortingState,
+ VisibilityState,
+ flexRender,
+ getCoreRowModel,
+ getFilteredRowModel,
+ getSortedRowModel,
+ useReactTable,
+} from '@tanstack/react-table'
+import { useWallet } from '@txnlab/use-wallet-react'
+import { FlaskConical, MoreHorizontal } from 'lucide-react'
+import * as React from 'react'
+import { constraintsQueryOptions } from '@/api/queries'
+import { AddPoolModal } from '@/components/AddPoolModal'
+import { AddStakeModal } from '@/components/AddStakeModal'
+import { AlgoDisplayAmount } from '@/components/AlgoDisplayAmount'
+import { DataTableColumnHeader } from '@/components/DataTableColumnHeader'
+import { DataTableViewOptions } from '@/components/DataTableViewOptions'
+import { NfdThumbnail } from '@/components/NfdThumbnail'
+import { Button } from '@/components/ui/button'
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu'
+import { Input } from '@/components/ui/input'
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '@/components/ui/table'
+import { UnstakeModal } from '@/components/UnstakeModal'
+import { StakerValidatorData } from '@/interfaces/staking'
+import { Validator } from '@/interfaces/validator'
+import {
+ calculateMaxStake,
+ calculateMaxStakers,
+ canManageValidator,
+ isAddingPoolDisabled,
+ isStakingDisabled,
+ isUnstakingDisabled,
+} from '@/utils/contracts'
+import { formatDuration } from '@/utils/dayjs'
+import { sendRewardTokensToPool } from '@/utils/development'
+import { ellipseAddress } from '@/utils/ellipseAddress'
+import { cn } from '@/utils/ui'
+
+interface ValidatorTableProps {
+ validators: Validator[]
+ stakesByValidator: StakerValidatorData[]
+}
+
+export function ValidatorTable({ validators, stakesByValidator }: ValidatorTableProps) {
+ const [sorting, setSorting] = React.useState([])
+ const [columnFilters, setColumnFilters] = React.useState([])
+ const [columnVisibility, setColumnVisibility] = React.useState({})
+ const [rowSelection, setRowSelection] = React.useState({})
+
+ const [addStakeValidator, setAddStakeValidator] = React.useState(null)
+ const [unstakeValidator, setUnstakeValidator] = React.useState(null)
+ const [addPoolValidator, setAddPoolValidator] = React.useState(null)
+
+ const { transactionSigner, activeAddress } = useWallet()
+
+ const { data: constraints } = useQuery(constraintsQueryOptions)
+
+ const columns: ColumnDef[] = [
+ {
+ accessorKey: 'id',
+ header: ({ column }) => ,
+ size: 70,
+ },
+ {
+ id: 'validator',
+ accessorFn: (row) => row.config.owner,
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const validator = row.original
+
+ const nfdAppId = validator.config.nfdForInfo
+ if (nfdAppId > 0) {
+ return
+ }
+ return ellipseAddress(validator.config.owner)
+ },
+ },
+ {
+ id: 'minEntry',
+ accessorFn: (row) => Number(row.config.minEntryStake),
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const validator = row.original
+ return
+ },
+ },
+ {
+ id: 'stake',
+ accessorFn: (row) => Number(row.state.totalAlgoStaked),
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const validator = row.original
+
+ const currentStake = AlgoAmount.MicroAlgos(Number(validator.state.totalAlgoStaked)).algos
+ const currentStakeCompact = new Intl.NumberFormat(undefined, {
+ notation: 'compact',
+ }).format(currentStake)
+
+ const maxStake = calculateMaxStake(validator, true)
+ const maxStakeCompact = new Intl.NumberFormat(undefined, {
+ notation: 'compact',
+ }).format(maxStake)
+
+ return (
+
+ {currentStakeCompact} / {maxStakeCompact}
+
+ )
+ },
+ },
+ {
+ id: 'stakers',
+ accessorFn: (row) => row.state.totalStakers,
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const validator = row.original
+
+ if (validator.state.numPools == 0) return '--'
+
+ const totalStakers = validator.state.totalStakers
+ const maxStakers = calculateMaxStakers(validator)
+
+ return (
+
+ {totalStakers} / {maxStakers}
+
+ )
+ },
+ },
+ {
+ id: 'commission',
+ accessorFn: (row) => row.config.percentToValidator,
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const validator = row.original
+ const percent = validator.config.percentToValidator / 10000
+ return `${percent}%`
+ },
+ },
+ {
+ id: 'payoutFrequency',
+ accessorFn: (row) => row.config.payoutEveryXMins,
+ header: ({ column }) => ,
+ cell: ({ row }) => {
+ const validator = row.original
+ const frequencyFormatted = formatDuration(validator.config.payoutEveryXMins)
+ return {frequencyFormatted}
+ },
+ },
+ {
+ id: 'actions',
+ cell: ({ row }) => {
+ const validator = row.original
+ const stakingDisabled = isStakingDisabled(validator, constraints)
+ const unstakingDisabled = isUnstakingDisabled(validator, stakesByValidator)
+ const addingPoolDisabled = isAddingPoolDisabled(validator)
+ const canManage = canManageValidator(validator, activeAddress!)
+
+ const isDevelopment = process.env.NODE_ENV === 'development'
+ const hasRewardToken = validator.config.rewardTokenId > 0
+ const canSendRewardTokens = isDevelopment && canManage && hasRewardToken
+ const sendRewardTokensDisabled = validator.state.numPools === 0
+
+ return (
+
+ setAddStakeValidator(validator)}
+ disabled={stakingDisabled}
+ >
+ Stake
+
+
+
+
+ Open menu
+
+
+
+
+
+ setAddStakeValidator(validator)}
+ disabled={stakingDisabled}
+ >
+ Stake
+
+ setUnstakeValidator(validator)}
+ disabled={unstakingDisabled}
+ >
+ Unstake
+
+
+
+
+
+
+ {canManage ? 'Manage' : 'View'}
+
+
+ {canManage && (
+ setAddPoolValidator(validator)}
+ disabled={addingPoolDisabled}
+ >
+ Add Staking Pool
+
+ )}
+
+ {canSendRewardTokens && (
+ <>
+
+
+
+ await sendRewardTokensToPool(
+ validator,
+ 5000,
+ transactionSigner,
+ activeAddress!,
+ )
+ }
+ disabled={sendRewardTokensDisabled}
+ >
+
+ Send Tokens
+
+
+ >
+ )}
+
+
+
+
+ )
+ },
+ size: 120,
+ },
+ ]
+
+ const table = useReactTable({
+ data: validators,
+ columns,
+ getCoreRowModel: getCoreRowModel(),
+ onSortingChange: setSorting,
+ getSortedRowModel: getSortedRowModel(),
+ onColumnFiltersChange: setColumnFilters,
+ getFilteredRowModel: getFilteredRowModel(),
+ onColumnVisibilityChange: setColumnVisibility,
+ onRowSelectionChange: setRowSelection,
+ state: {
+ sorting,
+ columnFilters,
+ columnVisibility,
+ rowSelection,
+ },
+ })
+
+ return (
+ <>
+
+
+
All Validators
+
+ table.getColumn('validator')?.setFilterValue(event.target.value)}
+ className="sm:max-w-sm lg:w-64"
+ />
+
+
+
+
+
+
+ {table.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ return (
+
+ {header.isPlaceholder
+ ? null
+ : flexRender(header.column.columnDef.header, header.getContext())}
+
+ )
+ })}
+
+ ))}
+
+
+ {table.getRowModel().rows?.length ? (
+ table.getRowModel().rows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+
+ ))}
+
+ ))
+ ) : (
+
+
+ No results
+
+
+ )}
+
+
+
+
+
+
+
+
+ >
+ )
+}
diff --git a/ui/src/components/WalletBalance.tsx b/ui/src/components/WalletBalance.tsx
new file mode 100644
index 00000000..bcb79b47
--- /dev/null
+++ b/ui/src/components/WalletBalance.tsx
@@ -0,0 +1,28 @@
+import { useQuery } from '@tanstack/react-query'
+import { balanceQueryOptions } from '@/api/queries'
+import { AlgoDisplayAmount } from '@/components/AlgoDisplayAmount'
+
+interface WalletBalanceProps {
+ activeAddress: string
+}
+
+export function WalletBalance({ activeAddress }: WalletBalanceProps) {
+ const { data: balance, isLoading, error } = useQuery(balanceQueryOptions(activeAddress))
+
+ if (isLoading) {
+ return Loading...
+ }
+
+ if (error || !balance) {
+ return Error fetching balance
+ }
+
+ return (
+
+
Account Balance
+
+
+
+
+ )
+}
diff --git a/ui/src/components/WalletShortcutHandler.tsx b/ui/src/components/WalletShortcutHandler.tsx
new file mode 100644
index 00000000..08fc55b9
--- /dev/null
+++ b/ui/src/components/WalletShortcutHandler.tsx
@@ -0,0 +1,28 @@
+import { useWallet } from '@txnlab/use-wallet-react'
+import * as React from 'react'
+
+export function WalletShortcutHandler() {
+ const { activeWallet } = useWallet()
+
+ const handleDisconnect = React.useCallback(() => {
+ if (activeWallet) {
+ activeWallet.disconnect()
+ }
+ }, [activeWallet])
+
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.shiftKey && event.key === 'D') {
+ handleDisconnect()
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [handleDisconnect])
+
+ return null
+}
diff --git a/ui/src/components/_Overview.tsx b/ui/src/components/_Overview.tsx
new file mode 100644
index 00000000..2d73d9de
--- /dev/null
+++ b/ui/src/components/_Overview.tsx
@@ -0,0 +1,70 @@
+import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from 'recharts'
+
+const data = [
+ {
+ name: 'Jan',
+ total: Math.floor(Math.random() * 5000) + 1000,
+ },
+ {
+ name: 'Feb',
+ total: Math.floor(Math.random() * 5000) + 1000,
+ },
+ {
+ name: 'Mar',
+ total: Math.floor(Math.random() * 5000) + 1000,
+ },
+ {
+ name: 'Apr',
+ total: Math.floor(Math.random() * 5000) + 1000,
+ },
+ {
+ name: 'May',
+ total: Math.floor(Math.random() * 5000) + 1000,
+ },
+ {
+ name: 'Jun',
+ total: Math.floor(Math.random() * 5000) + 1000,
+ },
+ {
+ name: 'Jul',
+ total: Math.floor(Math.random() * 5000) + 1000,
+ },
+ {
+ name: 'Aug',
+ total: Math.floor(Math.random() * 5000) + 1000,
+ },
+ {
+ name: 'Sep',
+ total: Math.floor(Math.random() * 5000) + 1000,
+ },
+ {
+ name: 'Oct',
+ total: Math.floor(Math.random() * 5000) + 1000,
+ },
+ {
+ name: 'Nov',
+ total: Math.floor(Math.random() * 5000) + 1000,
+ },
+ {
+ name: 'Dec',
+ total: Math.floor(Math.random() * 5000) + 1000,
+ },
+]
+
+export function Overview() {
+ return (
+
+
+
+ `${value}`}
+ />
+
+
+
+ )
+}
diff --git a/ui/src/components/ui/avatar.tsx b/ui/src/components/ui/avatar.tsx
new file mode 100644
index 00000000..33ae3c6e
--- /dev/null
+++ b/ui/src/components/ui/avatar.tsx
@@ -0,0 +1,48 @@
+import * as React from "react"
+import * as AvatarPrimitive from "@radix-ui/react-avatar"
+
+import { cn } from "@/utils/ui"
+
+const Avatar = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+Avatar.displayName = AvatarPrimitive.Root.displayName
+
+const AvatarImage = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarImage.displayName = AvatarPrimitive.Image.displayName
+
+const AvatarFallback = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
+
+export { Avatar, AvatarImage, AvatarFallback }
diff --git a/ui/src/components/ui/button.tsx b/ui/src/components/ui/button.tsx
new file mode 100644
index 00000000..32f326b7
--- /dev/null
+++ b/ui/src/components/ui/button.tsx
@@ -0,0 +1,49 @@
+import * as React from 'react'
+import { Slot } from '@radix-ui/react-slot'
+import { cva, type VariantProps } from 'class-variance-authority'
+
+import { cn } from '@/utils/ui'
+
+const buttonVariants = cva(
+ 'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
+ {
+ variants: {
+ variant: {
+ default: 'bg-primary text-primary-foreground hover:bg-primary/90',
+ destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
+ outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
+ secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
+ ghost: 'hover:bg-accent hover:text-accent-foreground',
+ link: 'text-primary underline-offset-4 hover:underline',
+ },
+ size: {
+ default: 'h-10 px-4 py-2',
+ sm: 'h-9 rounded-md px-3',
+ lg: 'h-11 rounded-md px-8',
+ icon: 'h-10 w-10',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ size: 'default',
+ },
+ },
+)
+
+export interface ButtonProps
+ extends React.ButtonHTMLAttributes,
+ VariantProps {
+ asChild?: boolean
+}
+
+const Button = React.forwardRef(
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : 'button'
+ return (
+
+ )
+ },
+)
+Button.displayName = 'Button'
+
+export { Button, buttonVariants }
diff --git a/ui/src/components/ui/card.tsx b/ui/src/components/ui/card.tsx
new file mode 100644
index 00000000..d67b9e57
--- /dev/null
+++ b/ui/src/components/ui/card.tsx
@@ -0,0 +1,76 @@
+import * as React from "react"
+
+import { cn } from "@/utils/ui"
+
+const Card = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+Card.displayName = "Card"
+
+const CardHeader = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardHeader.displayName = "CardHeader"
+
+const CardTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardTitle.displayName = "CardTitle"
+
+const CardDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardDescription.displayName = "CardDescription"
+
+const CardContent = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardContent.displayName = "CardContent"
+
+const CardFooter = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+))
+CardFooter.displayName = "CardFooter"
+
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
diff --git a/ui/src/components/ui/checkbox.tsx b/ui/src/components/ui/checkbox.tsx
new file mode 100644
index 00000000..5c5c7b43
--- /dev/null
+++ b/ui/src/components/ui/checkbox.tsx
@@ -0,0 +1,26 @@
+import * as React from 'react'
+import * as CheckboxPrimitive from '@radix-ui/react-checkbox'
+import { CheckIcon } from '@radix-ui/react-icons'
+
+import { cn } from '@/utils/ui'
+
+const Checkbox = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+))
+Checkbox.displayName = CheckboxPrimitive.Root.displayName
+
+export { Checkbox }
diff --git a/ui/src/components/ui/dialog.tsx b/ui/src/components/ui/dialog.tsx
new file mode 100644
index 00000000..0e9f699a
--- /dev/null
+++ b/ui/src/components/ui/dialog.tsx
@@ -0,0 +1,102 @@
+import * as React from 'react'
+import * as DialogPrimitive from '@radix-ui/react-dialog'
+import { Cross2Icon } from '@radix-ui/react-icons'
+
+import { cn } from '@/utils/ui'
+
+const Dialog = DialogPrimitive.Root
+
+const DialogTrigger = DialogPrimitive.Trigger
+
+const DialogPortal = DialogPrimitive.Portal
+
+const DialogClose = DialogPrimitive.Close
+
+const DialogOverlay = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+))
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+
+const DialogContent = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, children, ...props }, ref) => (
+