diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index d54407b8..cbc6d014 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -20,7 +20,8 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - environment: [devnet, phase-2-devnet, staging, testnet, mainnet-private, mainnet] + environment: + [devnet, phase-2-devnet, staging, testnet, mainnet-private, mainnet] environment: ${{ matrix.environment }} steps: - name: Checkout repository @@ -51,7 +52,8 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - environment: [devnet, phase-2-devnet, staging, testnet, mainnet-private, mainnet] + environment: + [devnet, phase-2-devnet, staging, testnet, mainnet-private, mainnet] needs: ["docker_build"] steps: - name: Download Docker image from workspace @@ -84,7 +86,8 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - environment: [devnet, phase-2-devnet, staging, testnet, mainnet-private, mainnet] + environment: + [devnet, phase-2-devnet, staging, testnet, mainnet-private, mainnet] needs: ["docker_build"] steps: - name: Download Docker image from workspace diff --git a/package-lock.json b/package-lock.json index 44b540b7..6d9c5848 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,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", @@ -15095,6 +15096,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.28.0", "resolved": "https://registry.npmjs.org/react-tooltip/-/react-tooltip-5.28.0.tgz", diff --git a/package.json b/package.json index 7a3ab456..c7a10896 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,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 fbe01d30..754cbd46 100644 --- a/src/app/components/Delegations/Delegations.tsx +++ b/src/app/components/Delegations/Delegations.tsx @@ -42,23 +42,21 @@ export const Delegations = () => { } return ( - network && ( - + - - - ) + btcWalletNetwork={network} + publicKeyNoCoord={publicKeyNoCoord} + isWalletConnected={connected} + /> + ); }; @@ -287,8 +285,7 @@ const DelegationsContent: React.FC = ({ delegations; return ( -
-

Staking history

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

No history found

@@ -366,6 +363,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/page.tsx b/src/app/page.tsx index c2d30fce..effd1ad3 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -3,7 +3,7 @@ import { initBTCCurve } from "@babylonlabs-io/btc-staking-ts"; import { useEffect } from "react"; -import { Delegations } from "./components/Delegations/Delegations"; +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 +26,7 @@ const Home = () => { - +
diff --git a/src/components/common/AuthGuard/index.tsx b/src/components/common/AuthGuard/index.tsx new file mode 100644 index 00000000..eb248fd7 --- /dev/null +++ b/src/components/common/AuthGuard/index.tsx @@ -0,0 +1,16 @@ +import type { PropsWithChildren, ReactNode } from "react"; + +import { useWalletConnection } from "@/app/context/wallet/WalletConnectionProvider"; + +interface AuthGuardProps { + fallback?: ReactNode; +} + +export function AuthGuard({ + children, + fallback, +}: PropsWithChildren) { + const { isConnected } = useWalletConnection(); + + return isConnected ? 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