diff --git a/package-lock.json b/package-lock.json index 689ccf89..663811ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "react-infinite-scroll-component": "^6.1.0", "react-number-format": "^5.4.2", "react-responsive-modal": "^6.4.2", + "react-tabs": "^6.0.2", "react-tooltip": "^5.26.4", "sharp": "^0.33.4", "tailwind-merge": "^2.5.2", @@ -7062,6 +7063,15 @@ "node": ">=12" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -14593,6 +14603,19 @@ "react-dom": "^16.8.0 || ^17 || ^18" } }, + "node_modules/react-tabs": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/react-tabs/-/react-tabs-6.0.2.tgz", + "integrity": "sha512-aQXTKolnM28k3KguGDBSAbJvcowOQr23A+CUJdzJtOSDOtTwzEaJA+1U4KwhNL9+Obe+jFS7geuvA7ICQPXOnQ==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "prop-types": "^15.5.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, "node_modules/react-tooltip": { "version": "5.27.1", "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.27.1.tgz", diff --git a/package.json b/package.json index 062d1ad3..9a86ab9a 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "node": "22.3.0" }, "dependencies": { + "@babylonlabs-io/btc-staking-ts": "0.3.0", "@bitcoin-js/tiny-secp256k1-asmjs": "2.2.3", "@bitcoinerlab/secp256k1": "^1.1.1", "@keystonehq/animated-qr": "^0.8.6", @@ -36,7 +37,6 @@ "@uidotdev/usehooks": "^2.4.1", "axios": "^1.7.4", "bitcoinjs-lib": "6.1.5", - "@babylonlabs-io/btc-staking-ts": "0.3.0", "date-fns": "^3.6.0", "decimal.js-light": "^2.5.1", "framer-motion": "^11.1.9", @@ -48,6 +48,7 @@ "react-infinite-scroll-component": "^6.1.0", "react-number-format": "^5.4.2", "react-responsive-modal": "^6.4.2", + "react-tabs": "^6.0.2", "react-tooltip": "^5.26.4", "sharp": "^0.33.4", "tailwind-merge": "^2.5.2", diff --git a/src/app/components/Delegations/DelegationTabs.tsx b/src/app/components/Delegations/DelegationTabs.tsx new file mode 100644 index 00000000..e6e36b26 --- /dev/null +++ b/src/app/components/Delegations/DelegationTabs.tsx @@ -0,0 +1,41 @@ +import { Tab, TabList, TabPanel, Tabs } from "react-tabs"; + +import { Delegations } from "@/app/components/Delegations/Delegations"; +import { AuthGuard } from "@/components/common/AuthGuard"; +import { DelegationList } from "@/components/delegations/DelegationList"; + +export function DelegationTabs() { + return ( + + +
+
+

Staking history

+ + + + Phase 1 + + + Phase 2 + + +
+ + + + + + + +
+
+
+ ); +} diff --git a/src/app/components/Delegations/Delegations.tsx b/src/app/components/Delegations/Delegations.tsx index b2499d61..99134c95 100644 --- a/src/app/components/Delegations/Delegations.tsx +++ b/src/app/components/Delegations/Delegations.tsx @@ -53,26 +53,24 @@ export const Delegations = () => { } return ( - network && ( - + - - - ) + btcWalletNetwork={network} + publicKeyNoCoord={publicKeyNoCoord} + isWalletConnected={connected} + /> + ); }; @@ -305,8 +303,7 @@ const DelegationsContent: React.FC = ({ delegations; return ( -
-

Staking history

+ <> {combinedDelegationsData.length === 0 ? (

No history found

@@ -384,6 +381,6 @@ const DelegationsContent: React.FC = ({ delegation={delegation} /> )} -
+ ); }; diff --git a/src/app/components/Modals/UnbondWithdrawModal.tsx b/src/app/components/Modals/UnbondWithdrawModal.tsx index 9f3e65b3..5d62f6fe 100644 --- a/src/app/components/Modals/UnbondWithdrawModal.tsx +++ b/src/app/components/Modals/UnbondWithdrawModal.tsx @@ -24,6 +24,8 @@ interface PreviewModalProps { delegation: DelegationInterface; } +const { coinName, networkName } = getNetworkConfig(); + export const UnbondWithdrawModal: React.FC = ({ open, onClose, @@ -32,8 +34,6 @@ export const UnbondWithdrawModal: React.FC = ({ awaitingWalletResponse, delegation, }) => { - const { coinName, networkName } = getNetworkConfig(); - const { currentVersion: delegationGlobalParams } = useVersionByHeight( delegation.stakingTx.startHeight ?? 0, ); diff --git a/src/app/components/Staking/FinalityProviders/FinalityProvider.tsx b/src/app/components/Staking/FinalityProviders/FinalityProvider.tsx index b576eec5..74fb92ed 100644 --- a/src/app/components/Staking/FinalityProviders/FinalityProvider.tsx +++ b/src/app/components/Staking/FinalityProviders/FinalityProvider.tsx @@ -2,6 +2,7 @@ import Image from "next/image"; import { AiOutlineInfoCircle } from "react-icons/ai"; import { FiExternalLink } from "react-icons/fi"; import { Tooltip } from "react-tooltip"; +import { twJoin } from "tailwind-merge"; import blue from "@/app/assets/blue-check.svg"; import { Hash } from "@/app/components/Hash/Hash"; @@ -43,11 +44,11 @@ export const FinalityProvider: React.FC = ({ return (
@@ -72,7 +73,7 @@ export const FinalityProvider: React.FC = ({ ) : (
= ({
)}
+
+

Delegation:

@@ -105,6 +108,7 @@ export const FinalityProvider: React.FC = ({ className="tooltip-wrap" />

+

Commission:

{finalityProviderHasData diff --git a/src/app/components/Staking/FinalityProviders/FinalityProviders.tsx b/src/app/components/Staking/FinalityProviders/FinalityProviders.tsx index 7794ef13..58d4339b 100644 --- a/src/app/components/Staking/FinalityProviders/FinalityProviders.tsx +++ b/src/app/components/Staking/FinalityProviders/FinalityProviders.tsx @@ -53,7 +53,7 @@ export const FinalityProviders: React.FC = ({ ); return flattenedData; }, - retry: (failureCount, error) => { + retry: (failureCount) => { return !isErrorOpen && failureCount <= 3; }, }); diff --git a/src/app/hooks/services/useDelegationService.ts b/src/app/hooks/services/useDelegationService.ts new file mode 100644 index 00000000..1858140a --- /dev/null +++ b/src/app/hooks/services/useDelegationService.ts @@ -0,0 +1,17 @@ +import { useDelegationState } from "@/app/state/DelegationState"; + +export function useDelegationService() { + const { + delegations = [], + fetchMoreDelegations, + hasMoreDelegations, + isLoading, + } = useDelegationState(); + + return { + delegations, + fetchMoreDelegations, + hasMoreDelegations, + isLoading, + }; +} diff --git a/src/app/hooks/useSigner.ts b/src/app/hooks/useSigner.ts new file mode 100644 index 00000000..9da078ce --- /dev/null +++ b/src/app/hooks/useSigner.ts @@ -0,0 +1,32 @@ +import { Psbt, Transaction } from "bitcoinjs-lib"; + +import { useWallet } from "@/app/context/wallet/WalletProvider"; + +const SIGN_PSBT_NOT_COMPATIBLE_WALLETS = ["OneKey"]; + +export function useSigner() { + const { walletProvider } = useWallet(); + + const signPsbtTx = async (psbtHex: string) => { + if (!walletProvider) { + throw Error("Wallet is not provided"); + } + + const signedHex = await walletProvider.signPsbt(psbtHex); + const providerName = await walletProvider.getWalletProviderName(); + if (SIGN_PSBT_NOT_COMPATIBLE_WALLETS.includes(providerName)) { + try { + // Try to parse the signedHex as PSBT to see if it follows the new implementation + return Psbt.fromHex(signedHex).extractTransaction(); + } catch { + // If parsing fails, it's the old version implementation + return Transaction.fromHex(signedHex); + } + } + + // For compatible wallets, directly extract the transaction from the signed PSBT + return Psbt.fromHex(signedHex).extractTransaction(); + }; + + return { signPsbtTx }; +} diff --git a/src/app/hooks/utils/useDelegationUtils.ts b/src/app/hooks/utils/useDelegationUtils.ts new file mode 100644 index 00000000..2d2731c8 --- /dev/null +++ b/src/app/hooks/utils/useDelegationUtils.ts @@ -0,0 +1,193 @@ +import { + PsbtTransactionResult, + unbondingTransaction, + withdrawEarlyUnbondedTransaction, + withdrawTimelockUnbondedTransaction, +} from "@babylonlabs-io/btc-staking-ts"; +import { Transaction } from "bitcoinjs-lib"; + +import { getUnbondingEligibility } from "@/app/api/getUnbondingEligibility"; +import { postUnbonding } from "@/app/api/postUnbonding"; +import { useWallet } from "@/app/context/wallet/WalletProvider"; +import { useSigner } from "@/app/hooks/useSigner"; +import { useVersions } from "@/app/hooks/useVersions"; +import type { Delegation } from "@/app/types/delegations"; +import { apiDataToStakingScripts } from "@/utils/apiDataToStakingScripts"; +import { txFeeSafetyCheck } from "@/utils/delegations/fee"; +import { getFeeRateFromMempool } from "@/utils/getFeeRateFromMempool"; +import { getCurrentGlobalParamsVersion } from "@/utils/globalParams"; + +const getStakerSignature = (unbondingTx: Transaction): string => { + try { + return unbondingTx.ins[0].witness[0].toString("hex"); + } catch (error) { + throw new Error("Failed to get staker signature"); + } +}; + +export function useDelegationUtils() { + const { walletProvider, address, publicKeyNoCoord, network } = useWallet(); + const { signPsbtTx } = useSigner(); + const { data: paramVersions = [] } = useVersions(); + + const signUnbondingTx = async (delegation: Delegation): Promise => { + if (!publicKeyNoCoord || !network) { + throw Error("Wallet is not connected"); + } + + // Check if the unbonding is possible + const unbondingEligibility = await getUnbondingEligibility( + delegation.stakingTxHashHex, + ); + + if (!unbondingEligibility) { + throw new Error("Not eligible for unbonding"); + } + + // State of global params when the staking transaction was submitted + const { currentVersion: globalParamsWhenStaking } = + getCurrentGlobalParamsVersion( + delegation.stakingTx.startHeight, + paramVersions, + ); + + if (!globalParamsWhenStaking) { + throw new Error("Current version not found"); + } + + // Recreate the staking scripts + const scripts = apiDataToStakingScripts( + delegation.finalityProviderPkHex, + delegation.stakingTx.timelock, + globalParamsWhenStaking, + publicKeyNoCoord, + ); + + // Create the unbonding transaction + const { psbt: unsignedUnbondingTx } = unbondingTransaction( + scripts, + Transaction.fromHex(delegation.stakingTx.txHex), + globalParamsWhenStaking.unbondingFeeSat, + network, + delegation.stakingTx.outputIndex, + ); + + // Sign the unbonding transaction + let unbondingTx: Transaction; + try { + unbondingTx = await signPsbtTx(unsignedUnbondingTx.toHex()); + } catch (error) { + throw new Error("Failed to sign PSBT for the unbonding transaction"); + } + + // Get the staker signature + const stakerSignature = getStakerSignature(unbondingTx); + + // Get the unbonding transaction hex + const unbondingTxHex = unbondingTx.toHex(); + + // POST unbonding to the API + await postUnbonding( + stakerSignature, + delegation.stakingTxHashHex, + unbondingTx.getId(), + unbondingTxHex, + ); + + return unbondingTxHex; + }; + + const signWithdrawalTx = async (delegation: Delegation): Promise => { + if (!publicKeyNoCoord || !network || !walletProvider || !address) { + throw Error("Wallet is not connected"); + } + + // Get the required data + const fees = await walletProvider.getNetworkFees(); + + // State of global params when the staking transaction was submitted + const { currentVersion: globalParamsWhenStaking } = + getCurrentGlobalParamsVersion( + delegation.stakingTx.startHeight, + paramVersions, + ); + + if (!globalParamsWhenStaking) { + throw new Error("Current version not found"); + } + + // Recreate the staking scripts + const { + timelockScript, + slashingScript, + unbondingScript, + unbondingTimelockScript, + } = apiDataToStakingScripts( + delegation.finalityProviderPkHex, + delegation.stakingTx.timelock, + globalParamsWhenStaking, + publicKeyNoCoord, + ); + + const feeRate = getFeeRateFromMempool(fees); + + // Create the withdrawal transaction + let withdrawPsbtTxResult: PsbtTransactionResult; + if (delegation?.unbondingTx) { + // Withdraw funds from an unbonding transaction that was submitted for early unbonding and the unbonding period has passed + withdrawPsbtTxResult = withdrawEarlyUnbondedTransaction( + { + unbondingTimelockScript, + slashingScript, + }, + Transaction.fromHex(delegation.unbondingTx.txHex), + address, + network, + feeRate.defaultFeeRate, + ); + } else { + // Withdraw funds from a staking transaction in which the timelock naturally expired + withdrawPsbtTxResult = withdrawTimelockUnbondedTransaction( + { + timelockScript, + slashingScript, + unbondingScript, + }, + Transaction.fromHex(delegation.stakingTx.txHex), + address, + network, + feeRate.defaultFeeRate, + delegation.stakingTx.outputIndex, + ); + } + + // Sign the withdrawal transaction + let withdrawalTx: Transaction; + + try { + const { psbt } = withdrawPsbtTxResult; + withdrawalTx = await signPsbtTx(psbt.toHex()); + } catch (error) { + throw new Error("Failed to sign PSBT for the withdrawal transaction"); + } + + // Get the withdrawal transaction hex + const withdrawalTxHex = withdrawalTx.toHex(); + // Perform a safety check on the estimated transaction fee + txFeeSafetyCheck( + withdrawalTx, + feeRate.defaultFeeRate, + withdrawPsbtTxResult.fee, + ); + + // Broadcast withdrawal transaction + await walletProvider.pushTx(withdrawalTxHex); + + return withdrawalTxHex; + }; + + return { + signUnbondingTx, + signWithdrawalTx, + }; +} diff --git a/src/app/hooks/utils/useIntermediateDelegations.ts b/src/app/hooks/utils/useIntermediateDelegations.ts new file mode 100644 index 00000000..1689a7a4 --- /dev/null +++ b/src/app/hooks/utils/useIntermediateDelegations.ts @@ -0,0 +1,114 @@ +import { useCallback, useEffect } from "react"; +import { useLocalStorage } from "usehooks-ts"; + +import { DelegationState, type Delegation } from "@/app/types/delegations"; +import { getIntermediateDelegationsLocalStorageKey } from "@/utils/local_storage/getIntermediateDelegationsLocalStorageKey"; +import { toLocalStorageIntermediateDelegation } from "@/utils/local_storage/toLocalStorageIntermediateDelegation"; + +// Local storage state for intermediate delegations (withdrawing, unbonding) +export function useIntermediateDelegations( + publicKey: string, + delegations?: Delegation[], +) { + const [intermediateDelegations, setIntermediateDelegations] = useLocalStorage< + Delegation[] + >(getIntermediateDelegationsLocalStorageKey(publicKey), []); + + useEffect( + function syncDelegations() { + if (!delegations) { + return; + } + + setIntermediateDelegations((intermediateDelegations) => { + if (!intermediateDelegations) { + return []; + } + + return intermediateDelegations.filter((intermediateDelegation) => { + const matchingDelegation = delegations.find( + (delegation) => + delegation?.stakingTxHashHex === + intermediateDelegation?.stakingTxHashHex, + ); + + if (!matchingDelegation) { + return true; // keep intermediate state if no matching state is found in the API + } + + // conditions based on intermediate states + if ( + intermediateDelegation.state === + DelegationState.INTERMEDIATE_UNBONDING + ) { + return !( + matchingDelegation.state === + DelegationState.UNBONDING_REQUESTED || + matchingDelegation.state === DelegationState.UNBONDING || + matchingDelegation.state === DelegationState.UNBONDED + ); + } + + if ( + intermediateDelegation.state === + DelegationState.INTERMEDIATE_WITHDRAWAL + ) { + return matchingDelegation.state !== DelegationState.WITHDRAWN; + } + + return true; + }); + }); + }, + [delegations, setIntermediateDelegations], + ); + + const updateDelegation = useCallback( + (delegation: Delegation, newState: string) => { + const newTxId = delegation.stakingTxHashHex; + + setIntermediateDelegations((delegations) => { + // Check if an intermediate delegation with the same transaction ID already exists + const exists = delegations.some( + (existingDelegation) => + existingDelegation.stakingTxHashHex === newTxId, + ); + + // If it doesn't exist, add the new intermediate delegation + if (!exists) { + return [ + toLocalStorageIntermediateDelegation( + newTxId, + publicKey, + delegation.finalityProviderPkHex, + delegation.stakingValueSat, + delegation.stakingTx.txHex, + delegation.stakingTx.timelock, + newState, + ), + ...delegations, + ]; + } + + // If it exists, return the existing delegations unchanged + return delegations; + }); + }, + [publicKey, setIntermediateDelegations], + ); + + const getDelegation = useCallback( + (txId: string) => { + return intermediateDelegations?.find( + (delegation) => delegation.stakingTxHashHex === txId, + ); + }, + [intermediateDelegations], + ); + + return { + intermediateDelegations, + updateDelegation, + getDelegation, + }; +} diff --git a/src/app/page.tsx b/src/app/page.tsx index c2d30fce..1634ca1a 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,7 +3,9 @@ import { initBTCCurve } from "@babylonlabs-io/btc-staking-ts"; import { useEffect } from "react"; -import { Delegations } from "./components/Delegations/Delegations"; +import { FinalityProviders } from "@/components/staking/FinalityProviders"; + +import { DelegationTabs } from "./components/Delegations/DelegationTabs"; import { FAQ } from "./components/FAQ/FAQ"; import { Footer } from "./components/Footer/Footer"; import { Header } from "./components/Header/Header"; @@ -26,7 +28,8 @@ const Home = () => { - + +
diff --git a/src/components/common/AuthGuard/index.tsx b/src/components/common/AuthGuard/index.tsx new file mode 100644 index 00000000..69752c5c --- /dev/null +++ b/src/components/common/AuthGuard/index.tsx @@ -0,0 +1,16 @@ +import type { PropsWithChildren, ReactNode } from "react"; + +import { useWallet } from "@/app/context/wallet/WalletProvider"; + +interface AuthGuardProps { + fallback?: ReactNode; +} + +export function AuthGuard({ + children, + fallback, +}: PropsWithChildren) { + const { address } = useWallet(); + + return Boolean(address) ? children : fallback; +} diff --git a/src/components/common/GridTable/components/TCell.tsx b/src/components/common/GridTable/components/TCell.tsx new file mode 100644 index 00000000..6e558b3a --- /dev/null +++ b/src/components/common/GridTable/components/TCell.tsx @@ -0,0 +1,26 @@ +import { MouseEventHandler, type ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; + +interface TCellProps { + className?: string; + align?: "left" | "right" | "center"; + children?: ReactNode; + onClick?: MouseEventHandler; +} + +const ALIGN = { + left: "justify-start", + right: "justify-end", + center: "justify-center", +} as const; + +export function GridCell({ className, align, children, onClick }: TCellProps) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/common/GridTable/components/THead.tsx b/src/components/common/GridTable/components/THead.tsx new file mode 100644 index 00000000..31ea6e4a --- /dev/null +++ b/src/components/common/GridTable/components/THead.tsx @@ -0,0 +1,48 @@ +import { useCallback, type ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; + +import type { SortColumn } from "../types"; + +interface THeadProps { + field: string; + title: ReactNode; + className?: string; + sortable?: boolean; + align?: "left" | "right" | "center"; + sortColumn?: SortColumn; + onSortChange: (sortColumn: SortColumn) => void; +} + +const SORT_DIRECTIONS = { + "": "ASC", + ASC: "DESC", + DESC: "", +} as const; + +export function GridHead({ + field, + title, + className, + align, + sortable = false, + sortColumn, + onSortChange, +}: THeadProps) { + const direction = SORT_DIRECTIONS[sortColumn?.direction || ""]; + + const handleColumnClick = useCallback(() => { + if (sortable) { + onSortChange({ field, direction }); + } + }, [field, direction, sortable, onSortChange]); + + return ( +

+ {title} +

+ ); +} diff --git a/src/components/common/GridTable/index.tsx b/src/components/common/GridTable/index.tsx new file mode 100644 index 00000000..4962f3b1 --- /dev/null +++ b/src/components/common/GridTable/index.tsx @@ -0,0 +1,116 @@ +import { useId } from "react"; +import InfiniteScroll from "react-infinite-scroll-component"; +import { twJoin, twMerge } from "tailwind-merge"; + +import { LoadingTableList } from "@/app/components/Loading/Loading"; + +import { GridCell } from "./components/TCell"; +import { GridHead } from "./components/THead"; +import type { TableColumn, TableProps } from "./types"; +import { createColumnTemplate } from "./utils"; + +export function GridTable({ + loading = false, + infiniteScroll = false, + classNames, + columns, + sortColumn, + fallback, + params = {} as P, + data = [], + getRowId, + onRowClick, + onCellClick, + onInfiniteScroll = () => null, + onSortColumn = () => null, +}: TableProps) { + const id = useId(); + + function handleCellClick(row: R, col: TableColumn) { + return () => { + onRowClick?.(row); + onCellClick?.(row, col); + }; + } + + if (!data?.length) { + return fallback; + } + + return ( +
+ : null} + scrollableTarget={id} + > +
+ {columns.map((col) => ( + + ))} +
+ + {data.map((row, i) => { + const rowId = getRowId(row); + + return ( +
+ {columns.map((col) => ( + handleCellClick(row, col)} + > + {col.renderCell?.(row, i, params) ?? + (row[col.field as keyof R] as string)} + + ))} +
+ ); + })} +
+
+ ); +} + +export * from "./types"; diff --git a/src/components/common/GridTable/types.ts b/src/components/common/GridTable/types.ts new file mode 100644 index 00000000..f286ff00 --- /dev/null +++ b/src/components/common/GridTable/types.ts @@ -0,0 +1,51 @@ +import type { ReactNode } from "react"; + +export interface TableColumn { + field: string; + headerName: ReactNode; + cellClassName?: string; + headerCellClassName?: string; + width?: string; + align?: "left" | "right" | "center"; + sortable?: boolean; + renderCell?: (row: R, index: number, params: P) => ReactNode; +} + +export interface SortColumn { + field: string; + direction: "ASC" | "DESC" | ""; +} + +type RowClassNameCreator = ( + row: R, + param: P, +) => string; +type CellClassNameCreator = ( + row: R, + col: TableColumn, + param: P, +) => string; + +export interface TableProps { + loading?: boolean; + classNames?: { + wrapperClassName?: string; + headerCellClassName?: string; + headerRowClassName?: string; + rowClassName?: string | RowClassNameCreator; + cellClassName?: string | CellClassNameCreator; + bodyClassName?: string; + contentClassName?: string; + }; + sortColumn?: SortColumn; + infiniteScroll?: boolean; + columns: TableColumn[]; + data: R[]; + params?: P; + fallback?: ReactNode; + getRowId: (row: R) => string; + onRowClick?: (row: R) => void; + onCellClick?: (row: R, column: TableColumn) => void; + onSortColumn?: (sortColumn: SortColumn) => void; + onInfiniteScroll?: () => void; +} diff --git a/src/components/common/GridTable/utils.ts b/src/components/common/GridTable/utils.ts new file mode 100644 index 00000000..ca2224ea --- /dev/null +++ b/src/components/common/GridTable/utils.ts @@ -0,0 +1,17 @@ +import type { TableColumn } from "./types"; + +export function createColumnTemplate( + columns: TableColumn[], +) { + const hasWidthParam = columns.some((col) => col.width); + + if (hasWidthParam) { + return columns.reduce((template, col) => { + const colWidth = col.width ?? "minmax(0, 1fr)"; + + return template ? `${template} ${colWidth}` : colWidth; + }, ""); + } + + return `repeat(${columns.length}, minmax(0, 1fr))`; +} diff --git a/src/components/common/Hint/index.tsx b/src/components/common/Hint/index.tsx new file mode 100644 index 00000000..df3f41f4 --- /dev/null +++ b/src/components/common/Hint/index.tsx @@ -0,0 +1,26 @@ +import { type PropsWithChildren, useId } from "react"; +import { AiOutlineInfoCircle } from "react-icons/ai"; +import { Tooltip } from "react-tooltip"; + +interface HintProps { + tooltip: string; +} + +export function Hint({ children, tooltip }: PropsWithChildren) { + const id = useId(); + + return ( +
+ {children &&

{children}

} + + + + +
+ ); +} diff --git a/src/components/delegations/DelegationList/components/ActionButton.tsx b/src/components/delegations/DelegationList/components/ActionButton.tsx new file mode 100644 index 00000000..d3e5d956 --- /dev/null +++ b/src/components/delegations/DelegationList/components/ActionButton.tsx @@ -0,0 +1,38 @@ +import { DelegationState } from "../type"; + +interface ActionButtonProps { + txHash: string; + state: string; + onClick?: (action: string, txHash: string) => void; +} + +type ButtonAdapter = (props: ActionButtonProps) => JSX.Element; +type ButtonStrategy = Record; + +const ACTION_BUTTONS: ButtonStrategy = { + [DelegationState.ACTIVE]: (props: ActionButtonProps) => ( + + ), + + [DelegationState.UNBONDED]: (props: ActionButtonProps) => ( + + ), +}; + +export function ActionButton(props: ActionButtonProps) { + const Button = ACTION_BUTTONS[props.state]; + + return