= ({
creatorTotalEscrowed = 0,
creatorCompletionRate = 0,
}) => {
- const [copied, setCopied] = useState(false);
-
- const truncatedWallet = walletAddress
- ? `${walletAddress.slice(0, 6)}...${walletAddress.slice(-4)}`
- : 'Not connected';
-
const badges = badgeStats ? computeBadges(badgeStats) : [];
const earnedCount = badges.filter((b) => b.earned).length;
const mostRecentPrTimestamp = badgeStats?.prSubmissionTimestampsUtc?.[badgeStats.prSubmissionTimestampsUtc.length - 1];
- const handleCopyWallet = async () => {
- if (!walletAddress) return;
- try {
- await navigator.clipboard.writeText(walletAddress);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- } catch {
- // clipboard API may not be available in all contexts
- }
- };
-
return (
{/* Profile Header */}
@@ -197,23 +181,16 @@ export const ContributorProfile: React.FC = ({
)}
-
{truncatedWallet}
- {walletAddress && (
-
+ {walletAddress ? (
+
+ ) : (
+
Not connected
)}
{joinDate && (
diff --git a/frontend/src/components/common/CopyAddress.test.tsx b/frontend/src/components/common/CopyAddress.test.tsx
new file mode 100644
index 00000000..ad506200
--- /dev/null
+++ b/frontend/src/components/common/CopyAddress.test.tsx
@@ -0,0 +1,233 @@
+/**
+ * @jest-environment jsdom
+ */
+import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { CopyAddress, truncateAddress } from './CopyAddress';
+
+// Mock navigator.clipboard
+const mockWriteText = vi.fn();
+Object.assign(navigator, {
+ clipboard: {
+ writeText: mockWriteText,
+ },
+});
+
+describe('truncateAddress', () => {
+ it('truncates address with default length', () => {
+ const address = 'C2Tv7g9qL4R8K3m5P9xQ2Y7Z';
+ expect(truncateAddress(address)).toBe('C2Tv...7Z');
+ });
+
+ it('truncates with custom start/end chars', () => {
+ const address = 'C2Tv7g9qL4R8K3m5P9xQ2Y7Z';
+ expect(truncateAddress(address, 6, 4)).toBe('C2Tv7g...7Z');
+ });
+
+ it('returns full address if too short to truncate', () => {
+ const address = 'ABC123';
+ expect(truncateAddress(address)).toBe('ABC123');
+ });
+
+ it('returns empty string for empty address', () => {
+ expect(truncateAddress('')).toBe('');
+ });
+});
+
+describe('CopyAddress', () => {
+ const testAddress = 'C2Tv7g9qL4R8K3m5P9xQ2Y7Z8W1BAGS';
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockWriteText.mockResolvedValue(undefined);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('renders truncated address', () => {
+ render();
+ expect(screen.getByTestId('copy-address-text').textContent).toBe('C2Tv...AGS');
+ });
+
+ it('shows full address in title attribute', () => {
+ render();
+ expect(screen.getByTestId('copy-address-text').getAttribute('title')).toBe(testAddress);
+ });
+
+ it('renders copy icon by default', () => {
+ render();
+ expect(screen.getByTestId('copy-address-icon')).toBeTruthy();
+ });
+
+ it('hides icon when showIcon=false', () => {
+ render();
+ expect(screen.queryByTestId('copy-address-btn')).toBeNull();
+ });
+
+ it('copies address to clipboard on click', async () => {
+ render();
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('copy-address-btn'));
+ });
+
+ expect(mockWriteText).toHaveBeenCalledWith(testAddress);
+ });
+
+ it('shows checkmark after successful copy', async () => {
+ render();
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('copy-address-btn'));
+ });
+
+ expect(screen.getByTestId('copy-address-checkmark')).toBeTruthy();
+ });
+
+ it('returns to copy icon after 2 seconds', async () => {
+ vi.useFakeTimers();
+ render();
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('copy-address-btn'));
+ });
+
+ expect(screen.getByTestId('copy-address-checkmark')).toBeTruthy();
+
+ act(() => {
+ vi.advanceTimersByTime(2100);
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByTestId('copy-address-checkmark')).toBeNull();
+ });
+
+ vi.useRealTimers();
+ });
+
+ it('shows error icon on copy failure', async () => {
+ mockWriteText.mockRejectedValue(new Error('Clipboard error'));
+ render();
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('copy-address-btn'));
+ });
+
+ expect(screen.getByTestId('copy-address-error')).toBeTruthy();
+ });
+
+ it('calls onCopy callback after successful copy', async () => {
+ const onCopy = vi.fn();
+ render();
+
+ await act(async () => {
+ fireEvent.click(screen.getByTestId('copy-address-btn'));
+ });
+
+ expect(onCopy).toHaveBeenCalled();
+ });
+
+ it('has correct aria-label', () => {
+ render();
+ expect(screen.getByTestId('copy-address-btn').getAttribute('aria-label')).toBe(
+ 'Copy C2Tv...AGS to clipboard'
+ );
+ });
+
+ it('uses custom ariaLabel when provided', () => {
+ render();
+ expect(screen.getByTestId('copy-address-btn').getAttribute('aria-label')).toBe(
+ 'Copy wallet address'
+ );
+ });
+
+ it('handles Enter key press', async () => {
+ render();
+
+ await act(async () => {
+ fireEvent.keyDown(screen.getByTestId('copy-address-btn'), { key: 'Enter' });
+ });
+
+ expect(mockWriteText).toHaveBeenCalledWith(testAddress);
+ });
+
+ it('handles Space key press', async () => {
+ render();
+
+ await act(async () => {
+ fireEvent.keyDown(screen.getByTestId('copy-address-btn'), { key: ' ' });
+ });
+
+ expect(mockWriteText).toHaveBeenCalledWith(testAddress);
+ });
+
+ it('does not copy on other key presses', async () => {
+ render();
+
+ fireEvent.keyDown(screen.getByTestId('copy-address-btn'), { key: 'Tab' });
+
+ expect(mockWriteText).not.toHaveBeenCalled();
+ });
+
+ it('prevents default on Enter/Space key', async () => {
+ render();
+
+ const event = fireEvent.keyDown(screen.getByTestId('copy-address-btn'), { key: 'Enter' });
+ // The handler calls preventDefault, but fireEvent doesn't easily test this
+ // The fact that it doesn't throw is a basic test
+ });
+
+ it('returns null when address is empty', () => {
+ const { container } = render();
+ expect(container.firstChild).toBeNull();
+ });
+
+ it('applies custom className', () => {
+ render();
+ expect(screen.getByTestId('copy-address').className).toContain('my-custom-class');
+ });
+
+ it('applies custom data-testid', () => {
+ render();
+ expect(screen.getByTestId('wallet-address')).toBeTruthy();
+ expect(screen.getByTestId('wallet-address-text')).toBeTruthy();
+ expect(screen.getByTestId('wallet-address-btn')).toBeTruthy();
+ });
+
+ it('updates title attribute after copy', async () => {
+ render();
+ const btn = screen.getByTestId('copy-address-btn');
+
+ expect(btn.getAttribute('title')).toBe('Copy to clipboard');
+
+ await act(async () => {
+ fireEvent.click(btn);
+ });
+
+ expect(btn.getAttribute('title')).toBe('Copied!');
+ });
+
+ it('has screen reader status element', () => {
+ render();
+ const status = screen.getByTestId('copy-address-status');
+ expect(status.getAttribute('role')).toBe('status');
+ expect(status.getAttribute('aria-live')).toBe('polite');
+ });
+
+ it('has sr-only class on status element', () => {
+ render();
+ expect(screen.getByTestId('copy-address-status').className).toContain('sr-only');
+ });
+
+ it('button has type="button"', () => {
+ render();
+ expect(screen.getByTestId('copy-address-btn').getAttribute('type')).toBe('button');
+ });
+
+ it('uses custom startChars and endChars', () => {
+ render();
+ expect(screen.getByTestId('copy-address-text').textContent).toBe('C2Tv7g...1BAGS');
+ });
+});
diff --git a/frontend/src/components/common/CopyAddress.tsx b/frontend/src/components/common/CopyAddress.tsx
new file mode 100644
index 00000000..7474f942
--- /dev/null
+++ b/frontend/src/components/common/CopyAddress.tsx
@@ -0,0 +1,171 @@
+/**
+ * CopyAddress Component
+ * Reusable component for displaying and copying wallet/contract addresses
+ */
+import { useState, useCallback } from 'react';
+
+export interface CopyAddressProps {
+ /** Full address to display and copy */
+ address: string;
+ /** Number of characters to show at start (default: 4) */
+ startChars?: number;
+ /** Number of characters to show at end (default: 4) */
+ endChars?: number;
+ /** Custom className for styling */
+ className?: string;
+ /** Whether to show copy icon (default: true) */
+ showIcon?: boolean;
+ /** Custom label for screen readers */
+ ariaLabel?: string;
+ /** Callback after successful copy */
+ onCopy?: () => void;
+ /** Test id for testing */
+ 'data-testid'?: string;
+}
+
+/**
+ * Truncates an address for display (e.g., "C2Tv...BAGS")
+ */
+export function truncateAddress(
+ address: string,
+ startChars: number = 4,
+ endChars: number = 4
+): string {
+ if (!address || address.length <= startChars + endChars + 3) {
+ return address || '';
+ }
+ return `${address.slice(0, startChars)}...${address.slice(-endChars)}`;
+}
+
+/**
+ * CopyAddress component - displays truncated address with copy functionality
+ */
+export function CopyAddress({
+ address,
+ startChars = 4,
+ endChars = 4,
+ className = '',
+ showIcon = true,
+ ariaLabel,
+ onCopy,
+ 'data-testid': dataTestId = 'copy-address',
+}: CopyAddressProps): JSX.Element | null {
+ const [copied, setCopied] = useState(false);
+ const [copyError, setCopyError] = useState(false);
+
+ const truncatedAddress = truncateAddress(address, startChars, endChars);
+ const displayLabel = ariaLabel || `Copy ${truncatedAddress} to clipboard`;
+
+ const handleCopy = useCallback(async () => {
+ if (!address) return;
+
+ try {
+ await navigator.clipboard.writeText(address);
+ setCopied(true);
+ setCopyError(false);
+ onCopy?.();
+
+ // Reset copied state after 2 seconds
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ setCopyError(true);
+ // Reset error state after 2 seconds
+ setTimeout(() => setCopyError(false), 2000);
+ }
+ }, [address, onCopy]);
+
+ const handleKeyDown = useCallback(
+ (event: React.KeyboardEvent) => {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault();
+ handleCopy();
+ }
+ },
+ [handleCopy]
+ );
+
+ if (!address) {
+ return null;
+ }
+
+ return (
+
+ {/* Truncated address with full address as tooltip */}
+
+ {truncatedAddress}
+
+
+ {/* Copy button */}
+ {showIcon && (
+
+ )}
+
+ {/* Screen reader announcement */}
+
+ {copied ? 'Address copied to clipboard' : copyError ? 'Failed to copy address' : ''}
+
+
+ );
+}
diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts
index bb48a1ec..99b64085 100644
--- a/frontend/src/components/common/index.ts
+++ b/frontend/src/components/common/index.ts
@@ -43,4 +43,6 @@ export type { MarkdownRendererProps } from './MarkdownRenderer';
export { SolFoundryLogoMark } from './SolFoundryLogoMark';
export type { SolFoundryLogoMarkProps, SolFoundryLogoMarkSize } from './SolFoundryLogoMark';
export { LoadingButton, Spinner } from './LoadingButton';
-export type { LoadingButtonProps } from './LoadingButton';
\ No newline at end of file
+export type { LoadingButtonProps } from './LoadingButton';
+export { CopyAddress, truncateAddress } from './CopyAddress';
+export type { CopyAddressProps } from './CopyAddress';
\ No newline at end of file