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
38 changes: 38 additions & 0 deletions docs/components/WalletConnectButton.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# WalletConnectButton

The `WalletConnectButton` component provides a unified control for users to connect and manage their crypto wallet session within the TalentTrust application.

## Location
`src/components/WalletConnectButton.tsx`

## Usage

```tsx
import { WalletConnectButton } from '@/components/WalletConnectButton';

export function Header() {
return (
<header>
<WalletConnectButton />
</header>
);
}
```

## Features
- **Global State Integration:** Uses `useWallet` context to ensure the connection state is shared across the app, such as gating actions in `ActionPanel`.
- **States:**
- **Disconnected:** Displays a prominent "Connect Wallet" button.
- **Connecting:** Displays a loading spinner and "Connecting..." text. Disables the button.
- **Error:** Displays a "Connection Error" message with a "Retry" link.
- **Connected:** Displays the truncated wallet address along with options to copy to clipboard or disconnect.
- **Accessibility:** Fully accessible with ARIA labels, semantic HTML, and proper focus states. Buttons are keyboard operable.
- **Responsiveness:** Works across mobile and desktop viewpoints.

## Dependencies
- `lucide-react` (icons) or inline SVGs.
- `@/contexts/WalletContext`
- `@/lib/truncateAddress`

## Testing
Tested with Jest and React Testing Library in `src/components/__tests__/WalletConnectButton.test.tsx`. Covers all UI states and interactions (click, copy, etc.).
11 changes: 11 additions & 0 deletions jest.setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,14 @@ const localStorageMock = (() => {

Object.defineProperty(window, 'localStorage', { value: localStorageMock });

// Global mock for WalletContext
jest.mock('@/contexts/WalletContext', () => ({
useWallet: jest.fn().mockReturnValue({
address: '0x123',
isConnecting: false,
error: null,
connect: jest.fn(),
disconnect: jest.fn(),
}),
WalletProvider: ({ children }) => children,
}));
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 16 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export const metadata: Metadata = {

import { PreferencesProvider } from '@/lib/preferences';
import { SettingsTrigger } from '@/components/settings/SettingsTrigger';
import { WalletProvider } from '@/contexts/WalletContext';
import { WalletConnectButton } from '@/components/WalletConnectButton';

export default function RootLayout({
children,
Expand All @@ -20,8 +22,20 @@ export default function RootLayout({
<body>
<PreferencesProvider>
<ToastProvider>
{children}
<SettingsTrigger />
<WalletProvider>
<div className="min-h-screen bg-slate-50 flex flex-col">
<header className="sticky top-0 z-40 flex w-full items-center justify-between border-b border-slate-200 bg-white/80 px-6 py-4 backdrop-blur-md">
<div className="flex items-center gap-2">
<span className="text-xl font-bold tracking-tight text-slate-900">TalentTrust</span>
</div>
<WalletConnectButton />
</header>
<main className="flex-1 p-6">
{children}
</main>
</div>
<SettingsTrigger />
</WalletProvider>
</ToastProvider>
</PreferencesProvider>
</body>
Expand Down
22 changes: 19 additions & 3 deletions src/components/ActionPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use client';

import { useWallet } from '@/contexts/WalletContext';

export type ActionPanelProps = {
status: 'Active' | 'Completed' | 'Disputed' | 'Pending';
onSubmitMilestone?: () => void;
Expand Down Expand Up @@ -29,21 +31,31 @@ const ActionPanel = ({
onViewSummary,
}: ActionPanelProps) => {
const actions = getActionButtons(status);
const { address } = useWallet();
const isWalletConnected = !!address;
const noWalletMsg = 'Connect wallet to perform this action';

return (
<aside className="sticky top-6 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm">
<div className="mb-6">
<p className="text-sm text-slate-500 uppercase tracking-[0.24em]">Action Panel</p>
<h2 className="mt-2 text-xl font-semibold text-slate-900">What would you like to do?</h2>
{!isWalletConnected && (
<p className="mt-2 text-sm text-red-500 bg-red-50 p-2 rounded-lg border border-red-100">
{noWalletMsg}
</p>
)}
</div>

<div className="space-y-3">
{actions.includes('Submit Milestone') && onSubmitMilestone && (
<button
type="button"
onClick={onSubmitMilestone}
disabled={!isWalletConnected}
title={!isWalletConnected ? noWalletMsg : undefined}
aria-label="Submit milestone for approval"
className="w-full rounded-2xl bg-blue-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-blue-700"
className="w-full rounded-2xl bg-blue-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Submit Milestone
</button>
Expand All @@ -53,8 +65,10 @@ const ActionPanel = ({
<button
type="button"
onClick={onReleaseFunds}
disabled={!isWalletConnected}
title={!isWalletConnected ? noWalletMsg : undefined}
aria-label="Release funds to the contractor"
className="w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm font-semibold text-slate-900 transition hover:border-slate-400"
className="w-full rounded-2xl border border-slate-300 bg-white px-4 py-3 text-sm font-semibold text-slate-900 transition hover:border-slate-400 disabled:opacity-50 disabled:cursor-not-allowed"
>
Release Funds
</button>
Expand All @@ -64,8 +78,10 @@ const ActionPanel = ({
<button
type="button"
onClick={onDispute}
disabled={!isWalletConnected}
title={!isWalletConnected ? noWalletMsg : undefined}
aria-label="Open a dispute for this contract"
className="w-full rounded-2xl bg-rose-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-rose-700"
className="w-full rounded-2xl bg-rose-600 px-4 py-3 text-sm font-semibold text-white transition hover:bg-rose-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Dispute
</button>
Expand Down
96 changes: 96 additions & 0 deletions src/components/WalletConnectButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
'use client';

import React, { useState } from 'react';
import { useWallet } from '@/contexts/WalletContext';
import { truncateAddress } from '@/lib/truncateAddress';

export const WalletConnectButton = () => {
const { address, isConnecting, error, connect, disconnect } = useWallet();
const [copied, setCopied] = useState(false);

const handleCopy = async () => {
if (address) {
await navigator.clipboard.writeText(address);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};

if (error) {
return (
<div className="flex items-center gap-2 rounded-xl bg-red-50 px-4 py-2 text-red-600 ring-1 ring-red-200">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="text-sm font-medium">Connection Error</span>
<button
onClick={connect}
className="ml-2 text-sm font-semibold underline hover:text-red-700"
aria-label="Retry wallet connection"
>
Retry
</button>
</div>
);
}

if (address) {
return (
<div className="flex items-center gap-2 rounded-xl bg-slate-100 p-1 ring-1 ring-slate-200">
<div className="flex items-center gap-2 px-3 py-1.5">
<div className="h-2 w-2 rounded-full bg-green-500" aria-hidden="true" />
<span className="text-sm font-medium text-slate-700 font-mono">
{truncateAddress(address)}
</span>
</div>
<button
onClick={handleCopy}
className="rounded-lg p-1.5 text-slate-500 hover:bg-slate-200 hover:text-slate-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label="Copy address to clipboard"
title="Copy address"
>
{copied ? (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
<button
onClick={disconnect}
className="rounded-lg p-1.5 text-slate-500 hover:bg-red-100 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-red-500"
aria-label="Disconnect wallet"
title="Disconnect wallet"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
</button>
</div>
);
}

return (
<button
onClick={connect}
disabled={isConnecting}
aria-label="Connect wallet"
className="flex items-center justify-center gap-2 rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-70"
>
{isConnecting ? (
<>
<svg className="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span>Connecting...</span>
</>
) : (
<span>Connect Wallet</span>
)}
</button>
);
};
Loading
Loading