Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions components/escrow/PayoutUI.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
'use client';

import { useEscrowPayout } from '@/hooks/useEscrowPayout';
import { SignatureProgressBar } from './SignatureProgressBar';
import { SignerList } from './SignerList';

interface PayoutUIProps {
escrowId: string;
}

export function PayoutUI({ escrowId }: PayoutUIProps) {
const {
isLoading,
error,
requiredSignatures,
currentSignatures,
signers,
canRelease,
releaseFunds,
} = useEscrowPayout(escrowId);

if (isLoading) {
return <div aria-label="Loading payout UI">Loading escrow details...</div>;
}

if (error) {
return <div role="alert" className="text-red-500">Error: {error}</div>;
}

return (
<div className="p-4 border rounded-md">
<h2 className="text-lg font-semibold">Escrow Payout</h2>
<SignatureProgressBar
current={currentSignatures}
required={requiredSignatures}
/>
<SignerList signers={signers} requiredSignatures={requiredSignatures} />
<button
onClick={releaseFunds}
disabled={!canRelease}
className={`mt-6 w-full px-4 py-2 rounded-md font-semibold text-white transition-colors ${canRelease ? 'bg-blue-600 hover:bg-blue-700' : 'bg-gray-400 cursor-not-allowed'}`}
>
Release Funds
</button>
</div>
);
}
41 changes: 41 additions & 0 deletions components/escrow/SignatureProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
'use client';

export function SignatureProgressBar({
current,
required,
}: {
current: number;
required: number;
}) {
if (required <= 0) {
return (
<p className="mt-2 text-sm text-gray-500">
Multi-signature not required for this escrow.
</p>
);
}
const percentage = Math.min((current / required) * 100, 100);
const isComplete = current >= required;

return (
<div className="mt-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="font-medium text-gray-700">Signatures Received</span>
<span className={`font-medium ${isComplete ? 'text-green-600' : 'text-gray-500'}`}>
{current} of {required}
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${isComplete ? 'bg-green-500' : 'bg-blue-600'}`}
style={{ width: `${percentage}%` }}
role="progressbar"
aria-valuenow={percentage}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Signature progress"
></div>
</div>
</div>
);
}
71 changes: 71 additions & 0 deletions components/escrow/SignerList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
'use client';

import { useState } from 'react';
import { Copy, Check, ListX } from 'lucide-react';
import { Tooltip } from '@/components/ui/Tooltip';

interface SignerListProps {
signers: string[];
requiredSignatures: number;
}

export function SignerList({ signers, requiredSignatures }: SignerListProps) {
const [copiedKey, setCopiedKey] = useState<string | null>(null);

const handleCopy = (key: string) => {
navigator.clipboard.writeText(key);
setCopiedKey(key);
setTimeout(() => setCopiedKey(null), 2000);
};

if (signers.length === 0 && requiredSignatures > 0) {
return (
<div className="mt-4 flex flex-col items-center justify-center gap-2 border-t border-gray-100 py-6 text-center text-sm text-gray-500">
<ListX className="h-8 w-8 text-gray-400" />
<span>No signatures have been recorded yet.</span>
</div>
);
}

if (signers.length === 0) {
return null;
}

return (
<div className="mt-4 border-t border-gray-100 pt-4">
<h3 className="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-gray-500">
Signers ({signers.length})
</h3>
<ul className="space-y-1 max-h-36 overflow-y-auto pr-2">
{signers.map((signer, index) => (
<li
key={index}
className="flex items-center justify-between rounded-lg p-2 transition-colors hover:bg-gray-100"
>
<div className="flex items-center gap-3">
<span className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full bg-gray-200 text-xs font-bold text-gray-600">
{index + 1}
</span>
<Tooltip content={signer} placement="top">
<code className="truncate text-xs text-gray-600 cursor-help">
{signer.slice(0, 8)}...{signer.slice(-8)}
</code>
</Tooltip>
</div>
<button
onClick={() => handleCopy(signer)}
className="p-1 text-gray-400 transition-colors hover:text-gray-600"
title="Copy public key"
>
{copiedKey === signer ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</li>
))}
</ul>
</div>
);
}
89 changes: 89 additions & 0 deletions components/escrow/__tests__/PayoutUI.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { PayoutUI } from '../PayoutUI';
import { useEscrowPayout } from '@/hooks/useEscrowPayout';

// Mock the useEscrowPayout hook
jest.mock('@/hooks/useEscrowPayout');
const mockedUseEscrowPayout = useEscrowPayout as jest.Mock;

describe('PayoutUI', () => {
const mockEscrowId = 'escrow-123';
const mockReleaseFunds = jest.fn();

beforeEach(() => {
// Reset mock before each test
mockedUseEscrowPayout.mockClear();
mockReleaseFunds.mockClear();
});

it('should render loading state', () => {
mockedUseEscrowPayout.mockReturnValue({
isLoading: true,
error: null,
requiredSignatures: 0,
currentSignatures: 0,
canRelease: false,
releaseFunds: mockReleaseFunds,
});

render(<PayoutUI escrowId={mockEscrowId} />);
expect(screen.getByLabelText('Loading payout UI')).toBeInTheDocument();
});

it('should render error state', () => {
mockedUseEscrowPayout.mockReturnValue({
isLoading: false,
error: 'Failed to fetch escrow details',
requiredSignatures: 0,
currentSignatures: 0,
canRelease: false,
releaseFunds: mockReleaseFunds,
});

render(<PayoutUI escrowId={mockEscrowId} />);
expect(screen.getByRole('alert')).toHaveTextContent(
'Error: Failed to fetch escrow details',
);
});

it('should disable the "Release Funds" button when signatures are below threshold (1 of 2)', () => {
mockedUseEscrowPayout.mockReturnValue({
isLoading: false,
error: null,
requiredSignatures: 2,
currentSignatures: 1,
canRelease: false,
releaseFunds: mockReleaseFunds,
});

render(<PayoutUI escrowId={mockEscrowId} />);

const releaseButton = screen.getByRole('button', { name: 'Release Funds' });
expect(releaseButton).toBeDisabled();
expect(screen.getByText('Signatures: 1 / 2')).toBeInTheDocument();

// Assert that clicking the disabled button throws no exceptions and does not call releaseFunds
fireEvent.click(releaseButton);
expect(mockReleaseFunds).not.toHaveBeenCalled();
});

it('should enable the "Release Funds" button when signatures meet the threshold (2 of 2)', () => {
mockedUseEscrowPayout.mockReturnValue({
isLoading: false,
error: null,
requiredSignatures: 2,
currentSignatures: 2,
canRelease: true,
releaseFunds: mockReleaseFunds,
});

render(<PayoutUI escrowId={mockEscrowId} />);

const releaseButton = screen.getByRole('button', { name: 'Release Funds' });
expect(releaseButton).not.toBeDisabled();
expect(screen.getByText('Signatures: 2 / 2')).toBeInTheDocument();

fireEvent.click(releaseButton);
expect(mockReleaseFunds).toHaveBeenCalledTimes(1);
});
});
50 changes: 50 additions & 0 deletions components/escrow/__tests__/SignatureProgressBar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { render, screen } from '@testing-library/react';
import { SignatureProgressBar } from '../SignatureProgressBar';

describe('SignatureProgressBar', () => {
it('should render a message when multi-signature is not required', () => {
render(<SignatureProgressBar current={0} required={0} />);
expect(
screen.getByText('Multi-signature not required for this escrow.'),
).toBeInTheDocument();
});

it('should render the progress correctly for an incomplete state (1 of 3)', () => {
render(<SignatureProgressBar current={1} required={3} />);

const progressbar = screen.getByRole('progressbar');
expect(progressbar).toBeInTheDocument();
// Note: Jest stringifies the style, so we check for the substring.
expect(progressbar.style.width).toContain('33.33');
expect(progressbar).toHaveClass('bg-blue-600');
expect(progressbar).toHaveAttribute('aria-valuenow', '33.33333333333333');

expect(screen.getByText('1 of 3')).toBeInTheDocument();
expect(screen.getByText('1 of 3')).toHaveClass('text-gray-500');
});

it('should render the progress correctly for a complete state (3 of 3)', () => {
render(<SignatureProgressBar current={3} required={3} />);

const progressbar = screen.getByRole('progressbar');
expect(progressbar).toBeInTheDocument();
expect(progressbar).toHaveStyle('width: 100%');
expect(progressbar).toHaveClass('bg-green-500');
expect(progressbar).toHaveAttribute('aria-valuenow', '100');

expect(screen.getByText('3 of 3')).toBeInTheDocument();
expect(screen.getByText('3 of 3')).toHaveClass('text-green-600');
});

it('should cap the progress at 100% if current signatures exceed required', () => {
render(<SignatureProgressBar current={5} required={4} />);

const progressbar = screen.getByRole('progressbar');
expect(progressbar).toHaveStyle('width: 100%');
expect(progressbar).toHaveClass('bg-green-500');
expect(progressbar).toHaveAttribute('aria-valuenow', '100');

expect(screen.getByText('5 of 4')).toBeInTheDocument();
expect(screen.getByText('5 of 4')).toHaveClass('text-green-600');
});
});
78 changes: 78 additions & 0 deletions components/escrow/__tests__/SignerList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import { SignerList } from '../SignerList';

// Mock the clipboard API
Object.assign(navigator, {
clipboard: {
writeText: jest.fn(),
},
});

const mockSigners = [
'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF',
'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB5432',
];

describe('SignerList', () => {
beforeEach(() => {
(navigator.clipboard.writeText as jest.Mock).mockClear();
jest.useFakeTimers();
});

afterEach(() => {
jest.useRealTimers();
});

it('should render a message when no signers are present but signatures are required', () => {
render(<SignerList signers={[]} requiredSignatures={2} />);
expect(
screen.getByText('No signatures have been recorded yet.'),
).toBeInTheDocument();
});

it('should render nothing when no signers are present and none are required', () => {
const { container } = render(
<SignerList signers={[]} requiredSignatures={0} />,
);
expect(container).toBeEmptyDOMElement();
});

it('should render a list of signers with truncated keys', () => {
render(<SignerList signers={mockSigners} requiredSignatures={2} />);

expect(screen.getByText('GAAAAAAA...AAAAWHF')).toBeInTheDocument();
expect(screen.getByText('GBBBBBBB...BBBB5432')).toBeInTheDocument();
expect(screen.getAllByRole('listitem').length).toBe(2);
});

it('should copy a key to the clipboard and show feedback', () => {
render(<SignerList signers={mockSigners} requiredSignatures={2} />);

const copyButtons = screen.getAllByTitle('Copy public key');
expect(copyButtons.length).toBe(2);

// Click the first copy button
fireEvent.click(copyButtons[0]);

// Check that clipboard.writeText was called with the full key
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(mockSigners[0]);
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);

// The icon should change to a checkmark, which has a distinct green color
const checkIcon = copyButtons[0].querySelector('.text-green-500');
expect(checkIcon).toBeInTheDocument();

// The other button should still have the default copy icon
const otherButtonCheckIcon = copyButtons[1].querySelector('.text-green-500');
expect(otherButtonCheckIcon).not.toBeInTheDocument();

// Fast-forward time to reset the copied state
act(() => {
jest.advanceTimersByTime(2000);
});

// The checkmark icon should be gone, reverting to the copy icon
const checkIconAfterTimeout = copyButtons[0].querySelector('.text-green-500');
expect(checkIconAfterTimeout).not.toBeInTheDocument();
});
});
Loading