diff --git a/package.json b/package.json index e9bf3c75..ac2afa89 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@permaweb/aoconnect": "^0.0.59", "@radix-ui/react-radio-group": "^1.2.1", "@radix-ui/react-select": "^2.1.4", + "@radix-ui/react-switch": "^1.1.2", "@sentry/react": "^7.45.0", "@tanstack/react-query": "^5.51.21", "@tanstack/react-query-persist-client": "^5.45.1", diff --git a/src/App.tsx b/src/App.tsx index 94a1bd09..5c88f09b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -59,6 +59,8 @@ const SettingsLayout = React.lazy( () => import('./components/pages/Settings/SettingsLayout'), ); +const RNPPage = React.lazy(() => import('./components/pages/RNPPage/RNPPage')); + const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouter(createHashRouter); @@ -323,6 +325,18 @@ function App() { } /> + + } + > + + + } + /> ; }) { const [{ walletAddress }] = useWalletState(); const { data: costDetails } = useCostDetails({ @@ -180,7 +183,13 @@ export function RNPChart({ return <>; }; - if (!chartData.length) return <>; + if (!chartData?.length) + return ( + + + Loading Price Chart... + + ); if (!costDetails?.returnedNameDetails) return <>; diff --git a/src/components/data-display/tables/ReturnedNamesTable.tsx b/src/components/data-display/tables/ReturnedNamesTable.tsx new file mode 100644 index 00000000..35368dfd --- /dev/null +++ b/src/components/data-display/tables/ReturnedNamesTable.tsx @@ -0,0 +1,482 @@ +import { AoReturnedName, mARIOToken } from '@ar.io/sdk'; +import { ExternalLinkIcon } from '@src/components/icons'; +import Switch from '@src/components/inputs/Switch'; +import { Loader } from '@src/components/layout'; +import ArweaveID, { + ArweaveIdTypes, +} from '@src/components/layout/ArweaveID/ArweaveID'; +import { buildCostDetailsQuery } from '@src/hooks/useCostDetails'; +import { ArweaveTransactionID } from '@src/services/arweave/ArweaveTransactionID'; +import { useGlobalState, useWalletState } from '@src/state'; +import { TRANSACTION_TYPES } from '@src/types'; +import { + camelToReadable, + decodeDomainToASCII, + encodeDomainToASCII, + formatARIOWithCommas, + formatDate, + formatForMaxCharCount, + isArweaveTransactionID, + lowerCaseDomain, +} from '@src/utils'; +import { NETWORK_DEFAULTS } from '@src/utils/constants'; +import { useQueryClient } from '@tanstack/react-query'; +import { ColumnDef, Row, createColumnHelper } from '@tanstack/react-table'; +import { CircleAlertIcon, Star } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { ReactNode } from 'react-markdown'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; + +import { Tooltip } from '..'; +import { RNPChart } from '../charts/RNPChart'; +import TableView from './TableView'; + +type TableData = { + openRow: ReactNode; + name: string; + closingDate: number; + initiator: string; + leasePrice: number | Error; + permabuy: number | Error; + returnType: string; + action: ReactNode; +} & Record; + +const columnHelper = createColumnHelper(); + +function filterTableData(filter: string, data: TableData[]): TableData[] { + const results: TableData[] = []; + + data.forEach((d) => { + let matchFound = false; + + Object.entries(d).forEach(([, v]) => { + if (typeof v === 'object' && v !== null) { + // Recurse into nested objects + const nestedResults = filterTableData(filter, [v]); + if (nestedResults.length > 0) { + matchFound = true; + } + } else if (v?.toString()?.toLowerCase()?.includes(filter.toLowerCase())) { + matchFound = true; + } + }); + if (!matchFound && d.antRecords) { + Object.keys(d?.antRecords).forEach((undername) => { + if (undername?.toLowerCase()?.includes(filter.toLowerCase())) { + matchFound = true; + } + }); + } + + if (matchFound) { + results.push(d); + } + }); + + return results; +} + +const ReturnedNamesTable = ({ + returnedNames, + loading, + filter, +}: { + returnedNames?: Array; + loading: boolean; + filter?: string; +}) => { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [searchParams] = useSearchParams(); + const [{ arioProcessId, arioTicker, arioContract }] = useGlobalState(); + const [{ walletAddress }] = useWalletState(); + + const [tableData, setTableData] = useState>([]); + const [filteredTableData, setFilteredTableData] = useState([]); + const [sortBy, setSortBy] = useState(searchParams.get('sortBy') ?? 'name'); + + useEffect(() => { + setSortBy(searchParams.get('sortBy') ?? 'name'); + }, [searchParams]); + + useEffect(() => { + if (loading) { + setTableData([]); + setFilteredTableData([]); + return; + } + if (returnedNames) { + const newTableData: TableData[] = []; + + returnedNames.map((nameData) => { + const { name, initiator, endTimestamp } = nameData; + const data: TableData = { + openRow: <>, + name, + closingDate: endTimestamp, + initiator, + leasePrice: -1, + permabuy: -1, + returnType: + initiator === arioProcessId ? 'Lease Expiry' : 'Permanent Return', + + action: <>, + // metadata used for search and other purposes + returnNameData: nameData, + }; + newTableData.push(data); + }); + + setTableData(newTableData); + } + }, [returnedNames, loading]); + async function fetchPrice( + name: string, + type: TRANSACTION_TYPES, + ): Promise { + try { + const res = await queryClient.fetchQuery( + buildCostDetailsQuery( + { + intent: 'Buy-Record', + name, + type, + years: 1, + fromAddress: walletAddress?.toString(), + }, + { arioContract, arioProcessId }, + ), + ); + + return res.tokenCost; + } catch (error: any) { + return new Error(error.message); + } + } + useEffect(() => { + async function updatePrices() { + // Filter rows that need price updates + const rowsToUpdate = tableData.filter( + (row) => + row.leasePrice instanceof Error || + row.leasePrice < 0 || + row.permabuy instanceof Error || + row.permabuy < 0, + ); + + if (rowsToUpdate.length === 0) { + // No rows need updates, exit early + return; + } + + const updatedData = await Promise.all( + tableData.map(async (row) => { + if ( + row.leasePrice instanceof Error || + row.leasePrice < 0 || + row.permabuy instanceof Error || + row.permabuy < 0 + ) { + // Fetch lease price + const leasePrice = await fetchPrice( + row.name, + TRANSACTION_TYPES.LEASE, + ); + // Fetch permabuy price + const permabuyPrice = await fetchPrice( + row.name, + TRANSACTION_TYPES.BUY, + ); + + // Return updated row with fetched prices + return { + ...row, + leasePrice, + permabuy: permabuyPrice, + }; + } + // Return the row unchanged if no update is needed + return row; + }), + ); + + // Set updated table data + setTableData(updatedData); + } + + updatePrices(); + }, [tableData]); // Re-run only when `tableData` changes + + useEffect(() => { + if (filter) { + setFilteredTableData(filterTableData(filter, tableData)); + } else { + setFilteredTableData([]); + } + }, [filter, tableData]); + // Define columns for the table + const columns: ColumnDef[] = [ + 'openRow', + 'name', + 'closingDate', + 'initiator', + 'leasePrice', + 'permabuy', + 'returnType', + 'action', + ].map((key) => + columnHelper.accessor(key as keyof TableData, { + id: key, + size: key == 'action' || key == 'openRow' ? 20 : undefined, + header: + key == 'action' || key == 'openRow' + ? '' + : key == 'leasePrice' + ? 'Price for 1 Year' + : camelToReadable(key), + sortDescFirst: true, + sortingFn: 'alphanumeric', + cell: ({ row }) => { + const rowValue = row.getValue(key) as any; + if (rowValue === undefined || rowValue === null) { + return ''; + } + + switch (key) { + case 'openRow': { + return row.getIsExpanded() ? ( + + ) : ( + <> + ); + } + case 'name': { + return ( + + {rowValue} + + } + icon={ + + {formatForMaxCharCount(decodeDomainToASCII(rowValue), 20)}{' '} + + + } + /> + ); + } + case 'closingDate': { + return formatDate(rowValue); + } + case 'initiator': { + return isArweaveTransactionID(rowValue) ? ( + + ) : ( + rowValue + ); + } + case 'leasePrice': + case 'permabuy': { + if (rowValue instanceof Error) + return ( + + Price Error{' '} + + + } + /> + ); + if (rowValue < 0) + return ( + Loading... + ); + return `${formatARIOWithCommas( + new mARIOToken(rowValue).toARIO().valueOf(), + )} ${arioTicker}`; + } + case 'returnType': { + return rowValue; + } + + case 'action': { + return ( +
+ + {row.getIsExpanded() ? ( + + ) : ( + + )} + + +
+ ); + } + + default: { + return rowValue; + } + } + }, + }), + ); + + const RNPChartSubComponent = ({ row }: { row: Row }) => { + const [purchaseType, setPurchaseType] = useState( + TRANSACTION_TYPES.LEASE, + ); + + return ( +
+
+ Returned Name +
+ + {purchaseType === TRANSACTION_TYPES.LEASE + ? 'Lease for 1 Year' + : 'Permabuy'} + + + setPurchaseType( + checked ? TRANSACTION_TYPES.BUY : TRANSACTION_TYPES.LEASE, + ) + } + className={{ + root: 'outline-none size-full w-[3rem] rounded-full border border-dark-grey', + thumb: + 'block size-[21px] translate-x-0.5 rounded-full bg-primary transition-transform duration-100 will-change-transform data-[state=checked]:translate-x-[1.43rem]', + }} + /> +
+
+ +
+ ); + }; + + return ( + <> +
+ + +
+ ) : ( +
+ <> + + No Returned Names Found + + + +
+ + Search for a Name + +
+
+ ) + } + defaultSortingState={{ + id: sortBy, + desc: sortBy == 'closingData' ? false : true, + }} + renderSubComponent={({ row }) => } + tableClass="overflow-hidden rounded" + tableWrapperClassName="border border-dark-grey rounded" + rowClass={(props) => { + if (props?.headerGroup) { + return 'rounded-t'; + } + if (props?.row !== undefined) { + return props.row.getIsExpanded() + ? 'bg-[#1B1B1D] ' + : 'overflow-hidden' + + ' data-[id=renderSubComponent]:hover:bg-background'; + } + + return ''; + }} + dataClass={(props) => { + if (props?.headerGroup) { + return 'rounded-t whitespace-nowrap'; + } + if (props?.row !== undefined && props.row.getIsExpanded()) { + return 'border-t-[1px] border-dark-grey border-b-0'; + } + + return ''; + }} + headerClass="bg-foreground rounded-t" + /> + + + ); +}; + +export default ReturnedNamesTable; diff --git a/src/components/data-display/tables/TableView.tsx b/src/components/data-display/tables/TableView.tsx index ee50fb63..77a35bea 100644 --- a/src/components/data-display/tables/TableView.tsx +++ b/src/components/data-display/tables/TableView.tsx @@ -32,6 +32,7 @@ const TableView = ({ getSubRows, renderSubComponent, tableClass, + tableWrapperClassName, headerClass, rowClass = () => '', dataClass = () => '', @@ -50,6 +51,7 @@ const TableView = ({ getSubRows?: (row: T, index: number) => T[] | undefined; renderSubComponent?: (props: { row: Row }) => ReactNode; tableClass?: string; + tableWrapperClassName?: string; headerClass?: string; rowClass?: (props?: { row?: Row; headerGroup?: HeaderGroup }) => string; dataClass?: (props?: { @@ -94,12 +96,9 @@ const TableView = ({ return ( <> -
+
- + {table.getHeaderGroups().map((headerGroup) => ( void; + className?: { + root?: string; + thumb?: string; + }; +}) => ( + + + +); + +export default Switch; diff --git a/src/components/pages/RNPPage/RNPPage.tsx b/src/components/pages/RNPPage/RNPPage.tsx new file mode 100644 index 00000000..0fddc6ec --- /dev/null +++ b/src/components/pages/RNPPage/RNPPage.tsx @@ -0,0 +1,31 @@ +import ReturnedNamesTable from '@src/components/data-display/tables/ReturnedNamesTable'; +import { useReturnedNames } from '@src/hooks/useReturnedNames'; +import { RefreshCwIcon } from 'lucide-react'; + +function RNPPage() { + const { + data: returnedNamesData, + refetch, + isLoading, + isRefetching, + } = useReturnedNames(); + return ( +
+
+

Recently Returned

+ +
+ +
+ ); +} + +export default RNPPage; diff --git a/src/hooks/useCostDetails.tsx b/src/hooks/useCostDetails.tsx index 7a14e4aa..4735b1e3 100644 --- a/src/hooks/useCostDetails.tsx +++ b/src/hooks/useCostDetails.tsx @@ -1,15 +1,33 @@ -import { AoGetCostDetailsParams } from '@ar.io/sdk'; +import { + AoARIORead, + AoARIOWrite, + AoGetCostDetailsParams, + CostDetailsResult, +} from '@ar.io/sdk'; import { useGlobalState } from '@src/state'; import { useQuery } from '@tanstack/react-query'; -export function useCostDetails(params: AoGetCostDetailsParams) { - const [{ arioProcessId, arioContract }] = useGlobalState(); - // we are verbose here to enable predictable keys. Passing in the entire params as a single object can have unpredictable side effects - return useQuery({ +export function buildCostDetailsQuery( + params: AoGetCostDetailsParams, + { + arioProcessId, + arioContract, + }: { arioProcessId: string; arioContract: AoARIORead | AoARIOWrite }, +): Parameters>[0] { + return { + // we are verbose here to enable predictable keys. Passing in the entire params as a single object can have unpredictable side effects queryKey: ['getCostDetails', params, arioProcessId.toString()], queryFn: async () => { return await arioContract.getCostDetails(params); }, staleTime: 1000 * 60 * 5, - }); + }; +} + +export function useCostDetails(params: AoGetCostDetailsParams) { + const [{ arioProcessId, arioContract }] = useGlobalState(); + + return useQuery( + buildCostDetailsQuery(params, { arioProcessId, arioContract }), + ); } diff --git a/src/hooks/useReturnedNames.tsx b/src/hooks/useReturnedNames.tsx index e85c02ae..3e829440 100644 --- a/src/hooks/useReturnedNames.tsx +++ b/src/hooks/useReturnedNames.tsx @@ -9,6 +9,7 @@ export function useReturnedNames() { queryFn: async () => { return await arioContract.getArNSReturnedNames(); }, + staleTime: 1000 * 60 * 5, }); } @@ -22,5 +23,6 @@ export function useReturnedName(name?: string) { if (!name) throw new Error('Must provide name in hook'); return await arioContract.getArNSReturnedName({ name }); }, + staleTime: 1000 * 60 * 5, }); } diff --git a/src/utils/routes.tsx b/src/utils/routes.tsx index f8d0ee0b..d6518a4b 100644 --- a/src/utils/routes.tsx +++ b/src/utils/routes.tsx @@ -1,5 +1,6 @@ +import RNPPage from '@src/components/pages/RNPPage/RNPPage'; import Settings from '@src/components/pages/Settings/SettingsLayout'; -import { Settings2Icon } from 'lucide-react'; +import { Recycle, Settings2Icon } from 'lucide-react'; import { SettingsIcon } from '../components/icons'; import { Home, Manage } from '../components/pages'; @@ -44,4 +45,12 @@ export const ROUTES: { [x: string]: Route } = { protected: false, index: false, }, + returnedNames: { + text: 'Returned Names', + icon: Recycle, + path: '/returned-names', + component: RNPPage, + protected: false, + index: false, + }, }; diff --git a/yarn.lock b/yarn.lock index 90bd176c..09cb067e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3277,6 +3277,19 @@ dependencies: "@radix-ui/react-compose-refs" "1.1.1" +"@radix-ui/react-switch@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@radix-ui/react-switch/-/react-switch-1.1.2.tgz#61323f4cccf25bf56c95fceb3b56ce1407bc9aec" + integrity sha512-zGukiWHjEdBCRyXvKR6iXAQG6qXm2esuAD6kDOi9Cn+1X6ev3ASo4+CsYaD6Fov9r/AQFekqnD/7+V0Cs6/98g== + dependencies: + "@radix-ui/primitive" "1.1.1" + "@radix-ui/react-compose-refs" "1.1.1" + "@radix-ui/react-context" "1.1.1" + "@radix-ui/react-primitive" "2.0.1" + "@radix-ui/react-use-controllable-state" "1.1.0" + "@radix-ui/react-use-previous" "1.1.0" + "@radix-ui/react-use-size" "1.1.0" + "@radix-ui/react-use-callback-ref@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz#bce938ca413675bc937944b0d01ef6f4a6dc5bf1"