diff --git a/components/navbar/WalletMenu.tsx b/components/navbar/WalletMenu.tsx
index d7eb9d3..2528b1b 100644
--- a/components/navbar/WalletMenu.tsx
+++ b/components/navbar/WalletMenu.tsx
@@ -1,108 +1,105 @@
-"use client";
-
-
-import { Button } from "@/components/ui/button";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuLabel,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
-import { useWalletContext } from "@/context/WalletContext";
-import { useWallet } from "@/hooks/useWallet.hook";
-import { ConnectWalletModal } from "@/components/connect-wallet-modal";
-import { Copy as CopyIcon, RefreshCcw, LogOut as LogOutIcon } from "lucide-react";
-import ArrowDownIcon from "../icons/ArrowDown";
-import { Switch } from "@/components/ui/switch";
-import { usePrivacy } from "@/context/PrivacyContext";
-import { maskAmount } from "@/utils/maskAmount";
-
-function truncateMiddle(address: string, visible = 4) {
- if (address.length <= visible * 2) return address;
- return `${address.slice(0, visible)}…${address.slice(-visible)}`;
-}
-
-export function WalletMenu() {
- const { address, connected, isLoading } = useWalletContext();
- const { disconnectWallet } = useWallet();
- const [isOpen, setIsOpen] = React.useState(false);
- const { hideBalances, setHideBalances } = usePrivacy();
-
- const display = connected && address ? (hideBalances ? maskAmount(address) : truncateMiddle(address)) : "Connect wallet";
-
- if (isLoading) {
- return (
-
- Loading...
-
- );
- }
-
- function handleCopy() {
- if (address) navigator.clipboard.writeText(address);
- }
-
- if (!connected) {
- return (
- <>
- setIsOpen(true)}
- aria-label="Connect wallet"
- >
- {display}
-
-
- >
- );
- }
-
- return (
- <>
-
-
-
- {display}
-
-
-
-
- Wallet
-
-
-
- Copy
-
- setIsOpen(true)} className="cursor-pointer" aria-label="Switch wallet">
-
- Switch
-
- { void disconnectWallet(); }} className="cursor-pointer" aria-label="Disconnect wallet">
-
- Disconnect
-
-
-
- Hide balances
-
-
-
-
-
-
- >
- );
+"use client";
+
+
+import React from "react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+import { useWalletContext } from "@/context/WalletContext";
+import { useWallet } from "@/hooks/useWallet.hook";
+import { ConnectWalletModal } from "@/components/connect-wallet-modal";
+import { RefreshCcw, LogOut as LogOutIcon } from "lucide-react";
+import ArrowDownIcon from "../icons/ArrowDown";
+import { Switch } from "@/components/ui/switch";
+import { usePrivacy } from "@/context/PrivacyContext";
+import { maskAmount } from "@/utils/maskAmount";
+import { CopyableText } from "@/components/ui/CopyableText";
+
+function truncateMiddle(address: string, visible = 4) {
+ if (address.length <= visible * 2) return address;
+ return `${address.slice(0, visible)}…${address.slice(-visible)}`;
+}
+
+export function WalletMenu() {
+ const { address, connected, isLoading } = useWalletContext();
+ const { disconnectWallet } = useWallet();
+ const [isOpen, setIsOpen] = React.useState(false);
+ const { hideBalances, setHideBalances } = usePrivacy();
+
+ const display = connected && address ? (hideBalances ? maskAmount(address) : truncateMiddle(address)) : "Connect wallet";
+
+ if (isLoading) {
+ return (
+
+ Loading...
+
+ );
+ }
+
+ if (!connected) {
+ return (
+ <>
+ setIsOpen(true)}
+ aria-label="Connect wallet"
+ >
+ {display}
+
+
+ >
+ );
+ }
+
+ return (
+ <>
+
+
+
+ {display}
+
+
+
+
+
+ Connected Address
+ {address && }
+
+
+ setIsOpen(true)} className="cursor-pointer" aria-label="Switch wallet">
+
+ Switch
+
+ { void disconnectWallet(); }} className="cursor-pointer" aria-label="Disconnect wallet">
+
+ Disconnect
+
+
+
+ Hide balances
+
+
+
+
+
+
+ >
+ );
}
diff --git a/components/transactions/TransactionsHstory.tsx b/components/transactions/TransactionsHstory.tsx
index 58efa44..8526282 100644
--- a/components/transactions/TransactionsHstory.tsx
+++ b/components/transactions/TransactionsHstory.tsx
@@ -3,6 +3,7 @@ import { ArrowDown, ArrowUp, RefreshCw, X } from 'lucide-react';
import { TransactionFilters } from '@/lib/types';
import { allTransactions } from '@/lib/mock-data';
import { Timestamp } from '@/components/ui/Timestamp';
+import { CopyableText } from '@/components/ui/CopyableText';
export default function TransactionHistory() {
@@ -291,6 +292,7 @@ export default function TransactionHistory() {
Date
+ Hash
Type
Amount
Status
@@ -303,6 +305,9 @@ export default function TransactionHistory() {
+
+
+
{transaction.icon === 'down' && (
diff --git a/components/ui/CopyableText.tsx b/components/ui/CopyableText.tsx
new file mode 100644
index 0000000..0eace6c
--- /dev/null
+++ b/components/ui/CopyableText.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import React, { useState, useEffect } from "react";
+import { Copy, Check } from "lucide-react";
+import { useToast } from "@/components/ui/use-toast";
+import { cn } from "@/lib/utils";
+
+interface CopyableTextProps {
+ text: string;
+ truncateMiddle?: boolean;
+ visibleChars?: number;
+ className?: string;
+}
+
+export function CopyableText({
+ text,
+ truncateMiddle = true,
+ visibleChars = 4,
+ className,
+}: CopyableTextProps) {
+ const [copied, setCopied] = useState(false);
+ const { toast } = useToast();
+
+ useEffect(() => {
+ if (copied) {
+ const timeout = setTimeout(() => setCopied(false), 2000);
+ return () => clearTimeout(timeout);
+ }
+ }, [copied]);
+
+ const handleCopy = async (e: React.MouseEvent | React.KeyboardEvent) => {
+ e.stopPropagation();
+ try {
+ await navigator.clipboard.writeText(text);
+ setCopied(true);
+ toast({
+ title: "Copied!",
+ description: "The text has been copied to your clipboard.",
+ });
+ } catch (err) {
+ console.error("Failed to copy text: ", err);
+ toast({
+ title: "Failed to copy",
+ description: "Could not copy text to clipboard.",
+ variant: "destructive",
+ });
+ }
+ };
+
+ const displayText = truncateMiddle
+ ? text.length > visibleChars * 2
+ ? `${text.slice(0, visibleChars)}…${text.slice(-visibleChars)}`
+ : text
+ : text;
+
+ return (
+
{
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ handleCopy(e);
+ }
+ }}
+ aria-label={`Copy ${text}`}
+ title={text}
+ >
+ {displayText}
+
+ {copied ? (
+
+ ) : (
+
+ )}
+
+ {/* Screen reader only live region for accessibility */}
+
+ {copied ? "Copied" : ""}
+
+
+ );
+}
diff --git a/components/ui/__tests__/CopyableText.test.tsx b/components/ui/__tests__/CopyableText.test.tsx
new file mode 100644
index 0000000..1e43291
--- /dev/null
+++ b/components/ui/__tests__/CopyableText.test.tsx
@@ -0,0 +1,66 @@
+import { render, screen, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { CopyableText } from '../CopyableText';
+import { ToastProvider } from '@/components/ui/toast';
+
+const renderWithToast = (ui: React.ReactElement) => {
+ return render(
+
+ {ui}
+
+ );
+};
+
+// Mock clipboard
+Object.assign(navigator, {
+ clipboard: {
+ writeText: jest.fn(),
+ },
+});
+
+describe('CopyableText', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders truncated text by default', () => {
+ renderWithToast(
);
+ expect(screen.getByText('0x12…cdef')).toBeInTheDocument();
+ });
+
+ it('renders full text when truncateMiddle is false', () => {
+ renderWithToast(
);
+ expect(screen.getByText('0x1234567890abcdef')).toBeInTheDocument();
+ });
+
+ it('copies text to clipboard on click', async () => {
+ renderWithToast(
);
+
+ const copyButton = screen.getByRole('button');
+ await userEvent.click(copyButton);
+
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-copy');
+ });
+
+ it('copies text to clipboard on Enter key press', async () => {
+ renderWithToast(
);
+
+ const copyButton = screen.getByRole('button');
+ copyButton.focus();
+ await userEvent.keyboard('{Enter}');
+
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith('test-copy');
+ });
+
+ it('updates live region on copy', async () => {
+ renderWithToast(
);
+
+ const liveRegion = screen.getByText('', { selector: '[aria-live="polite"]' });
+ expect(liveRegion).toBeEmptyDOMElement();
+
+ const copyButton = screen.getByRole('button');
+ await userEvent.click(copyButton);
+
+ expect(liveRegion).toHaveTextContent('Copied');
+ });
+});
diff --git a/lib/mock-data.ts b/lib/mock-data.ts
index 56488c0..0a53a79 100644
--- a/lib/mock-data.ts
+++ b/lib/mock-data.ts
@@ -139,7 +139,8 @@ export const categoryColors = {
icon: 'down',
amountColor: 'text-green-500',
currency: 'XLM',
- numericAmount: 50
+ numericAmount: 50,
+ hash: '0x1A2b3C4d5E6f7G8h9I0j1K2l3M4n5O6p7Q8r9S0t'
},
{
date: '09/06/2023',
@@ -150,7 +151,8 @@ export const categoryColors = {
icon: 'up',
amountColor: 'text-red-500',
currency: 'XLM',
- numericAmount: -10
+ numericAmount: -10,
+ hash: '0x2B3c4D5e6F7g8H9i0J1k2L3m4N5o6P7q8R9s0T1u'
},
{
date: '08/06/2023',
@@ -161,7 +163,8 @@ export const categoryColors = {
icon: 'down',
amountColor: 'text-green-500',
currency: 'XLM',
- numericAmount: 18
+ numericAmount: 18,
+ hash: '0x3C4d5E6f7G8h9I0j1K2l3M4n5O6p7Q8r9S0t1U2v'
},
{
date: '07/06/2023',
@@ -172,7 +175,8 @@ export const categoryColors = {
icon: 'up',
amountColor: 'text-red-500',
currency: 'XLM',
- numericAmount: -25
+ numericAmount: -25,
+ hash: '0x4D5e6F7g8H9i0J1k2L3m4N5o6P7q8R9s0T1u2V3w'
},
{
date: '06/06/2023',
@@ -183,7 +187,8 @@ export const categoryColors = {
icon: 'up',
amountColor: 'text-red-500',
currency: 'USDC',
- numericAmount: -15
+ numericAmount: -15,
+ hash: '0x5E6f7G8h9I0j1K2l3M4n5O6p7Q8r9S0t1U2v3W4x'
},
{
date: '05/06/2023',
@@ -194,7 +199,8 @@ export const categoryColors = {
icon: 'refresh',
amountColor: 'text-green-500',
currency: 'USDC',
- numericAmount: 5
+ numericAmount: 5,
+ hash: '0x6F7g8H9i0J1k2L3m4N5o6P7q8R9s0T1u2V3w4X5y'
},
{
date: '06/06/2023',
@@ -205,7 +211,8 @@ export const categoryColors = {
icon: 'up',
amountColor: 'text-red-500',
currency: 'USDC',
- numericAmount: -15
+ numericAmount: -15,
+ hash: '0x7G8h9I0j1K2l3M4n5O6p7Q8r9S0t1U2v3W4x5Y6z'
},
{
date: '05/06/2023',
@@ -216,7 +223,8 @@ export const categoryColors = {
icon: 'refresh',
amountColor: 'text-green-500',
currency: 'USDC',
- numericAmount: 5
+ numericAmount: 5,
+ hash: '0x8H9i0J1k2L3m4N5o6P7q8R9s0T1u2V3w4X5y6Z7a'
},
{
date: '04/06/2023',
@@ -227,6 +235,7 @@ export const categoryColors = {
icon: 'down',
amountColor: 'text-green-500',
currency: 'USDC',
- numericAmount: 100
+ numericAmount: 100,
+ hash: '0x9I0j1K2l3M4n5O6p7Q8r9S0t1U2v3W4x5Y6z7A8b'
}
];
diff --git a/lib/types.ts b/lib/types.ts
index f620f6d..885c6af 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -55,6 +55,7 @@ export interface Transaction {
amountColor: string;
currency: 'XLM' | 'USDC';
numericAmount: number;
+ hash: string;
}
export interface TransactionFilters {