diff --git a/src/Routes.tsx b/src/Routes.tsx index f56de1023..2bb29716c 100644 --- a/src/Routes.tsx +++ b/src/Routes.tsx @@ -55,6 +55,9 @@ const SwapAndBridge = lazyWithRetry( () => import(/* webpackChunkName: "RewardStaking" */ "./views/SwapAndBridge") ); const DepositStatus = lazyWithRetry(() => import("./views/DepositStatus")); +const Transaction = lazyWithRetry( + () => import(/* webpackChunkName: "Transaction" */ "./views/Transaction") +); function useRoutes() { const [enableACXBanner, setEnableACXBanner] = useState(true); @@ -113,6 +116,11 @@ const Routes: React.FC = () => { }> + ; + animate?: Record; + exit?: Record; + transition?: Record; +}; + +type OverlayConfig = { + depositId: string; + color: "aqua" | "white"; + animation: AnimationProps; +}; + +type Props = { + overlay: OverlayConfig | null; +}; + +export function AnimatedColorOverlay({ overlay }: Props) { + const props = overlay; + if (!props) return null; + + const { depositId, color, animation } = props; + + return ( + + + + ); +} + +const ColorOverlay = styled(motion.div)<{ color: "aqua" | "yellow" | "white" }>` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: ${({ color }) => COLORS[color]}; + pointer-events: none; + z-index: 0; +`; diff --git a/src/components/DepositsTable/DataRow.tsx b/src/components/DepositsTable/DataRow.tsx index b97aecae0..5b57dd672 100644 --- a/src/components/DepositsTable/DataRow.tsx +++ b/src/components/DepositsTable/DataRow.tsx @@ -1,9 +1,11 @@ import styled from "@emotion/styled"; +import { motion } from "framer-motion"; +import { useHistory } from "react-router-dom"; import { Deposit } from "hooks/useDeposits"; import { COLORS, getConfig } from "utils"; -import { HeaderCells, ColumnKey } from "./HeadRow"; +import { ColumnKey, HeaderCells } from "./HeadRow"; import { AssetCell } from "./cells/AssetCell"; import { AmountCell } from "./cells/AmountCell"; import { RouteCell } from "./cells/RouteCell"; @@ -17,6 +19,9 @@ import { RateCell } from "./cells/RateCell"; import { RewardsCell } from "./cells/RewardsCell"; import { ActionsCell } from "./cells/ActionsCell"; import { useTokenFromAddress } from "hooks/useToken"; +import { TimeAgoCell } from "./cells/TimeAgoCell"; +import { useDepositRowAnimation } from "./hooks/useDepositRowAnimation"; +import { AnimatedColorOverlay } from "./AnimatedColorOverlay"; type Props = { deposit: Deposit; @@ -35,6 +40,9 @@ export function DataRow({ disabledColumns = [], onClickSpeedUp, }: Props) { + const history = useHistory(); + const { rowAnimation, overlayProps } = useDepositRowAnimation(deposit); + const swapToken = useTokenFromAddress( deposit.swapToken?.address || "", deposit.sourceChainId @@ -51,13 +59,17 @@ export function DataRow({ deposit.destinationChainId ); - // Hide unsupported or unknown token deposits if (!inputToken) { return null; } + const handleRowClick = () => { + history.push(`/transaction/${deposit.depositTxHash}`); + }; + return ( - + + {isColumnDisabled(disabledColumns, "asset") ? null : ( )} + {isColumnDisabled(disabledColumns, "timeAgo") ? null : ( + + )} {isColumnDisabled(disabledColumns, "actions") ? null : ( )} @@ -107,13 +122,29 @@ export function DataRow({ ); } -const StyledRow = styled.tr` +const StyledRow = styled(motion.tr)` + position: relative; display: flex; flex-direction: row; align-items: center; + justify-content: space-between; gap: 16px; padding: 0px 24px; border-width: 0px 1px 1px 1px; border-style: solid; border-color: ${COLORS["grey-600"]}; + cursor: pointer; + overflow: hidden; + transform-origin: top; + transition: background-color 0.2s; + + &:hover { + background-color: ${COLORS["grey-500"]}; + } + + & > td, + & > div:not(.color-overlay) { + position: relative; + z-index: 1; + } `; diff --git a/src/components/DepositsTable/HeadRow.tsx b/src/components/DepositsTable/HeadRow.tsx index d62505140..2c7681c6a 100644 --- a/src/components/DepositsTable/HeadRow.tsx +++ b/src/components/DepositsTable/HeadRow.tsx @@ -55,6 +55,10 @@ export const headerCells = { label: "Rewards", width: 128, }, + timeAgo: { + label: "Time", + width: 84, + }, actions: { label: "", width: 64, @@ -102,6 +106,7 @@ const StyledHead = styled.thead``; const StyledRow = styled.tr` display: flex; height: 40px; + justify-content: space-between; align-items: center; padding: 0px 24px; gap: 16px; diff --git a/src/components/DepositsTable/PaginatedDepositsTable.tsx b/src/components/DepositsTable/PaginatedDepositsTable.tsx index 70ecbd551..2a7743673 100644 --- a/src/components/DepositsTable/PaginatedDepositsTable.tsx +++ b/src/components/DepositsTable/PaginatedDepositsTable.tsx @@ -12,6 +12,7 @@ type Props = DepositsTableProps & { initialPageSize?: number; pageSizes?: number[]; displayPageNumbers?: boolean; + hasNoResults: boolean; }; const DEFAULT_PAGE_SIZES = [10, 25, 50]; @@ -24,6 +25,7 @@ export function PaginatedDepositsTable({ totalCount, pageSizes = DEFAULT_PAGE_SIZES, displayPageNumbers = true, + hasNoResults, ...depositsTableProps }: Props) { const paginateValues = paginate({ @@ -36,23 +38,25 @@ export function PaginatedDepositsTable({ return ( <> - - - + {!hasNoResults && ( + + + + )} > ); } diff --git a/src/components/DepositsTable/cells/ActionsCell.tsx b/src/components/DepositsTable/cells/ActionsCell.tsx index e315121f7..bb63b4d42 100644 --- a/src/components/DepositsTable/cells/ActionsCell.tsx +++ b/src/components/DepositsTable/cells/ActionsCell.tsx @@ -54,6 +54,11 @@ export function ActionsCell({ deposit, onClickSpeedUp }: Props) { ) : null; + if (!speedUp && !slowRelayInfo) { + // This might be wrong. We want to show the actions if either sppedup or slowRelayInfo is there + return null; + } + return ( diff --git a/src/components/DepositsTable/cells/StatusCell.tsx b/src/components/DepositsTable/cells/StatusCell.tsx index 50c52237e..d0fc72823 100644 --- a/src/components/DepositsTable/cells/StatusCell.tsx +++ b/src/components/DepositsTable/cells/StatusCell.tsx @@ -58,18 +58,21 @@ function FilledStatusCell({ deposit, width }: Props) { function PendingStatusCell({ width, deposit }: Props) { const { isDelayed, isProfitable, isExpired } = useDepositStatus(deposit); + // On all transactions page, always show "Processing..." instead of "Fee too low" + const showAsProcessing = deposit.hideFeeTooLow || isProfitable; + return ( {isExpired ? "Expired" : isDelayed ? "Delayed" - : isProfitable + : showAsProcessing ? "Processing..." : "Fee too low"} @@ -90,7 +93,7 @@ function PendingStatusCell({ width, deposit }: Props) { > - ) : isProfitable ? ( + ) : showAsProcessing ? ( ) : ( + getTimeAgoText(deposit.depositTime) + ); + + // Update every second for live time updates + useEffect(() => { + const interval = setInterval(() => { + setTimeAgoText(getTimeAgoText(deposit.depositTime)); + }, 1000); + return () => clearInterval(interval); + }, [deposit.depositTime]); + + const fullDateTime = DateTime.fromSeconds(deposit.depositTime).toFormat( + "dd LLL yyyy, hh:mm:ss a" + ); + + return ( + + + {timeAgoText} + + + ); +} + +const StyledTimeAgoCell = styled(BaseCell)` + display: flex; + align-items: center; + position: relative; + cursor: help; +`; diff --git a/src/components/DepositsTable/cells/getTimeAgoText.tsx b/src/components/DepositsTable/cells/getTimeAgoText.tsx new file mode 100644 index 000000000..fc9af2232 --- /dev/null +++ b/src/components/DepositsTable/cells/getTimeAgoText.tsx @@ -0,0 +1,19 @@ +import { DateTime } from "luxon"; + +export function getTimeAgoText(seconds: number): string { + const now = DateTime.now(); + const depositTime = DateTime.fromSeconds(seconds); + const diff = now.diff(depositTime, ["minutes", "seconds"]).toObject(); + + const totalSeconds = Math.floor(diff.seconds || 0); + const totalMinutes = Math.floor((diff.minutes || 0) + totalSeconds / 60); + + if (totalMinutes === 0) { + return "just now"; + } else if (totalMinutes < 3) { + return `${totalMinutes}m ago`; + } else { + // After 3 minutes, show the actual date/time + return depositTime.toFormat("dd LLL, hh:mm a"); + } +} diff --git a/src/components/DepositsTable/hooks/useDepositRowAnimation.ts b/src/components/DepositsTable/hooks/useDepositRowAnimation.ts new file mode 100644 index 000000000..510039ca9 --- /dev/null +++ b/src/components/DepositsTable/hooks/useDepositRowAnimation.ts @@ -0,0 +1,77 @@ +import { useEffect, useRef, useState } from "react"; +import { Deposit, DepositStatus } from "hooks/useDeposits"; + +type AnimationProps = { + initial?: Record; + animate?: Record; + exit?: Record; + transition?: Record; + layout?: boolean; +}; + +type OverlayProps = { + depositId: string; + color: "aqua" | "white"; + animation: AnimationProps; +} | null; + +type DepositRowAnimationResult = { + rowAnimation: AnimationProps; + overlayProps: OverlayProps; +}; + +export function useDepositRowAnimation( + deposit: Deposit +): DepositRowAnimationResult { + const previousStatusRef = useRef(null); + const [statusJustChanged, setStatusJustChanged] = useState(false); + + useEffect(() => { + const currentStatus = deposit.status; + const previousStatus = previousStatusRef.current; + + if (previousStatus !== null && previousStatus !== currentStatus) { + setStatusJustChanged(true); + const timer = setTimeout(() => setStatusJustChanged(false), 1200); + return () => clearTimeout(timer); + } + + previousStatusRef.current = currentStatus; + }, [deposit.status]); + + const rowAnimation: AnimationProps = { + initial: { opacity: 0, scaleY: 0 }, + animate: { opacity: 1, scaleY: 1 }, + transition: { + opacity: { duration: 0.5, ease: "easeOut" }, + scaleY: { duration: 0.4, ease: [0.4, 0, 0.2, 1] }, + }, + layout: true, + }; + + const shouldShowOverlay = + previousStatusRef.current === null || statusJustChanged; + + if (!shouldShowOverlay) { + return { rowAnimation, overlayProps: null }; + } + + const overlayColor: "aqua" | "white" = + deposit.status === "filled" ? "aqua" : "white"; + + const overlayAnimation: AnimationProps = { + initial: { opacity: 0.3 }, + animate: { opacity: 0 }, + exit: { opacity: 0 }, + transition: { duration: 1.2, ease: "easeOut" }, + }; + + return { + rowAnimation, + overlayProps: { + depositId: deposit.depositId, + color: overlayColor, + animation: overlayAnimation, + }, + }; +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 517143af7..daec305b5 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -17,5 +17,6 @@ export * from "./useWalletTrace"; export * from "./useQueue"; export * from "./useAmplitude"; export * from "./useRewardSummary"; +export * from "./useElapsedSeconds"; export * from "./feature-flags/useFeatureFlag"; export * from "./useTokenInput"; diff --git a/src/hooks/useDepositStatus.ts b/src/hooks/useDepositStatus.ts new file mode 100644 index 000000000..858f02af8 --- /dev/null +++ b/src/hooks/useDepositStatus.ts @@ -0,0 +1,69 @@ +import axios from "axios"; +import { useQuery } from "@tanstack/react-query"; +import { indexerApiBaseUrl } from "utils"; + +export type IndexerDeposit = { + actionsSucceeded: boolean | null; + actionsTargetChainId: string | null; + bridgeFeeUsd: string; + depositBlockNumber: number; + depositBlockTimestamp: string; + depositId: string; + depositRefundTxHash: string | null; + depositRefundTxnRef: string | null; + depositTxHash: string; + depositTxnRef: string; + depositor: string; + destinationChainId: string; + exclusiveRelayer: string; + exclusivityDeadline: string | null; + fillBlockTimestamp: string | null; + fillDeadline: string; + fillGasFee: string; + fillGasFeeUsd: string; + fillGasTokenPriceUsd: string; + fillTx: string; + id: number; + inputAmount: string; + inputPriceUsd: string; + inputToken: string; + message: string; + messageHash: string; + originChainId: string; + outputAmount: string; + outputPriceUsd: string; + outputToken: string; + quoteTimestamp: string; + recipient: string; + relayHash: string; + relayer: string; + status: "filled" | "pending" | "unfilled" | string; + swapFeeUsd: string | null; + swapToken: string | null; + swapTokenAmount: string | null; + swapTokenPriceUsd: string | null; + swapTransactionHash: string | null; +}; + +export type IndexerDepositResponse = { + deposit: IndexerDeposit; + pagination: { + currentIndex: number; + maxIndex: number; + }; +}; + +export function useDepositByTxHash(depositTxHash?: string) { + return useQuery({ + queryKey: ["deposit", depositTxHash], + queryFn: async () => { + const { data } = await axios.get( + `${indexerApiBaseUrl}/deposit`, + { params: { depositTxHash } } + ); + return data; + }, + enabled: !!depositTxHash, + refetchInterval: 10000, + }); +} diff --git a/src/hooks/useDeposits.ts b/src/hooks/useDeposits.ts index e4b5a8862..066caad6b 100644 --- a/src/hooks/useDeposits.ts +++ b/src/hooks/useDeposits.ts @@ -2,10 +2,9 @@ import axios from "axios"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { - depositsQueryKey, - userDepositsQueryKey, defaultRefetchInterval, indexerApiBaseUrl, + userDepositsQueryKey, getConfig, } from "utils"; import { DepositStatusFilter } from "views/Transactions/types"; @@ -96,6 +95,7 @@ export type Deposit = { swapOutputToken?: string; // destination swap output token swapOutputTokenAmount?: string; // destination swap output amount actionsTargetChainId?: number; + hideFeeTooLow?: boolean; }; export type Pagination = { @@ -109,6 +109,11 @@ export type GetDepositsResponse = { deposits: Deposit[]; }; +export type GetDepositResponse = { + pagination: Pagination; + deposit: Deposit; +}; + export type IndexerDeposit = { id: number; relayHash: string; @@ -156,22 +161,23 @@ export type IndexerDeposit = { export type GetIndexerDepositsResponse = IndexerDeposit[]; export function useDeposits( - status: DepositStatusFilter, limit: number, - offset: number = 0 + offset: number = 0, + userAddress?: string ) { return useQuery({ - queryKey: depositsQueryKey(status, limit, offset), - queryFn: () => { - return getDeposits({ - status: status === "all" ? undefined : status, + queryKey: userDepositsQueryKey(userAddress!, "all", limit, offset), + queryFn: async () => ({ + deposits: await getDeposits({ + address: userAddress, + // status: status === "all" ? undefined : status, limit, offset, - skipOldUnprofitable: true, - }); - }, + }), + }), + gcTime: 0, placeholderData: keepPreviousData, - refetchInterval: defaultRefetchInterval, + refetchInterval: Infinity, }); } diff --git a/src/stories/PaginatedDepositsTable.stories.tsx b/src/stories/PaginatedDepositsTable.stories.tsx index 039ba3556..24515190d 100644 --- a/src/stories/PaginatedDepositsTable.stories.tsx +++ b/src/stories/PaginatedDepositsTable.stories.tsx @@ -174,6 +174,7 @@ const BasicPagination = () => { totalCount={mockedDeposits.length} pageSizes={[1, 2, 3]} initialPageSize={pageSize} + hasNoResults={false} /> ); }; diff --git a/src/views/RewardsProgram/GenericRewardsProgram/GenericRewardsProgram.tsx b/src/views/RewardsProgram/GenericRewardsProgram/GenericRewardsProgram.tsx index 241a04776..ffef1ad6a 100644 --- a/src/views/RewardsProgram/GenericRewardsProgram/GenericRewardsProgram.tsx +++ b/src/views/RewardsProgram/GenericRewardsProgram/GenericRewardsProgram.tsx @@ -100,6 +100,7 @@ const GenericRewardsProgram = ({ totalCount={depositsCount} initialPageSize={pageSize} disabledColumns={["actions", "bridgeFee"]} + hasNoResults={false} /> )} diff --git a/src/views/Transaction/Transaction.tsx b/src/views/Transaction/Transaction.tsx new file mode 100644 index 000000000..1319b64f7 --- /dev/null +++ b/src/views/Transaction/Transaction.tsx @@ -0,0 +1,476 @@ +import { useParams, Link } from "react-router-dom"; +import { useEffect, useState } from "react"; +import styled from "@emotion/styled"; +import { COLORS, getChainInfo, getConfig, QUERIESV2 } from "utils"; +import { Text } from "components/Text"; +import { LayoutV2 } from "components"; +import { ReactComponent as ArrowIcon } from "assets/icons/chevron-down.svg"; +import { useDepositByTxHash } from "hooks/useDepositStatus"; +import { CenteredMessage } from "./components/CenteredMessage"; +import { DetailSection } from "./components/DetailSection"; +import { StatusBadge } from "./components/StatusBadge"; +import { CopyableText } from "./components/CopyableText"; +import { CollapsibleSection } from "./components/CollapsibleSection"; +import { formatUnitsWithMaxFractions, shortenAddress } from "utils/format"; +import { TransactionSourceSection } from "./components/TransactionSourceSection"; +import { TransactionDestinationSection } from "./components/TransactionDestinationSection"; +import { TransactionFeeSection } from "./components/TransactionFeeSection"; + +const LOADING_DELAY_MS = 400; + +// Helper function to format USD string values +function formatUSDValue(value: string | null): string { + if (!value || value === "0") return "$0.00"; + const num = parseFloat(value); + if (isNaN(num)) return "$0.00"; + return `$${num.toFixed(2)}`; +} + +// Helper function to format timestamps +function formatTimestamp(timestamp: string | null): string { + if (!timestamp) return "N/A"; + try { + const date = new Date(timestamp); + return date.toLocaleString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); + } catch { + return "Invalid date"; + } +} + +function calculateFillDuration( + depositTimestamp: string | null, + fillTimestamp: string | null +): { formatted: string; isPending: boolean } { + if (!depositTimestamp) { + return { formatted: "N/A", isPending: false }; + } + + const depositTime = new Date(depositTimestamp).getTime(); + + if (!fillTimestamp) { + const elapsedMs = Date.now() - depositTime; + const elapsedSeconds = elapsedMs / 1000; + + if (elapsedSeconds < 60) { + return { + formatted: `${elapsedSeconds.toFixed(1)}s elapsed`, + isPending: true, + }; + } else if (elapsedSeconds < 3600) { + const minutes = Math.floor(elapsedSeconds / 60); + return { + formatted: `${minutes}m ${Math.floor(elapsedSeconds % 60)}s elapsed`, + isPending: true, + }; + } else { + const hours = Math.floor(elapsedSeconds / 3600); + const minutes = Math.floor((elapsedSeconds % 3600) / 60); + return { + formatted: `${hours}h ${minutes}m elapsed`, + isPending: true, + }; + } + } + + const fillTime = new Date(fillTimestamp).getTime(); + const durationMs = fillTime - depositTime; + + if (durationMs < 1000) { + return { formatted: `${durationMs}ms`, isPending: false }; + } else if (durationMs < 60000) { + return { + formatted: `${(durationMs / 1000).toFixed(3)}s`, + isPending: false, + }; + } else { + const seconds = Math.floor(durationMs / 1000); + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return { + formatted: `${minutes}m ${remainingSeconds}s`, + isPending: false, + }; + } +} + +export default function Transaction() { + const { depositTxnRef } = useParams<{ depositTxnRef: string }>(); + const { + data: depositData, + isLoading, + error, + } = useDepositByTxHash(depositTxnRef); + + const [showLoading, setShowLoading] = useState(false); + + // Delay showing loading state to avoid flash for fast loads + useEffect(() => { + if (!isLoading) { + setShowLoading(false); + return; + } + + const timer = setTimeout(() => { + setShowLoading(true); + }, LOADING_DELAY_MS); + + return () => clearTimeout(timer); + }, [isLoading]); + + if (showLoading) return ; + if (error) + return ( + + ); + if (!depositData) { + // If we have no data and not loading, it means the transaction wasn't found + if (!isLoading) { + return ; + } + // Still loading but within the delay period, show nothing yet + return null; + } + + const deposit = depositData.deposit; + const config = getConfig(); + + const sourceChainId = parseInt(deposit.originChainId); + const destinationChainId = parseInt(deposit.destinationChainId); + const sourceChain = getChainInfo(sourceChainId); + const destinationChain = getChainInfo(destinationChainId); + + const inputToken = config.getTokenInfoByAddressSafe( + sourceChainId, + deposit.inputToken + ); + const outputToken = config.getTokenInfoByAddressSafe( + destinationChainId, + deposit.outputToken + ); + + const fillDuration = calculateFillDuration( + deposit.depositBlockTimestamp, + deposit.fillBlockTimestamp + ); + + return ( + + + + + + Transactions + + + Transaction Details + + + + + + + + + {fillDuration.isPending ? "Time Elapsed:" : "Fill Time:"} + + + {fillDuration.formatted} + + + + + + + + + + + + + + + {deposit.depositBlockNumber} + + + + + {formatTimestamp(deposit.fillDeadline)} + + + + {deposit.exclusivityDeadline && ( + + + {formatTimestamp(deposit.exclusivityDeadline)} + + + )} + + + + {deposit.relayHash} + + + + + + {deposit.messageHash} + + + + {deposit.message && deposit.message !== "0x" && ( + + + {deposit.message} + + + )} + + {deposit.actionsTargetChainId && ( + <> + + + { + getChainInfo(parseInt(deposit.actionsTargetChainId)) + .name + } + + + + + + {deposit.actionsSucceeded === null + ? "Pending" + : deposit.actionsSucceeded + ? "Yes" + : "No"} + + + > + )} + + {deposit.swapToken && ( + <> + + + {shortenAddress(deposit.swapToken, "...", 6)} + + + + {deposit.swapTokenAmount && ( + + + {deposit.swapTokenAmount}{" "} + + ( + {formatUSDValue( + deposit.swapTokenPriceUsd && deposit.swapTokenAmount + ? String( + parseFloat(deposit.swapTokenPriceUsd) * + parseFloat(deposit.swapTokenAmount) + ) + : null + )} + ) + + + + )} + > + )} + + {deposit.fillGasTokenPriceUsd && ( + + + {formatUSDValue(deposit.fillGasTokenPriceUsd)} + + + )} + + + + + + ); +} + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + gap: 24px; + + max-width: 1140px; + width: 100%; + + margin: 0 auto; + padding: 32px 0; + + @media ${QUERIESV2.sm.andDown} { + padding: 16px 0; + gap: 16px; + } +`; + +const InnerSectionWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 0px; + gap: 24px; + + width: 100%; + + @media ${QUERIESV2.sm.andDown} { + gap: 16px; + } +`; + +const BreadcrumbWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 0px; + gap: 12px; + width: 100%; +`; + +const BreadcrumbDivider = styled.div` + background: #34353b; + height: 1px; + width: 100%; +`; + +const BreadcrumbContent = styled.div` + display: flex; + flex-direction: row; + align-items: center; + padding: 0px; + gap: 8px; +`; + +const BreadcrumbLink = styled(Link)` + color: #9daab2; + text-transform: capitalize; + text-decoration: none; +`; + +const BreadcrumbLinkText = styled(Text)` + color: #9daab2; + text-transform: capitalize; +`; + +const CurrentPageText = styled(Text)` + color: #e0f3ff; + text-transform: capitalize; +`; + +const StyledArrowIcon = styled(ArrowIcon)` + rotate: -90deg; +`; + +const DetailsGrid = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: 24px; + width: 100%; + padding: 24px; + background: ${COLORS["grey-600"]}; + border-radius: 12px; + border: 1px solid ${COLORS["grey-500"]}; + + @media ${QUERIESV2.sm.andDown} { + grid-template-columns: 1fr; + gap: 20px; + padding: 16px; + } +`; + +const HeaderBar = styled.div` + display: flex; + flex-direction: row; + align-items: center; + width: 100%; + padding: 16px; + background: ${COLORS["black-800"]}; + border-radius: 16px; + border: 1px solid ${COLORS["grey-600"]}; + gap: 16px; + + @media ${QUERIESV2.sm.andDown} { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } +`; + +const FillTimeText = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +`; + +const TwoColumnGrid = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + width: 100%; + + @media ${QUERIESV2.sm.andDown} { + grid-template-columns: 1fr; + } +`; diff --git a/src/views/Transaction/components/CenteredMessage.tsx b/src/views/Transaction/components/CenteredMessage.tsx new file mode 100644 index 000000000..c1401a8ba --- /dev/null +++ b/src/views/Transaction/components/CenteredMessage.tsx @@ -0,0 +1,59 @@ +import styled from "@emotion/styled"; +import { COLORS, QUERIESV2 } from "utils"; + +type Props = { + title: string; + error?: string; +}; + +export function CenteredMessage({ title, error }: Props) { + return ( + + + {title} + {error && {error}} + + + ); +} + +const Wrapper = styled.div` + display: flex; + justify-content: center; + padding: 40px 20px; + min-height: calc(100vh - 200px); + background: ${COLORS["black-900"]}; + + @media ${QUERIESV2.sm.andDown} { + padding: 20px 12px; + } +`; + +const Container = styled.div` + max-width: 900px; + width: 100%; + background: ${COLORS["grey-600"]}; + border-radius: 12px; + padding: 32px; + border: 1px solid ${COLORS["grey-500"]}; + + @media ${QUERIESV2.sm.andDown} { + padding: 20px; + } +`; + +const Title = styled.h1` + font-size: 28px; + font-weight: 600; + color: ${COLORS.white}; + margin: 0; + + @media ${QUERIESV2.sm.andDown} { + font-size: 24px; + } +`; + +const ErrorText = styled.div` + color: ${COLORS["error"]}; + font-size: 14px; +`; diff --git a/src/views/Transaction/components/CollapsibleSection.tsx b/src/views/Transaction/components/CollapsibleSection.tsx new file mode 100644 index 000000000..3208434e7 --- /dev/null +++ b/src/views/Transaction/components/CollapsibleSection.tsx @@ -0,0 +1,77 @@ +import styled from "@emotion/styled"; +import { useState } from "react"; +import { Text } from "components/Text"; +import { COLORS, QUERIESV2 } from "utils"; +import { ReactComponent as ChevronIcon } from "assets/icons/chevron-down.svg"; + +type Props = { + title: string; + children: React.ReactNode; + defaultOpen?: boolean; +}; + +export function CollapsibleSection({ + title, + children, + defaultOpen = false, +}: Props) { + const [isOpen, setIsOpen] = useState(defaultOpen); + + return ( + + setIsOpen(!isOpen)}> + {title} + + + {isOpen && {children}} + + ); +} + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 0px; + gap: 16px; + width: 100%; +`; + +const HeaderButton = styled.button` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 0px 16px; + width: 100%; + background: transparent; + border: none; + cursor: pointer; + transition: opacity 0.2s ease; + + &:hover { + opacity: 0.8; + } + + @media ${QUERIESV2.sm.andDown} { + padding: 0px 12px; + } +`; + +const Title = styled(Text)` + color: #9daab2; + text-align: left; +`; + +const StyledChevronIcon = styled(ChevronIcon)<{ isOpen: boolean }>` + width: 20px; + height: 20px; + color: ${COLORS["grey-400"]}; + transition: transform 0.2s ease; + transform: ${({ isOpen }) => (isOpen ? "rotate(180deg)" : "rotate(0deg)")}; + flex-shrink: 0; +`; + +const ContentWrapper = styled.div` + width: 100%; +`; diff --git a/src/views/Transaction/components/CopyIconButton.tsx b/src/views/Transaction/components/CopyIconButton.tsx new file mode 100644 index 000000000..5f5c05ac2 --- /dev/null +++ b/src/views/Transaction/components/CopyIconButton.tsx @@ -0,0 +1,101 @@ +import styled from "@emotion/styled"; +import { useState } from "react"; +import { useCopyToClipboard } from "hooks/useCopyToClipboard"; +import { COLORS } from "utils"; + +type Props = { + textToCopy: string; + className?: string; +}; + +export function CopyIconButton({ textToCopy, className }: Props) { + const [showTooltip, setShowTooltip] = useState(false); + const { copyToClipboard, success } = useCopyToClipboard(); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + await copyToClipboard([{ content: textToCopy }]); + setShowTooltip(true); + setTimeout(() => setShowTooltip(false), 2000); + }; + + return ( + + + + + + + {showTooltip && ( + {success ? "Copied!" : "Failed to copy"} + )} + + ); +} + +const ButtonWrapper = styled.div` + position: relative; + display: inline-flex; +`; + +const CopyButton = styled.button` + background: none; + border: none; + padding: 4px; + cursor: pointer; + color: ${COLORS["grey-400"]}; + display: inline-flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + border-radius: 4px; + + &:hover { + color: ${COLORS.aqua}; + background: rgba(108, 249, 216, 0.1); + } + + &:active { + transform: scale(0.95); + } +`; + +const Tooltip = styled.div` + position: absolute; + bottom: calc(100% + 4px); + left: 50%; + transform: translateX(-50%); + background: ${COLORS["grey-600"]}; + color: ${COLORS.white}; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + white-space: nowrap; + border: 1px solid ${COLORS["grey-500"]}; + z-index: 1000; + animation: fadeIn 0.2s ease-in; + + @keyframes fadeIn { + from { + opacity: 0; + transform: translateX(-50%) translateY(4px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + } +`; diff --git a/src/views/Transaction/components/CopyableAddress.tsx b/src/views/Transaction/components/CopyableAddress.tsx new file mode 100644 index 000000000..354103290 --- /dev/null +++ b/src/views/Transaction/components/CopyableAddress.tsx @@ -0,0 +1,38 @@ +import { TextColor, TextSize } from "components/Text"; +import { CSSProperties } from "react"; +import { useEnsQuery } from "hooks/useEns"; +import { shortenAddress } from "utils/format"; +import { CopyableText } from "./CopyableText"; + +type Props = { + address: string; + explorerLink?: string; + color?: TextColor; + size?: TextSize; + weight?: number; + style?: CSSProperties; +}; + +export function CopyableAddress({ + address, + explorerLink, + color, + size, + weight, + style, +}: Props) { + const { data } = useEnsQuery(address); + + return ( + + {data.ensName ?? shortenAddress(address, "...", 6)} + + ); +} diff --git a/src/views/Transaction/components/CopyableText.tsx b/src/views/Transaction/components/CopyableText.tsx new file mode 100644 index 000000000..0f13bdeba --- /dev/null +++ b/src/views/Transaction/components/CopyableText.tsx @@ -0,0 +1,77 @@ +import styled from "@emotion/styled"; +import { Text, TextColor, TextSize } from "components/Text"; +import { CopyIconButton } from "./CopyIconButton"; +import { CSSProperties } from "react"; +import { ReactComponent as ExternalLinkIcon } from "assets/icons/arrow-up-right.svg"; +import { COLORS } from "utils"; + +type Props = { + textToCopy: string; + children: React.ReactNode; + color?: TextColor; + size?: TextSize; + weight?: number; + style?: CSSProperties; + explorerLink?: string; +}; + +export function CopyableText({ + textToCopy, + children, + color, + size, + weight, + style, + explorerLink, +}: Props) { + return ( + + + {children} + + + + {explorerLink && ( + + + + )} + + + ); +} + +const Wrapper = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +const ActionsWrapper = styled.div` + display: flex; + align-items: center; + gap: 4px; +`; + +const ExplorerLink = styled.a` + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: opacity 0.2s; + + &:hover { + opacity: 0.7; + } +`; + +const StyledExternalLinkIcon = styled(ExternalLinkIcon)` + width: 14px; + height: 14px; + color: ${COLORS["grey-400"]}; +`; diff --git a/src/views/Transaction/components/DetailSection.tsx b/src/views/Transaction/components/DetailSection.tsx new file mode 100644 index 000000000..cb8eb18d2 --- /dev/null +++ b/src/views/Transaction/components/DetailSection.tsx @@ -0,0 +1,32 @@ +import styled from "@emotion/styled"; +import { Text } from "components/Text"; +import { COLORS } from "utils"; + +type Props = { + label: string; + children: React.ReactNode; +}; + +export function DetailSection({ label, children }: Props) { + return ( + + {label} + {children} + + ); +} + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const SectionLabel = styled(Text)` + font-size: 12px; + font-weight: 600; + color: ${COLORS["grey-400"]}; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +`; diff --git a/src/views/Transaction/components/IconPairDisplay.tsx b/src/views/Transaction/components/IconPairDisplay.tsx new file mode 100644 index 000000000..d1d5223d9 --- /dev/null +++ b/src/views/Transaction/components/IconPairDisplay.tsx @@ -0,0 +1,38 @@ +import styled from "@emotion/styled"; +import { Text } from "components/Text"; +import { IconPair } from "components/IconPair"; + +type Props = { + leftIcon: string; + leftAlt: string; + rightIcon: string; + rightAlt: string; + label: string; +}; + +export function IconPairDisplay({ + leftIcon, + leftAlt, + rightIcon, + rightAlt, + label, +}: Props) { + return ( + + } + RightIcon={} + iconSize={32} + /> + + {label} + + + ); +} + +const InfoRow = styled.div` + display: flex; + align-items: center; + gap: 20px; +`; diff --git a/src/views/Transaction/components/QuickLinksBar.tsx b/src/views/Transaction/components/QuickLinksBar.tsx new file mode 100644 index 000000000..058bc7b44 --- /dev/null +++ b/src/views/Transaction/components/QuickLinksBar.tsx @@ -0,0 +1,113 @@ +import styled from "@emotion/styled"; +import { Text } from "components/Text"; +import { COLORS, QUERIESV2 } from "utils"; +import { ReactComponent as ExternalLinkIcon } from "assets/icons/arrow-up-right.svg"; + +type QuickLink = { + label: string; + url: string; + chainName: string; +}; + +type Props = { + links: QuickLink[]; +}; + +export function QuickLinksBar({ links }: Props) { + if (links.length === 0) return null; + + return ( + + Quick Links + + {links.map((link, index) => ( + + + {link.label} + + + + {link.chainName} + + + + + ))} + + + ); +} + +const Container = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + width: 100%; + padding: 20px 24px; + background: ${COLORS["grey-600"]}; + border-radius: 12px; + border: 1px solid ${COLORS["grey-500"]}; + + @media ${QUERIESV2.sm.andDown} { + padding: 16px; + } +`; + +const Label = styled.div` + font-size: 12px; + font-weight: 600; + color: ${COLORS["grey-400"]}; + text-transform: uppercase; + letter-spacing: 0.5px; +`; + +const LinksWrapper = styled.div` + display: flex; + flex-wrap: wrap; + gap: 12px; + + @media ${QUERIESV2.sm.andDown} { + flex-direction: column; + } +`; + +const LinkButton = styled.a` + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 16px; + background: ${COLORS["grey-500"]}; + border: 1px solid ${COLORS["grey-400"]}; + border-radius: 8px; + text-decoration: none; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: ${COLORS["grey-400"]}; + border-color: ${COLORS.aqua}; + } + + @media ${QUERIESV2.sm.andDown} { + width: 100%; + justify-content: space-between; + } +`; + +const ChainBadge = styled.div` + padding: 4px 8px; + background: ${COLORS["grey-600"]}; + border-radius: 4px; +`; + +const StyledExternalLinkIcon = styled(ExternalLinkIcon)` + width: 16px; + height: 16px; + color: ${COLORS["grey-400"]}; + flex-shrink: 0; +`; diff --git a/src/views/Transaction/components/StatusBadge.tsx b/src/views/Transaction/components/StatusBadge.tsx new file mode 100644 index 000000000..76efefe3e --- /dev/null +++ b/src/views/Transaction/components/StatusBadge.tsx @@ -0,0 +1,39 @@ +import styled from "@emotion/styled"; +import { Text } from "components/Text"; +import { COLORS } from "utils"; + +type Props = { + status: string; +}; + +export function StatusBadge({ status }: Props) { + const capitalize = (str: string) => + str.charAt(0).toUpperCase() + str.slice(1); + + return ( + + + {capitalize(status)} + + + ); +} + +const Badge = styled.div<{ status: string }>` + display: inline-flex; + padding: 8px 16px; + border-radius: 8px; + background: ${({ status }) => + status === "filled" + ? `${COLORS.aqua}20` + : status === "pending" + ? `${COLORS.yellow}20` + : COLORS["grey-500"]}; + border: 1px solid + ${({ status }) => + status === "filled" + ? COLORS.aqua + : status === "pending" + ? COLORS.yellow + : COLORS["grey-400"]}; +`; diff --git a/src/views/Transaction/components/TransactionDestinationSection.tsx b/src/views/Transaction/components/TransactionDestinationSection.tsx new file mode 100644 index 000000000..25e43c89e --- /dev/null +++ b/src/views/Transaction/components/TransactionDestinationSection.tsx @@ -0,0 +1,288 @@ +import styled from "@emotion/styled"; +import { COLORS, getChainInfo, getConfig, QUERIESV2 } from "utils"; +import { Text } from "components/Text"; +import { formatUnitsWithMaxFractions, shortenAddress } from "utils/format"; +import { CopyableAddress } from "./CopyableAddress"; +import { CopyableText } from "./CopyableText"; +import { ReactComponent as ExternalLinkIcon } from "assets/icons/arrow-up-right-boxed.svg"; + +type TransactionDestinationSectionProps = { + deposit: any; + destinationChainId: number; + formatUSDValue: (value: string | null) => string; + formatTimestamp: (timestamp: string | null) => string; + explorerLink?: string; +}; + +export function TransactionDestinationSection({ + deposit, + destinationChainId, + formatUSDValue, + formatTimestamp, + explorerLink, +}: TransactionDestinationSectionProps) { + const config = getConfig(); + const destinationChain = getChainInfo(destinationChainId); + const outputToken = config.getTokenInfoByAddressSafe( + destinationChainId, + deposit.outputToken + ); + + return ( + + + + + + {destinationChain.name} + + + + + Destination + + {explorerLink && ( + + + + )} + + + + + + + + Token + + + {outputToken && ( + + )} + + {outputToken?.symbol} + + + + + + + Amount + + + + {outputToken + ? formatUnitsWithMaxFractions( + deposit.outputAmount, + outputToken.decimals + ) + : deposit.outputAmount}{" "} + {outputToken?.symbol} + + + {" "} + {formatUSDValue(deposit.outputPriceUsd)} + + + + + + + + + Recipient + + + + + {deposit.relayer && ( + + + Relayer + + + + )} + + {deposit.exclusiveRelayer && + deposit.exclusiveRelayer !== + "0x0000000000000000000000000000000000000000" && ( + + + Exclusive Relayer + + + + )} + + {deposit.fillTx && ( + + + Transaction + + + {shortenAddress(deposit.fillTx, "...", 6)} + + + )} + + {deposit.swapTransactionHash && ( + + + Swap Transaction + + + {shortenAddress(deposit.swapTransactionHash, "...", 6)} + + + )} + + {deposit.fillBlockTimestamp && ( + <> + + + + Fill time + + + {formatTimestamp(deposit.fillBlockTimestamp)} + + + > + )} + + ); +} + +const SectionCard = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + background: ${COLORS["black-800"]}; + border-radius: 16px; + border: 1px solid ${COLORS["grey-600"]}; + width: 100%; +`; + +const SectionHeader = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +`; + +const HeaderRight = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; +`; + +const ChainBadge = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +`; + +const ExplorerLinkButton = styled.a` + display: flex; + align-items: center; + justify-content: center; + padding: 6px; + border-radius: 8px; + background: transparent; + cursor: pointer; + transition: all 0.2s ease; + + svg { + width: 16px; + height: 16px; + path { + stroke: ${COLORS["grey-400"]}; + transition: stroke 0.2s ease; + } + } + + &:hover { + background: ${COLORS["grey-600"]}; + svg path { + stroke: ${COLORS.aqua}; + } + } +`; + +const ChainIcon = styled.img` + width: 24px; + height: 24px; + border-radius: 50%; +`; + +const TokenIcon = styled.img` + width: 16px; + height: 16px; + border-radius: 50%; +`; + +const TokenDisplay = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +`; + +const DetailRow = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 8px; + + @media ${QUERIESV2.sm.andDown} { + flex-direction: column; + align-items: flex-start; + } +`; + +const Divider = styled.div` + height: 1px; + width: 100%; + background: ${COLORS["grey-600"]}; +`; diff --git a/src/views/Transaction/components/TransactionFeeSection.tsx b/src/views/Transaction/components/TransactionFeeSection.tsx new file mode 100644 index 000000000..92a107246 --- /dev/null +++ b/src/views/Transaction/components/TransactionFeeSection.tsx @@ -0,0 +1,241 @@ +import styled from "@emotion/styled"; +import { useState } from "react"; +import { COLORS, QUERIESV2 } from "utils"; +import { Text } from "components/Text"; +import { Tooltip } from "components/Tooltip"; +import { ReactComponent as InfoIcon } from "assets/icons/info.svg"; +import { ReactComponent as ChevronIcon } from "assets/icons/chevron-down.svg"; + +type TransactionFeeSectionProps = { + bridgeFeeUsd: string | null; + fillGasFee: string | null; + fillGasFeeUsd: string | null; + swapFeeUsd?: string | null; + formatUSDValue: (value: string | null) => string; +}; + +export function TransactionFeeSection({ + bridgeFeeUsd, + fillGasFee, + fillGasFeeUsd, + swapFeeUsd, + formatUSDValue, +}: TransactionFeeSectionProps) { + const [isExpanded, setIsExpanded] = useState(false); + + const calculateTotalFee = () => { + const bridgeFee = parseFloat(bridgeFeeUsd || "0"); + const gasFee = parseFloat(fillGasFeeUsd || "0"); + const swapFee = parseFloat(swapFeeUsd || "0"); + const total = bridgeFee + gasFee + swapFee; + return total > 0 ? `$${total.toFixed(2)}` : "$0.00"; + }; + + return ( + + setIsExpanded(!isExpanded)}> + + + Total fees + + + + + + + + + + {calculateTotalFee()} + + + + + + {isExpanded && ( + <> + + + + + + + + Bridge fee + + + + + + + + + {formatUSDValue(bridgeFeeUsd)} + + + + + + + + Destination gas fee + + + + + + + + + {fillGasFee} {formatUSDValue(fillGasFeeUsd)} + + + + {swapFeeUsd && ( + + + + Swap fee + + + + + + + + + {formatUSDValue(swapFeeUsd)} + + + )} + + > + )} + + ); +} + +const FeeCard = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + background: ${COLORS["black-800"]}; + border-radius: 16px; + border: 1px solid ${COLORS["grey-600"]}; + width: 100%; +`; + +const Row = styled.div<{ collapsible?: boolean }>` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 0px; + gap: 6px; + cursor: ${({ collapsible }) => (collapsible ? "pointer" : "default")}; + width: 100%; + + @media ${QUERIESV2.xs.andDown} { + flex-direction: row; + align-items: flex-start; + gap: 8px; + } +`; + +const InnerWrapper = styled.div` + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 0px; + gap: 16px; + padding-left: 32px; + padding-top: 8px; + position: relative; + width: 100%; +`; + +const InnerRow = styled(Row)` + position: relative; +`; + +const VectorVertical = styled.div` + width: 14px; + border-left: 2px ${COLORS["grey-500"]} solid; + border-bottom: 2px ${COLORS["grey-500"]} solid; + border-bottom-left-radius: 10px; + position: absolute; + top: 0; + height: calc(100% - 8px); + left: 8px; +`; + +const VectorHorizontal = styled.div` + position: absolute; + top: 50%; + left: -24px; + width: 16px; + height: 2px; + background-color: ${COLORS["grey-500"]}; +`; + +const ToolTipWrapper = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + gap: 8px; +`; + +const InfoIconWrapper = styled.div` + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + height: 16px; + width: 16px; +`; + +const Divider = styled.div` + height: 1px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 10px; + align-self: stretch; + background: ${COLORS["grey-600"]}; + width: 100%; +`; + +const ChevronIconWrapper = styled.div` + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 8px; +`; + +const ChevronIconStyled = styled(ChevronIcon)` + transform: rotate( + ${({ isExpanded }: { isExpanded: boolean }) => + isExpanded ? "180deg" : "0deg"} + ); + transition: transform 0.2s ease-in-out; +`; diff --git a/src/views/Transaction/components/TransactionSourceSection.tsx b/src/views/Transaction/components/TransactionSourceSection.tsx new file mode 100644 index 000000000..e13b01a40 --- /dev/null +++ b/src/views/Transaction/components/TransactionSourceSection.tsx @@ -0,0 +1,238 @@ +import styled from "@emotion/styled"; +import { COLORS, getChainInfo, getConfig, QUERIESV2 } from "utils"; +import { Text } from "components/Text"; +import { formatUnitsWithMaxFractions, shortenAddress } from "utils/format"; +import { CopyableAddress } from "./CopyableAddress"; +import { CopyableText } from "./CopyableText"; +import { ReactComponent as ExternalLinkIcon } from "assets/icons/arrow-up-right-boxed.svg"; + +type TransactionSourceSectionProps = { + deposit: any; + sourceChainId: number; + formatUSDValue: (value: string | null) => string; + formatTimestamp: (timestamp: string | null) => string; + explorerLink: string; +}; + +export function TransactionSourceSection({ + deposit, + sourceChainId, + formatUSDValue, + formatTimestamp, + explorerLink, +}: TransactionSourceSectionProps) { + const config = getConfig(); + const sourceChain = getChainInfo(sourceChainId); + const inputToken = config.getTokenInfoByAddressSafe( + sourceChainId, + deposit.inputToken + ); + + return ( + + + + + + {sourceChain.name} + + + + + Source + + + + + + + + + + + + Token + + + {inputToken && ( + + )} + + {inputToken?.symbol} + + + + + + + Amount + + + + {inputToken + ? formatUnitsWithMaxFractions( + deposit.inputAmount, + inputToken.decimals + ) + : deposit.inputAmount}{" "} + {inputToken?.symbol} + + + {" "} + {formatUSDValue(deposit.inputPriceUsd)} + + + + + + + + + Depositor + + + + + + + Transaction + + + {shortenAddress(deposit.depositTxnRef, "...", 6)} + + + + + + + + Deposit time + + + {formatTimestamp(deposit.depositBlockTimestamp)} + + + + + + Quote time + + + {formatTimestamp(deposit.quoteTimestamp)} + + + + ); +} + +const SectionCard = styled.div` + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + background: ${COLORS["black-800"]}; + border-radius: 16px; + border: 1px solid ${COLORS["grey-600"]}; + width: 100%; +`; + +const SectionHeader = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +`; + +const HeaderRight = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; +`; + +const ChainBadge = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +`; + +const ExplorerLinkButton = styled.a` + display: flex; + align-items: center; + justify-content: center; + padding: 6px; + border-radius: 8px; + background: transparent; + cursor: pointer; + transition: all 0.2s ease; + + svg { + width: 16px; + height: 16px; + path { + stroke: ${COLORS["grey-400"]}; + transition: stroke 0.2s ease; + } + } + + &:hover { + background: ${COLORS["grey-600"]}; + svg path { + stroke: ${COLORS.aqua}; + } + } +`; + +const ChainIcon = styled.img` + width: 24px; + height: 24px; + border-radius: 50%; +`; + +const TokenIcon = styled.img` + width: 16px; + height: 16px; + border-radius: 50%; +`; + +const TokenDisplay = styled.div` + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +`; + +const DetailRow = styled.div` + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + gap: 8px; + + @media ${QUERIESV2.sm.andDown} { + flex-direction: column; + align-items: flex-start; + } +`; + +const Divider = styled.div` + height: 1px; + width: 100%; + background: ${COLORS["grey-600"]}; +`; diff --git a/src/views/Transaction/components/TxDetailSection.tsx b/src/views/Transaction/components/TxDetailSection.tsx new file mode 100644 index 000000000..ed919f41e --- /dev/null +++ b/src/views/Transaction/components/TxDetailSection.tsx @@ -0,0 +1,57 @@ +import styled from "@emotion/styled"; +import { Text } from "components/Text"; +import { COLORS } from "utils"; +import { CopyIconButton } from "./CopyIconButton"; + +type Props = { + label: string; + txHash: string; + explorerLink: string; +}; + +export function TxDetailSection({ label, txHash, explorerLink }: Props) { + const shortenHash = (hash: string) => + hash.length <= 13 ? hash : `${hash.slice(0, 6)}...${hash.slice(-4)}`; + + return ( + + {label} + + + {shortenHash(txHash)} + + + + + ); +} + +const DetailSection = styled.div` + display: flex; + flex-direction: column; + gap: 12px; +`; + +const SectionLabel = styled(Text)` + font-size: 12px; + font-weight: 600; + color: ${COLORS["grey-400"]}; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +`; + +const ValueRow = styled.div` + display: flex; + align-items: center; + gap: 8px; +`; + +const TxLink = styled.a` + text-decoration: none; + transition: opacity 0.2s; + + &:hover { + opacity: 0.8; + } +`; diff --git a/src/views/Transaction/index.tsx b/src/views/Transaction/index.tsx new file mode 100644 index 000000000..2391bc616 --- /dev/null +++ b/src/views/Transaction/index.tsx @@ -0,0 +1 @@ +export { default } from "./Transaction"; diff --git a/src/views/Transactions/Transactions.tsx b/src/views/Transactions/Transactions.tsx index 40e3a556b..1a4280e7f 100644 --- a/src/views/Transactions/Transactions.tsx +++ b/src/views/Transactions/Transactions.tsx @@ -8,6 +8,7 @@ import { PersonalTransactions } from "./components/PersonalTransactions"; import { DepositStatusFilter } from "./types"; import { LayoutV2 } from "components"; import BreadcrumbV2 from "components/BreadcrumbV2"; +import { AllTransactions } from "./components/AllTransactions"; const statusFilterOptions: DepositStatusFilter[] = [ "all", @@ -17,35 +18,51 @@ const statusFilterOptions: DepositStatusFilter[] = [ ]; export function Transactions() { - const [activeTab, setActiveTab] = useState<"personal" | "all">("personal"); + const [activeTab, setActiveTab] = useState<"personal" | "all">("all"); const [statusFilter, setStatusFilter] = useState( statusFilterOptions[0] ); + const NO_TAB_POC = true; return ( - - - - setActiveTab("personal")} - active={activeTab === "personal"} - > - Personal - - - - setStatusFilter(filter as DepositStatusFilter) - } - /> - + {activeTab !== "all" && } + {!NO_TAB_POC && ( + + ( + + setActiveTab("all")} + active={activeTab === "all"} + > + All + + setActiveTab("personal")} + active={activeTab === "personal"} + > + Personal + + + ) + {activeTab !== "all" && ( + + setStatusFilter(filter as DepositStatusFilter) + } + /> + )} + + )} - + {activeTab === "personal" && ( + + )} + {activeTab === "all" && } diff --git a/src/views/Transactions/components/AllTransactions.tsx b/src/views/Transactions/components/AllTransactions.tsx new file mode 100644 index 000000000..836ed4861 --- /dev/null +++ b/src/views/Transactions/components/AllTransactions.tsx @@ -0,0 +1,173 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import styled from "@emotion/styled"; + +import { PaginatedDepositsTable } from "components/DepositsTable"; +import { Text } from "components/Text"; +import { SecondaryButton } from "components"; +import { COLORS } from "utils"; +import { EmptyTable } from "./EmptyTable"; +import { LiveToggle } from "./LiveToggle"; +import { WalletAddressFilter } from "./WalletAddressFilter"; +import { useTransactions } from "../hooks/useTransactions"; +import { useLiveMode } from "../hooks/useLiveMode"; +import { convertIndexerDepositToDeposit } from "../utils/convertDeposit"; +import { useConnectionEVM } from "hooks/useConnectionEVM"; +import { useConnectionSVM } from "hooks/useConnectionSVM"; + +const LIVE_REFETCH_INTERVAL = 1_000; + +export function AllTransactions() { + const [walletAddressFilter, setWalletAddressFilter] = useState(""); + + const { account: accountEVM } = useConnectionEVM(); + const { account: accountSVM } = useConnectionSVM(); + const account = accountEVM || accountSVM?.toString(); + + const { + currentPage, + pageSize, + setCurrentPage, + handlePageSizeChange, + deposits, + totalDeposits, + depositsQuery, + } = useTransactions(walletAddressFilter.trim() || undefined); + + const queryClient = useQueryClient(); + + const isFirstPage = currentPage === 0; + const isFiltering = walletAddressFilter.trim().length > 0; + + const { isLiveMode, setIsLiveMode, isEnabled } = useLiveMode({ + refetchFn: depositsQuery.refetch, + refetchInterval: LIVE_REFETCH_INTERVAL, + enabled: isFirstPage && !isFiltering, + isLoading: depositsQuery.isLoading, + isFetching: depositsQuery.isFetching, + }); + + const convertedDeposits = useMemo( + () => + deposits.map((deposit) => { + const converted = convertIndexerDepositToDeposit(deposit); + // Show "processing" instead of "fee too low" on all transactions page + return { + ...converted, + hideFeeTooLow: !(account === walletAddressFilter), + }; + }), + [deposits, account, walletAddressFilter] + ); + + if (depositsQuery.isLoading) { + return ( + + Loading... + + ); + } + + if (depositsQuery.isError) { + return ( + + Something went wrong... Please try again later + { + await queryClient.cancelQueries({ queryKey: ["deposits"] }); + await queryClient.resetQueries({ queryKey: ["deposits"] }); + depositsQuery.refetch(); + }} + > + Reload data + + + ); + } + + const hasNoResults = currentPage === 0 && deposits.length === 0; + + return ( + <> + + + + + + + + {hasNoResults && ( + + + {isFiltering + ? "No transactions found for this address" + : "No transactions found"} + + {isFiltering && ( + + Try a different wallet address + + )} + + )} + > + ); +} + +const EmptyStateMessage = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 48px 24px; + text-align: center; + border: 1px solid ${COLORS["grey-600"]}; + border-top: none; + border-radius: 0 0 12px 12px; + background: ${COLORS["grey-600"]}; +`; + +const ControlsContainer = styled.div` + margin-bottom: 20px; +`; + +const ControlsRow = styled.div` + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; + + @media (max-width: 768px) { + flex-direction: column; + align-items: flex-start; + gap: 12px; + } +`; diff --git a/src/views/Transactions/components/LiveToggle.tsx b/src/views/Transactions/components/LiveToggle.tsx new file mode 100644 index 000000000..68a81c070 --- /dev/null +++ b/src/views/Transactions/components/LiveToggle.tsx @@ -0,0 +1,137 @@ +import styled from "@emotion/styled"; +import { Text } from "components/Text"; +import { Tooltip } from "components/Tooltip"; + +type DisabledReason = "filtering" | "not-first-page"; + +type Props = { + isLiveMode: boolean; + onToggle: (value: boolean) => void; + disabled: boolean; + disabledReason?: DisabledReason; +}; + +const getTooltipContent = (reason: DisabledReason) => { + const messages = { + filtering: { + title: "Live updates disabled during filtering", + body: "Clear the wallet address filter to enable live updates", + }, + "not-first-page": { + title: "Live updates only available on first page", + body: "Navigate to the first page to enable live updates", + }, + }; + + return messages[reason]; +}; + +export function LiveToggle({ + isLiveMode, + onToggle, + disabled, + disabledReason = "not-first-page", +}: Props) { + const toggleContent = ( + + + onToggle(e.target.checked)} + disabled={disabled} + /> + + + + Live updates + + + ); + + if (!disabled) { + return toggleContent; + } + + const tooltipContent = getTooltipContent(disabledReason); + + return ( + {tooltipContent.body}} + placement="bottom" + > + {toggleContent} + + ); +} + +const ToggleSection = styled.div<{ disabled?: boolean }>` + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + background: rgba(62, 64, 71, 0.3); + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.05); + transition: all 0.2s ease; + opacity: ${({ disabled }) => (disabled ? 0.5 : 1)}; + cursor: ${({ disabled }) => (disabled ? "not-allowed" : "default")}; + + &:hover { + background: ${({ disabled }) => + disabled ? "rgba(62, 64, 71, 0.3)" : "rgba(62, 64, 71, 0.5)"}; + } +`; + +const ToggleSwitch = styled.label` + position: relative; + display: inline-block; + width: 36px; + height: 20px; + flex-shrink: 0; +`; + +const ToggleInput = styled.input` + opacity: 0; + width: 0; + height: 0; + + &:checked + span { + background-color: #6cf9d8; + } + + &:checked + span:before { + transform: translateX(16px); + } + + &:disabled + span { + cursor: not-allowed; + opacity: 0.6; + } +`; + +const ToggleSlider = styled.span<{ disabled?: boolean }>` + position: absolute; + cursor: ${({ disabled }) => (disabled ? "not-allowed" : "pointer")}; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #3e4047; + transition: 0.3s; + border-radius: 20px; + + &:before { + position: absolute; + content: ""; + height: 14px; + width: 14px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.3s; + border-radius: 50%; + } +`; diff --git a/src/views/Transactions/components/PersonalTransactions.tsx b/src/views/Transactions/components/PersonalTransactions.tsx index 43ec58f26..d23a0bd01 100644 --- a/src/views/Transactions/components/PersonalTransactions.tsx +++ b/src/views/Transactions/components/PersonalTransactions.tsx @@ -134,6 +134,7 @@ export function PersonalTransactions({ statusFilter }: Props) { filterKey={`personal-${statusFilter}`} disabledColumns={["bridgeFee", "rewards", "rewardsRate"]} displayPageNumbers={false} + hasNoResults={false} /> > ); diff --git a/src/views/Transactions/components/WalletAddressFilter.tsx b/src/views/Transactions/components/WalletAddressFilter.tsx new file mode 100644 index 000000000..f76d464b3 --- /dev/null +++ b/src/views/Transactions/components/WalletAddressFilter.tsx @@ -0,0 +1,89 @@ +import styled from "@emotion/styled"; +import { UnstyledButton } from "components"; +import { Input, InputGroup } from "components/Input"; +import { Text } from "components/Text"; +import { COLORS } from "utils"; + +type WalletAddressFilterProps = { + value: string; + onChange: (value: string) => void; + connectedAddress?: string; +}; + +export function WalletAddressFilter({ + value, + onChange, + connectedAddress, +}: WalletAddressFilterProps) { + return ( + + + + onChange(e.target.value)} + validationLevel="valid" + /> + + {value.trim().length > 0 && ( + onChange("")}> + Clear + + )} + {connectedAddress && ( + onChange(connectedAddress)}> + Filter by my Address + + )} + + + ); +} + +const FilterSection = styled.div` + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + min-width: 240px; +`; + +const FilterLabel = styled.div` + display: flex; + align-items: center; + padding-left: 2px; +`; + +const FilterInputWrapper = styled.div` + max-width: 600px; + width: 100%; + display: flex; + gap: 8px; + align-items: center; +`; + +const QuickFilterButton = styled(UnstyledButton)` + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: 0 10px; + height: 24px; + width: fit-content; + border: 1px solid ${COLORS["grey-400"]}; + border-radius: 6px; + cursor: pointer; + font-size: 12px; + line-height: 14px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: ${COLORS["grey-400"]}; + white-space: nowrap; + + &:hover { + color: ${COLORS["aqua"]}; + border-color: ${COLORS["aqua"]}; + } +`; diff --git a/src/views/Transactions/hooks/useLiveMode.ts b/src/views/Transactions/hooks/useLiveMode.ts new file mode 100644 index 000000000..cd418b508 --- /dev/null +++ b/src/views/Transactions/hooks/useLiveMode.ts @@ -0,0 +1,85 @@ +import { useEffect, useState } from "react"; + +type UseLiveModeParams = { + refetchFn: () => Promise; + refetchInterval: number; + enabled: boolean; + isLoading: boolean; + isFetching?: boolean; + onReset?: () => void; +}; + +type UseLiveModeResult = { + isLiveMode: boolean; + setIsLiveMode: (value: boolean) => void; + isEnabled: boolean; +}; + +export function useLiveMode({ + refetchFn, + refetchInterval, + enabled, + isLoading, + isFetching, + onReset, +}: UseLiveModeParams): UseLiveModeResult { + const [isLiveMode, setIsLiveMode] = useState(true); + const [isPageVisible, setIsPageVisible] = useState(!document.hidden); + + useEffect(() => { + if (!enabled) { + setIsLiveMode(false); + } + }, [enabled]); + + useEffect(() => { + // TODO, test if this is needed + const handleVisibilityChange = async () => { + const isVisible = !document.hidden; + setIsPageVisible(isVisible); + + if (isVisible && isLiveMode && enabled && !isLoading) { + onReset?.(); + await refetchFn(); + } + }; + + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, [isLiveMode, enabled, isLoading, refetchFn, onReset]); + + useEffect(() => { + const shouldRefetch = isLiveMode && enabled && isPageVisible && !isLoading; + + if (!shouldRefetch) { + return; + } + + const intervalId = setInterval(() => { + if (!isFetching) { + void refetchFn(); + } + }, refetchInterval); + + return () => { + clearInterval(intervalId); + }; + }, [ + isLiveMode, + enabled, + isPageVisible, + isLoading, + isFetching, + refetchFn, + refetchInterval, + ]); + + return { + isLiveMode, + setIsLiveMode, + isEnabled: enabled, + }; +} diff --git a/src/views/Transactions/hooks/useTransactions.tsx b/src/views/Transactions/hooks/useTransactions.tsx new file mode 100644 index 000000000..bcb863e2e --- /dev/null +++ b/src/views/Transactions/hooks/useTransactions.tsx @@ -0,0 +1,52 @@ +import { useEffect } from "react"; +import { useDeposits } from "hooks/useDeposits"; + +import { useCurrentPage, usePageSize } from "./usePagination"; + +export function useTransactions(userAddress?: string) { + const { pageSize, handlePageSizeChange } = usePageSize(); + + const { + currentPage, + setCurrentPage, + deposits, + totalDeposits, + depositsQuery, + } = usePaginatedDeposits(pageSize, userAddress); + + useEffect(() => { + setCurrentPage(0); + }, [userAddress, setCurrentPage]); + + return { + currentPage, + setCurrentPage, + pageSize, + handlePageSizeChange, + deposits, + totalDeposits, + depositsQuery, + }; +} + +function usePaginatedDeposits(pageSize: number, userAddress?: string) { + const { currentPage, setCurrentPage } = useCurrentPage(); + const depositsQuery = useDeposits( + pageSize, + currentPage * pageSize, + userAddress + ); + const end = depositsQuery.data + ? depositsQuery.data!.deposits.length < pageSize + : false; + const numberOfDeposits = depositsQuery.data?.deposits.length || 0; + const totalDeposits = numberOfDeposits + currentPage * pageSize; + + return { + currentPage, + setCurrentPage, + depositsQuery, + deposits: depositsQuery.data?.deposits || [], + totalDeposits: end ? totalDeposits : totalDeposits + 1, + }; +} diff --git a/src/views/Transactions/utils/convertDeposit.ts b/src/views/Transactions/utils/convertDeposit.ts new file mode 100644 index 000000000..a2c656718 --- /dev/null +++ b/src/views/Transactions/utils/convertDeposit.ts @@ -0,0 +1,80 @@ +import { Deposit, IndexerDeposit } from "hooks/useDeposits"; + +export function convertIndexerDepositToDeposit( + indexerDeposit: IndexerDeposit +): Deposit { + const depositTime = + new Date(indexerDeposit.depositBlockTimestamp).getTime() / 1000; + const fillTime = new Date(indexerDeposit.fillBlockTimestamp).getTime() / 1000; + const status = + indexerDeposit.status === "unfilled" ? "pending" : indexerDeposit.status; + + return { + depositId: indexerDeposit.depositId, + depositTime, + fillTime, + status, + filled: "0", + + sourceChainId: indexerDeposit.originChainId, + destinationChainId: indexerDeposit.destinationChainId, + assetAddr: indexerDeposit.inputToken, + depositorAddr: indexerDeposit.depositor, + recipientAddr: indexerDeposit.recipient, + + depositTxHash: + indexerDeposit.depositTransactionHash || indexerDeposit.depositTxHash, + fillTx: indexerDeposit.fillTx, + depositRefundTxHash: indexerDeposit.depositRefundTxHash, + + amount: indexerDeposit.inputAmount, + message: indexerDeposit.message, + token: { + address: indexerDeposit.inputToken, + symbol: undefined, + name: undefined, + decimals: undefined, + }, + outputToken: { + address: indexerDeposit.outputToken, + symbol: undefined, + name: undefined, + decimals: undefined, + }, + swapToken: { + address: indexerDeposit.swapToken, + symbol: undefined, + name: undefined, + decimals: undefined, + }, + swapTokenAmount: indexerDeposit.swapTokenAmount, + swapTokenAddress: indexerDeposit.swapToken, + + speedUps: indexerDeposit.speedups, + fillDeadline: indexerDeposit.fillDeadline, + + depositRelayerFeePct: "0", + initialRelayerFeePct: "0", + suggestedRelayerFeePct: "0", + rewards: undefined, + feeBreakdown: indexerDeposit.bridgeFeeUsd + ? { + lpFeeUsd: "0", + lpFeePct: "0", + lpFeeAmount: "0", + relayCapitalFeeUsd: "0", + relayCapitalFeePct: "0", + relayCapitalFeeAmount: "0", + relayGasFeeUsd: indexerDeposit.fillGasFeeUsd, + relayGasFeePct: "0", + relayGasFeeAmount: "0", + totalBridgeFeeUsd: indexerDeposit.bridgeFeeUsd, + totalBridgeFeePct: "0", + totalBridgeFeeAmount: "0", + swapFeeUsd: indexerDeposit.swapFeeUsd, + swapFeePct: "0", + swapFeeAmount: "0", + } + : undefined, + }; +}