Skip to content

Commit 747e7a0

Browse files
committed
feat(nav): wallet status chip with address and network badge
Implements #686. Two of the four assigned issues describe components that do not exist in this repository — see the note below and the PR body. The nav bar's CTA was a bare "Connect Wallet" link that stayed there whether or not a wallet was connected, so there was no persistent indication of which account or which network was active. WalletStatusChip replaces it: - Truncated address via the existing shortenAddress, which already formats first-4/last-4 exactly as the issue specifies. - Network badge, green for mainnet and yellow for testnet. - Dropdown with the full address, copy, and disconnect. - The same Connect link rendered from inside the component when no wallet is connected, so the nav slot is never empty and Header does not need to branch. The chip reads useAccount and useChainId directly rather than taking props. Passing the address down would leave it stale until whatever owns that prop happened to re-render; reading wagmi state means a network switch in the extension updates the badge immediately, which is one of the acceptance criteria. describeNetwork is a separate module so the classification is testable without rendering. Anything that is not Ethereum mainnet is treated as a testnet, and an unrecognised chain gets its own "unsupported" state rather than being shown as a testnet — labelling a chain the app cannot talk to as "testnet" implies the app works there. That default also means a chain added to supportedChains later shows yellow until someone deliberately promotes it, instead of silently rendering a green "you are on mainnet". Copy keeps the menu open so the copied-state tick is visible, and reverts after 2s so the confirmation is unambiguous rather than permanent. Verified: tsc -b --noEmit clean, 7 tests passing. Not implemented — the underlying features are absent: - #687 asks for an optimistic update in a useFollowCreator mutation. There is no follow feature anywhere in src: no hook, no service call, no isFollowing state, no button. - #689 asks for tests of a transaction history filter with a type dropdown, date range and Reset. TransactionHistory.tsx renders a list from props and has no filter controls at all. Both need the feature built first, which is a different change from the one they describe.
1 parent 1721d71 commit 747e7a0

4 files changed

Lines changed: 217 additions & 11 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { useAccount, useChainId, useDisconnect } from 'wagmi';
2+
import { Link } from 'react-router';
3+
import { Copy, Check, LogOut } from 'lucide-react';
4+
import { useState } from 'react';
5+
import {
6+
DropdownMenu,
7+
DropdownMenuContent,
8+
DropdownMenuItem,
9+
DropdownMenuLabel,
10+
DropdownMenuSeparator,
11+
DropdownMenuTrigger,
12+
} from '@/components/ui/dropdown-menu';
13+
import { shortenAddress } from '@/lib/web3/format';
14+
import { describeNetwork } from '@/lib/web3/network';
15+
import { copyTextToClipboard } from '@/utils/clipboard.utils';
16+
import showToast from '@/utils/toast.util';
17+
18+
/**
19+
* Persistent wallet status chip for the nav bar (issue #686).
20+
*
21+
* Reads `useAccount` and `useChainId` directly rather than taking props, so the
22+
* chip re-renders from wagmi's own state when the user switches network in
23+
* their extension. Passing the address down would leave it stale until whatever
24+
* owns that prop happened to re-render.
25+
*/
26+
export function WalletStatusChip({ className = '' }: { className?: string }) {
27+
const { address, isConnected } = useAccount();
28+
const chainId = useChainId();
29+
const { disconnect } = useDisconnect();
30+
const [copied, setCopied] = useState(false);
31+
32+
if (!isConnected || !address) {
33+
return (
34+
<Link
35+
to="/connect"
36+
className={`inline-flex items-center rounded-full bg-white/10 px-3 py-1.5 font-jakarta text-sm text-white transition-colors hover:bg-white/20 ${className}`}
37+
>
38+
Connect Wallet
39+
</Link>
40+
);
41+
}
42+
43+
const network = describeNetwork(chainId);
44+
45+
const handleCopy = async () => {
46+
try {
47+
await copyTextToClipboard(address);
48+
} catch {
49+
showToast.error('Could not copy address');
50+
return;
51+
}
52+
setCopied(true);
53+
showToast.success('Address copied');
54+
// Reverting the icon gives the user a second, unambiguous confirmation
55+
// that the action completed rather than leaving a permanent tick.
56+
setTimeout(() => setCopied(false), 2000);
57+
};
58+
59+
return (
60+
<DropdownMenu>
61+
<DropdownMenuTrigger asChild>
62+
<button
63+
type="button"
64+
aria-label={`Wallet ${shortenAddress(address)} on ${network.label}. Open wallet menu.`}
65+
className={`inline-flex items-center gap-2 rounded-full bg-white/10 px-3 py-1.5 transition-colors hover:bg-white/20 ${className}`}
66+
>
67+
<span className="font-mono text-xs text-white">{shortenAddress(address)}</span>
68+
<span
69+
// aria-hidden: the network is already stated in the button's
70+
// accessible name, so announcing the badge repeats it.
71+
aria-hidden="true"
72+
className={`rounded-full px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide ring-1 ring-inset ${network.badgeClass}`}
73+
>
74+
{network.kind === 'unsupported' ? 'Unsupported' : network.label}
75+
</span>
76+
</button>
77+
</DropdownMenuTrigger>
78+
79+
<DropdownMenuContent align="end" className="w-72">
80+
<DropdownMenuLabel className="font-normal">
81+
<span className="block text-xs text-muted-foreground">Connected wallet</span>
82+
{/* break-all so a full address wraps instead of overflowing the menu */}
83+
<span className="mt-1 block break-all font-mono text-xs">{address}</span>
84+
<span className="mt-2 block text-xs text-muted-foreground">
85+
Network: {network.label}
86+
</span>
87+
</DropdownMenuLabel>
88+
89+
<DropdownMenuSeparator />
90+
91+
<DropdownMenuItem onSelect={event => {
92+
// Keep the menu open so the copied-state tick is visible.
93+
event.preventDefault();
94+
void handleCopy();
95+
}}>
96+
{copied ? <Check className="mr-2 size-4" /> : <Copy className="mr-2 size-4" />}
97+
{copied ? 'Copied' : 'Copy address'}
98+
</DropdownMenuItem>
99+
100+
<DropdownMenuItem
101+
onSelect={() => disconnect()}
102+
className="text-red-600 focus:text-red-600"
103+
>
104+
<LogOut className="mr-2 size-4" />
105+
Disconnect
106+
</DropdownMenuItem>
107+
</DropdownMenuContent>
108+
</DropdownMenu>
109+
);
110+
}
111+
112+
export default WalletStatusChip;

src/components/home/Header.tsx

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useEffect, useState } from 'react';
2+
import WalletStatusChip from '@/components/common/WalletStatusChip';
23
import { Link } from 'react-router';
34

45
const navLinks = [
@@ -65,17 +66,10 @@ export default function Header() {
6566
)}
6667
</nav>
6768

68-
{/* CTA */}
69-
<Link
70-
to="/connect"
71-
className={`rounded-sm px-5 py-2 font-mono text-[10px] uppercase tracking-wider transition-all duration-300 ${
72-
scrolled
73-
? 'border border-gray-200 bg-gray-50 text-gray-600 hover:border-gray-900 hover:bg-gray-900 hover:text-white'
74-
: 'border border-white/15 bg-white/[0.05] text-white/60 hover:border-white/30 hover:bg-white/[0.09] hover:text-white'
75-
}`}
76-
>
77-
Connect Wallet
78-
</Link>
69+
{/* CTA — #686: a persistent wallet status chip replaces the bare
70+
Connect link. WalletStatusChip renders the same link itself when
71+
no wallet is connected, so the slot is never empty. */}
72+
<WalletStatusChip />
7973
</div>
8074
</header>
8175
);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { mainnet, sepolia, baseSepolia, anvil } from 'wagmi/chains';
3+
import { describeNetwork } from '@/lib/web3/network';
4+
5+
/** Issue #686 — network badge classification. */
6+
describe('describeNetwork', () => {
7+
it('classifies Ethereum mainnet as mainnet with a green badge', () => {
8+
const info = describeNetwork(mainnet.id);
9+
expect(info.kind).toBe('mainnet');
10+
expect(info.badgeClass).toContain('green');
11+
});
12+
13+
it.each([
14+
['sepolia', sepolia.id],
15+
['baseSepolia', baseSepolia.id],
16+
['anvil', anvil.id],
17+
])('classifies %s as testnet with a yellow badge', (_name, id) => {
18+
const info = describeNetwork(id);
19+
expect(info.kind).toBe('testnet');
20+
expect(info.badgeClass).toContain('yellow');
21+
});
22+
23+
it('reports an unknown chain as unsupported, not testnet', () => {
24+
// Showing "testnet" for a chain the app cannot talk to would imply the
25+
// app works there.
26+
const info = describeNetwork(999_999);
27+
expect(info.kind).toBe('unsupported');
28+
expect(info.label).toContain('999999');
29+
});
30+
31+
it('handles an undefined chain id', () => {
32+
expect(describeNetwork(undefined).kind).toBe('unsupported');
33+
});
34+
35+
it('never labels a non-mainnet chain green', () => {
36+
// The safe default: a chain added to supportedChains later shows as
37+
// testnet until deliberately promoted, rather than silently rendering
38+
// "you are on mainnet".
39+
for (const id of [sepolia.id, baseSepolia.id, anvil.id, 42_161]) {
40+
expect(describeNetwork(id).badgeClass).not.toContain('green');
41+
}
42+
});
43+
});

src/lib/web3/network.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { mainnet } from 'wagmi/chains';
2+
import { supportedChains } from '@/lib/web3/chains';
3+
4+
/**
5+
* Network classification for the nav status chip (issue #686).
6+
*
7+
* Only Ethereum mainnet is a live-value network among the supported chains;
8+
* anvil, sepolia and baseSepolia are all test environments. Treating anything
9+
* that is not mainnet as a testnet is the safe default — a new chain added to
10+
* `supportedChains` shows as "testnet" until someone deliberately promotes it,
11+
* rather than silently rendering a green "you are on mainnet" badge.
12+
*/
13+
export type NetworkKind = 'mainnet' | 'testnet' | 'unsupported';
14+
15+
export interface NetworkInfo {
16+
kind: NetworkKind;
17+
label: string;
18+
/** Tailwind classes for the badge. Green for mainnet, yellow for testnet. */
19+
badgeClass: string;
20+
}
21+
22+
const MAINNET_CHAIN_IDS: readonly number[] = [mainnet.id];
23+
24+
export function describeNetwork(chainId: number | undefined): NetworkInfo {
25+
if (chainId === undefined) {
26+
return {
27+
kind: 'unsupported',
28+
label: 'Unknown network',
29+
badgeClass: 'bg-red-500/15 text-red-600 ring-red-500/30',
30+
};
31+
}
32+
33+
const chain = supportedChains.find(c => c.id === chainId);
34+
if (!chain) {
35+
// The wallet is on a chain the app cannot talk to. Surfacing this as its
36+
// own state matters: showing "testnet" would imply the app works there.
37+
return {
38+
kind: 'unsupported',
39+
label: `Unsupported (${chainId})`,
40+
badgeClass: 'bg-red-500/15 text-red-600 ring-red-500/30',
41+
};
42+
}
43+
44+
if (MAINNET_CHAIN_IDS.includes(chain.id)) {
45+
return {
46+
kind: 'mainnet',
47+
label: chain.name,
48+
badgeClass: 'bg-green-500/15 text-green-600 ring-green-500/30',
49+
};
50+
}
51+
52+
return {
53+
kind: 'testnet',
54+
label: chain.name,
55+
badgeClass: 'bg-yellow-500/15 text-yellow-700 ring-yellow-500/30',
56+
};
57+
}

0 commit comments

Comments
 (0)