Skip to content

Commit eaa3b41

Browse files
authored
Merge pull request #226 from Johnsource-hub/feat/combined-dashboard-fleet-improvements
feat: integrate tokenized reputation, add multisig tests, and fix z-index
2 parents 296ad7a + db270b8 commit eaa3b41

13 files changed

Lines changed: 773 additions & 95 deletions

components/escrow/PayoutUI.tsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
'use client';
2+
3+
import { useEscrowPayout } from '@/hooks/useEscrowPayout';
4+
import { SignatureProgressBar } from './SignatureProgressBar';
5+
import { SignerList } from './SignerList';
6+
7+
interface PayoutUIProps {
8+
escrowId: string;
9+
}
10+
11+
export function PayoutUI({ escrowId }: PayoutUIProps) {
12+
const {
13+
isLoading,
14+
error,
15+
requiredSignatures,
16+
currentSignatures,
17+
signers,
18+
canRelease,
19+
releaseFunds,
20+
} = useEscrowPayout(escrowId);
21+
22+
if (isLoading) {
23+
return <div aria-label="Loading payout UI">Loading escrow details...</div>;
24+
}
25+
26+
if (error) {
27+
return <div role="alert" className="text-red-500">Error: {error}</div>;
28+
}
29+
30+
return (
31+
<div className="p-4 border rounded-md">
32+
<h2 className="text-lg font-semibold">Escrow Payout</h2>
33+
<SignatureProgressBar
34+
current={currentSignatures}
35+
required={requiredSignatures}
36+
/>
37+
<SignerList signers={signers} requiredSignatures={requiredSignatures} />
38+
<button
39+
onClick={releaseFunds}
40+
disabled={!canRelease}
41+
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'}`}
42+
>
43+
Release Funds
44+
</button>
45+
</div>
46+
);
47+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
'use client';
2+
3+
export function SignatureProgressBar({
4+
current,
5+
required,
6+
}: {
7+
current: number;
8+
required: number;
9+
}) {
10+
if (required <= 0) {
11+
return (
12+
<p className="mt-2 text-sm text-gray-500">
13+
Multi-signature not required for this escrow.
14+
</p>
15+
);
16+
}
17+
const percentage = Math.min((current / required) * 100, 100);
18+
const isComplete = current >= required;
19+
20+
return (
21+
<div className="mt-4 space-y-2">
22+
<div className="flex justify-between text-sm">
23+
<span className="font-medium text-gray-700">Signatures Received</span>
24+
<span className={`font-medium ${isComplete ? 'text-green-600' : 'text-gray-500'}`}>
25+
{current} of {required}
26+
</span>
27+
</div>
28+
<div className="w-full bg-gray-200 rounded-full h-2">
29+
<div
30+
className={`h-2 rounded-full transition-all duration-300 ${isComplete ? 'bg-green-500' : 'bg-blue-600'}`}
31+
style={{ width: `${percentage}%` }}
32+
role="progressbar"
33+
aria-valuenow={percentage}
34+
aria-valuemin={0}
35+
aria-valuemax={100}
36+
aria-label="Signature progress"
37+
></div>
38+
</div>
39+
</div>
40+
);
41+
}

components/escrow/SignerList.tsx

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { Copy, Check, ListX } from 'lucide-react';
5+
import { Tooltip } from '@/components/ui/Tooltip';
6+
7+
interface SignerListProps {
8+
signers: string[];
9+
requiredSignatures: number;
10+
}
11+
12+
export function SignerList({ signers, requiredSignatures }: SignerListProps) {
13+
const [copiedKey, setCopiedKey] = useState<string | null>(null);
14+
15+
const handleCopy = (key: string) => {
16+
navigator.clipboard.writeText(key);
17+
setCopiedKey(key);
18+
setTimeout(() => setCopiedKey(null), 2000);
19+
};
20+
21+
if (signers.length === 0 && requiredSignatures > 0) {
22+
return (
23+
<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">
24+
<ListX className="h-8 w-8 text-gray-400" />
25+
<span>No signatures have been recorded yet.</span>
26+
</div>
27+
);
28+
}
29+
30+
if (signers.length === 0) {
31+
return null;
32+
}
33+
34+
return (
35+
<div className="mt-4 border-t border-gray-100 pt-4">
36+
<h3 className="mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-gray-500">
37+
Signers ({signers.length})
38+
</h3>
39+
<ul className="space-y-1 max-h-36 overflow-y-auto pr-2">
40+
{signers.map((signer, index) => (
41+
<li
42+
key={index}
43+
className="flex items-center justify-between rounded-lg p-2 transition-colors hover:bg-gray-100"
44+
>
45+
<div className="flex items-center gap-3">
46+
<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">
47+
{index + 1}
48+
</span>
49+
<Tooltip content={signer} placement="top">
50+
<code className="truncate text-xs text-gray-600 cursor-help">
51+
{signer.slice(0, 8)}...{signer.slice(-8)}
52+
</code>
53+
</Tooltip>
54+
</div>
55+
<button
56+
onClick={() => handleCopy(signer)}
57+
className="p-1 text-gray-400 transition-colors hover:text-gray-600"
58+
title="Copy public key"
59+
>
60+
{copiedKey === signer ? (
61+
<Check className="h-4 w-4 text-green-500" />
62+
) : (
63+
<Copy className="h-4 w-4" />
64+
)}
65+
</button>
66+
</li>
67+
))}
68+
</ul>
69+
</div>
70+
);
71+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { render, screen, fireEvent } from '@testing-library/react';
2+
import { PayoutUI } from '../PayoutUI';
3+
import { useEscrowPayout } from '@/hooks/useEscrowPayout';
4+
5+
// Mock the useEscrowPayout hook
6+
jest.mock('@/hooks/useEscrowPayout');
7+
const mockedUseEscrowPayout = useEscrowPayout as jest.Mock;
8+
9+
describe('PayoutUI', () => {
10+
const mockEscrowId = 'escrow-123';
11+
const mockReleaseFunds = jest.fn();
12+
13+
beforeEach(() => {
14+
// Reset mock before each test
15+
mockedUseEscrowPayout.mockClear();
16+
mockReleaseFunds.mockClear();
17+
});
18+
19+
it('should render loading state', () => {
20+
mockedUseEscrowPayout.mockReturnValue({
21+
isLoading: true,
22+
error: null,
23+
requiredSignatures: 0,
24+
currentSignatures: 0,
25+
canRelease: false,
26+
releaseFunds: mockReleaseFunds,
27+
});
28+
29+
render(<PayoutUI escrowId={mockEscrowId} />);
30+
expect(screen.getByLabelText('Loading payout UI')).toBeInTheDocument();
31+
});
32+
33+
it('should render error state', () => {
34+
mockedUseEscrowPayout.mockReturnValue({
35+
isLoading: false,
36+
error: 'Failed to fetch escrow details',
37+
requiredSignatures: 0,
38+
currentSignatures: 0,
39+
canRelease: false,
40+
releaseFunds: mockReleaseFunds,
41+
});
42+
43+
render(<PayoutUI escrowId={mockEscrowId} />);
44+
expect(screen.getByRole('alert')).toHaveTextContent(
45+
'Error: Failed to fetch escrow details',
46+
);
47+
});
48+
49+
it('should disable the "Release Funds" button when signatures are below threshold (1 of 2)', () => {
50+
mockedUseEscrowPayout.mockReturnValue({
51+
isLoading: false,
52+
error: null,
53+
requiredSignatures: 2,
54+
currentSignatures: 1,
55+
canRelease: false,
56+
releaseFunds: mockReleaseFunds,
57+
});
58+
59+
render(<PayoutUI escrowId={mockEscrowId} />);
60+
61+
const releaseButton = screen.getByRole('button', { name: 'Release Funds' });
62+
expect(releaseButton).toBeDisabled();
63+
expect(screen.getByText('Signatures: 1 / 2')).toBeInTheDocument();
64+
65+
// Assert that clicking the disabled button throws no exceptions and does not call releaseFunds
66+
fireEvent.click(releaseButton);
67+
expect(mockReleaseFunds).not.toHaveBeenCalled();
68+
});
69+
70+
it('should enable the "Release Funds" button when signatures meet the threshold (2 of 2)', () => {
71+
mockedUseEscrowPayout.mockReturnValue({
72+
isLoading: false,
73+
error: null,
74+
requiredSignatures: 2,
75+
currentSignatures: 2,
76+
canRelease: true,
77+
releaseFunds: mockReleaseFunds,
78+
});
79+
80+
render(<PayoutUI escrowId={mockEscrowId} />);
81+
82+
const releaseButton = screen.getByRole('button', { name: 'Release Funds' });
83+
expect(releaseButton).not.toBeDisabled();
84+
expect(screen.getByText('Signatures: 2 / 2')).toBeInTheDocument();
85+
86+
fireEvent.click(releaseButton);
87+
expect(mockReleaseFunds).toHaveBeenCalledTimes(1);
88+
});
89+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { render, screen } from '@testing-library/react';
2+
import { SignatureProgressBar } from '../SignatureProgressBar';
3+
4+
describe('SignatureProgressBar', () => {
5+
it('should render a message when multi-signature is not required', () => {
6+
render(<SignatureProgressBar current={0} required={0} />);
7+
expect(
8+
screen.getByText('Multi-signature not required for this escrow.'),
9+
).toBeInTheDocument();
10+
});
11+
12+
it('should render the progress correctly for an incomplete state (1 of 3)', () => {
13+
render(<SignatureProgressBar current={1} required={3} />);
14+
15+
const progressbar = screen.getByRole('progressbar');
16+
expect(progressbar).toBeInTheDocument();
17+
// Note: Jest stringifies the style, so we check for the substring.
18+
expect(progressbar.style.width).toContain('33.33');
19+
expect(progressbar).toHaveClass('bg-blue-600');
20+
expect(progressbar).toHaveAttribute('aria-valuenow', '33.33333333333333');
21+
22+
expect(screen.getByText('1 of 3')).toBeInTheDocument();
23+
expect(screen.getByText('1 of 3')).toHaveClass('text-gray-500');
24+
});
25+
26+
it('should render the progress correctly for a complete state (3 of 3)', () => {
27+
render(<SignatureProgressBar current={3} required={3} />);
28+
29+
const progressbar = screen.getByRole('progressbar');
30+
expect(progressbar).toBeInTheDocument();
31+
expect(progressbar).toHaveStyle('width: 100%');
32+
expect(progressbar).toHaveClass('bg-green-500');
33+
expect(progressbar).toHaveAttribute('aria-valuenow', '100');
34+
35+
expect(screen.getByText('3 of 3')).toBeInTheDocument();
36+
expect(screen.getByText('3 of 3')).toHaveClass('text-green-600');
37+
});
38+
39+
it('should cap the progress at 100% if current signatures exceed required', () => {
40+
render(<SignatureProgressBar current={5} required={4} />);
41+
42+
const progressbar = screen.getByRole('progressbar');
43+
expect(progressbar).toHaveStyle('width: 100%');
44+
expect(progressbar).toHaveClass('bg-green-500');
45+
expect(progressbar).toHaveAttribute('aria-valuenow', '100');
46+
47+
expect(screen.getByText('5 of 4')).toBeInTheDocument();
48+
expect(screen.getByText('5 of 4')).toHaveClass('text-green-600');
49+
});
50+
});
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { render, screen, fireEvent, act } from '@testing-library/react';
2+
import { SignerList } from '../SignerList';
3+
4+
// Mock the clipboard API
5+
Object.assign(navigator, {
6+
clipboard: {
7+
writeText: jest.fn(),
8+
},
9+
});
10+
11+
const mockSigners = [
12+
'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF',
13+
'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB5432',
14+
];
15+
16+
describe('SignerList', () => {
17+
beforeEach(() => {
18+
(navigator.clipboard.writeText as jest.Mock).mockClear();
19+
jest.useFakeTimers();
20+
});
21+
22+
afterEach(() => {
23+
jest.useRealTimers();
24+
});
25+
26+
it('should render a message when no signers are present but signatures are required', () => {
27+
render(<SignerList signers={[]} requiredSignatures={2} />);
28+
expect(
29+
screen.getByText('No signatures have been recorded yet.'),
30+
).toBeInTheDocument();
31+
});
32+
33+
it('should render nothing when no signers are present and none are required', () => {
34+
const { container } = render(
35+
<SignerList signers={[]} requiredSignatures={0} />,
36+
);
37+
expect(container).toBeEmptyDOMElement();
38+
});
39+
40+
it('should render a list of signers with truncated keys', () => {
41+
render(<SignerList signers={mockSigners} requiredSignatures={2} />);
42+
43+
expect(screen.getByText('GAAAAAAA...AAAAWHF')).toBeInTheDocument();
44+
expect(screen.getByText('GBBBBBBB...BBBB5432')).toBeInTheDocument();
45+
expect(screen.getAllByRole('listitem').length).toBe(2);
46+
});
47+
48+
it('should copy a key to the clipboard and show feedback', () => {
49+
render(<SignerList signers={mockSigners} requiredSignatures={2} />);
50+
51+
const copyButtons = screen.getAllByTitle('Copy public key');
52+
expect(copyButtons.length).toBe(2);
53+
54+
// Click the first copy button
55+
fireEvent.click(copyButtons[0]);
56+
57+
// Check that clipboard.writeText was called with the full key
58+
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(mockSigners[0]);
59+
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
60+
61+
// The icon should change to a checkmark, which has a distinct green color
62+
const checkIcon = copyButtons[0].querySelector('.text-green-500');
63+
expect(checkIcon).toBeInTheDocument();
64+
65+
// The other button should still have the default copy icon
66+
const otherButtonCheckIcon = copyButtons[1].querySelector('.text-green-500');
67+
expect(otherButtonCheckIcon).not.toBeInTheDocument();
68+
69+
// Fast-forward time to reset the copied state
70+
act(() => {
71+
jest.advanceTimersByTime(2000);
72+
});
73+
74+
// The checkmark icon should be gone, reverting to the copy icon
75+
const checkIconAfterTimeout = copyButtons[0].querySelector('.text-green-500');
76+
expect(checkIconAfterTimeout).not.toBeInTheDocument();
77+
});
78+
});

0 commit comments

Comments
 (0)