Skip to content

Commit 697c7a8

Browse files
committed
feat: add network-mismatch warning for unsupported Stellar network (#117)
- Add useNetworkMismatch hook that compares the connected wallet's chain against the app's defaultChain (wagmi); only flags a mismatch when a wallet is actually connected so disconnected users are unaffected - Add NetworkMismatchBanner component styled consistently with WalletConnectCalloutBanner; self-contained (calls the hook internally), renders nothing when there is no mismatch, role=alert + aria-live for accessibility - Wire mismatch guard into CreatorCard: buy button disabled, handleBuy returns early with a toast, BuyActionHelperText receives disabledReason, NetworkMismatchBanner shown when connected + mismatched - Wire mismatch guard into LandingPage: desktop and mobile Buy/Sell buttons disabled, NetworkMismatchBanner shown above trade buttons - Add useNetworkMismatch unit tests (4/4 passing): disconnected wallet, correct chain, wrong chain, static expected-chain values
1 parent 2a70a2a commit 697c7a8

5 files changed

Lines changed: 200 additions & 3 deletions

File tree

src/components/common/CreatorCard.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import CardMetaRow from '@/components/common/CardMetaRow';
1313
import VerifiedBadge from '@/components/common/VerifiedBadge';
1414
import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar';
1515
import WalletConnectCalloutBanner from '@/components/common/WalletConnectCalloutBanner';
16+
import NetworkMismatchBanner from '@/components/common/NetworkMismatchBanner';
1617
import CreatorSocialLinksList from '@/components/common/CreatorSocialLinksList';
1718
import TransactionStatusIcon from '@/components/common/TransactionStatusIcon';
1819
import MiniStatChip from '@/components/common/MiniStatChip';
@@ -22,6 +23,7 @@ import CreatorListRowDivider from '@/components/common/CreatorListRowDivider';
2223
import BuyActionHelperText from '@/components/common/BuyActionHelperText';
2324
import CreatorLabeledStatRow from '@/components/common/CreatorLabeledStatRow';
2425
import { useTransactionTelemetry } from '@/hooks/useTransactionTelemetry';
26+
import { useNetworkMismatch } from '@/hooks/useNetworkMismatch';
2527
import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils';
2628

2729
interface CreatorCardProps {
@@ -31,6 +33,7 @@ interface CreatorCardProps {
3133

3234
const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
3335
const { isConnected } = useAccount();
36+
const { isMismatch: isNetworkMismatch, expectedChainName } = useNetworkMismatch();
3437
const [transactionState, setTransactionState] = useState<
3538
'idle' | 'submitting' | 'failed' | 'success'
3639
>('idle');
@@ -91,6 +94,13 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
9194
return;
9295
}
9396

97+
if (isNetworkMismatch) {
98+
toast.error(`Switch to ${expectedChainName} to purchase keys`, {
99+
duration: 4000,
100+
});
101+
return;
102+
}
103+
94104
toast.success(`Purchasing keys for ${creator.title}...`, {
95105
duration: 3000,
96106
});
@@ -221,6 +231,7 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
221231
size="sm"
222232
isPending={transactionState === 'submitting'}
223233
pendingText="Processing..."
234+
disabled={isNetworkMismatch}
224235
className={cn(
225236
'rounded-xl font-bold',
226237
!isConnected && 'border-white/10 hover:bg-white/5'
@@ -241,10 +252,22 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
241252
</AsyncButton>
242253
</div>
243254

244-
<BuyActionHelperText state={transactionState} className="mt-4" />
255+
<BuyActionHelperText
256+
state={transactionState}
257+
className="mt-4"
258+
disabledReason={
259+
isNetworkMismatch
260+
? `Switch to ${expectedChainName} to enable purchases.`
261+
: undefined
262+
}
263+
/>
245264

246265
{!isConnected && <WalletConnectCalloutBanner className="mt-4" />}
247266

267+
{isConnected && isNetworkMismatch && (
268+
<NetworkMismatchBanner className="mt-4" />
269+
)}
270+
248271
{transactionState === 'failed' && (
249272
<TransactionRetryNotice
250273
className="mt-4"
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { AlertTriangle } from 'lucide-react';
2+
import { cn } from '@/lib/utils';
3+
import { useNetworkMismatch } from '@/hooks/useNetworkMismatch';
4+
5+
interface NetworkMismatchBannerProps {
6+
className?: string;
7+
}
8+
9+
/**
10+
* Displays a warning banner when the connected wallet is on a different
11+
* network than the app's configured default chain.
12+
*
13+
* Renders nothing when there is no mismatch or no wallet is connected,
14+
* so it is safe to place unconditionally in any layout.
15+
*/
16+
const NetworkMismatchBanner: React.FC<NetworkMismatchBannerProps> = ({
17+
className,
18+
}) => {
19+
const { isMismatch, expectedChainName } = useNetworkMismatch();
20+
21+
if (!isMismatch) {
22+
return null;
23+
}
24+
25+
return (
26+
<div
27+
role="alert"
28+
aria-live="polite"
29+
className={cn(
30+
'rounded-2xl border border-red-400/30 bg-gradient-to-r from-red-500/10 via-red-400/5 to-orange-400/10 p-4',
31+
className
32+
)}
33+
>
34+
<div className="flex items-start gap-3">
35+
<div className="mt-0.5 shrink-0 rounded-full bg-red-500/15 p-2 text-red-300">
36+
<AlertTriangle className="size-4" aria-hidden="true" />
37+
</div>
38+
<div className="min-w-0 flex-1">
39+
<div className="mb-1 inline-flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.18em] text-red-300/85">
40+
Network mismatch
41+
</div>
42+
<p className="font-jakarta text-sm font-bold text-red-100">
43+
Wrong network detected
44+
</p>
45+
<p className="mt-1 text-xs text-red-100/75">
46+
Your wallet is connected to an unsupported network. Switch to{' '}
47+
<span className="font-semibold text-red-100">
48+
{expectedChainName}
49+
</span>{' '}
50+
to enable trade actions. You can still browse the marketplace in
51+
read-only mode.
52+
</p>
53+
</div>
54+
</div>
55+
</div>
56+
);
57+
};
58+
59+
export default NetworkMismatchBanner;
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { renderHook } from '@testing-library/react';
3+
import { useNetworkMismatch } from '@/hooks/useNetworkMismatch';
4+
5+
// ── wagmi mocks ──────────────────────────────────────────────────────────────
6+
vi.mock('wagmi', () => ({
7+
useAccount: vi.fn(),
8+
useChainId: vi.fn(),
9+
}));
10+
11+
// ── wagmiConfig mock (provides defaultChain) ─────────────────────────────────
12+
vi.mock('@/lib/web3/wagmiConfig', () => ({
13+
defaultChain: { id: 84532, name: 'Base Sepolia' },
14+
}));
15+
16+
import { useAccount, useChainId } from 'wagmi';
17+
18+
const mockUseAccount = vi.mocked(useAccount);
19+
const mockUseChainId = vi.mocked(useChainId);
20+
21+
// ─────────────────────────────────────────────────────────────────────────────
22+
23+
describe('useNetworkMismatch', () => {
24+
beforeEach(() => {
25+
vi.clearAllMocks();
26+
});
27+
28+
it('returns isMismatch=false when no wallet is connected', () => {
29+
mockUseAccount.mockReturnValue({ isConnected: false } as ReturnType<typeof useAccount>);
30+
mockUseChainId.mockReturnValue(1); // mainnet – irrelevant when disconnected
31+
32+
const { result } = renderHook(() => useNetworkMismatch());
33+
34+
expect(result.current.isMismatch).toBe(false);
35+
expect(result.current.connectedChainId).toBeUndefined();
36+
});
37+
38+
it('returns isMismatch=false when wallet is on the expected chain', () => {
39+
mockUseAccount.mockReturnValue({ isConnected: true } as ReturnType<typeof useAccount>);
40+
mockUseChainId.mockReturnValue(84532); // matches defaultChain
41+
42+
const { result } = renderHook(() => useNetworkMismatch());
43+
44+
expect(result.current.isMismatch).toBe(false);
45+
expect(result.current.connectedChainId).toBe(84532);
46+
});
47+
48+
it('returns isMismatch=true when wallet is on a different chain', () => {
49+
mockUseAccount.mockReturnValue({ isConnected: true } as ReturnType<typeof useAccount>);
50+
mockUseChainId.mockReturnValue(1); // mainnet ≠ Base Sepolia
51+
52+
const { result } = renderHook(() => useNetworkMismatch());
53+
54+
expect(result.current.isMismatch).toBe(true);
55+
expect(result.current.connectedChainId).toBe(1);
56+
});
57+
58+
it('always exposes the expected chain id and name', () => {
59+
mockUseAccount.mockReturnValue({ isConnected: false } as ReturnType<typeof useAccount>);
60+
mockUseChainId.mockReturnValue(84532);
61+
62+
const { result } = renderHook(() => useNetworkMismatch());
63+
64+
expect(result.current.expectedChainId).toBe(84532);
65+
expect(result.current.expectedChainName).toBe('Base Sepolia');
66+
});
67+
});

src/hooks/useNetworkMismatch.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { useAccount, useChainId } from 'wagmi';
2+
import { defaultChain } from '@/lib/web3/wagmiConfig';
3+
4+
/**
5+
* Detects whether the connected wallet is on a different chain than the
6+
* app's configured default chain.
7+
*
8+
* Returns:
9+
* - `isMismatch` – true only when a wallet is connected AND its chain
10+
* does not match the app's default chain
11+
* - `connectedChainId` – the chain ID reported by the wallet (undefined when
12+
* no wallet is connected)
13+
* - `expectedChainId` – the app's configured default chain ID
14+
* - `expectedChainName` – human-readable name of the expected chain
15+
*/
16+
export function useNetworkMismatch() {
17+
const { isConnected } = useAccount();
18+
const connectedChainId = useChainId();
19+
20+
const expectedChainId = defaultChain.id;
21+
const expectedChainName = defaultChain.name;
22+
23+
const isMismatch = isConnected && connectedChainId !== expectedChainId;
24+
25+
return {
26+
isMismatch,
27+
connectedChainId: isConnected ? connectedChainId : undefined,
28+
expectedChainId,
29+
expectedChainName,
30+
};
31+
}

src/pages/LandingPage.tsx

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import TransactionRetryNotice from '@/components/common/TransactionRetryNotice';
2121
import EmptyTransactionTimelineState from '@/components/common/EmptyTransactionTimelineState';
2222
import TradeDialog, { type TradeSide } from '@/components/common/TradeDialog';
2323
import PendingTxModal from '@/components/common/PendingTxModal';
24+
import NetworkMismatchBanner from '@/components/common/NetworkMismatchBanner';
25+
import { useNetworkMismatch } from '@/hooks/useNetworkMismatch';
2426
import showToast from '@/utils/toast.util';
2527
import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils';
2628

@@ -124,6 +126,7 @@ type SortOption = 'featured' | 'price-asc' | 'price-desc' | 'supply-desc';
124126

125127
function LandingPage() {
126128
const [creators, setCreators] = useState<Course[]>([]);
129+
const { isMismatch: isNetworkMismatch } = useNetworkMismatch();
127130
const [isLoading, setIsLoading] = useState(true);
128131
const [searchQuery, setSearchQuery] = useState('');
129132
const [activeProfileTab, setActiveProfileTab] = useState('overview');
@@ -530,14 +533,22 @@ function LandingPage() {
530533
label="Creator Share Supply"
531534
value={`${formatCompactNumber(250)} shares available`}
532535
/>
536+
{isNetworkMismatch && (
537+
<NetworkMismatchBanner />
538+
)}
533539
<div className="hidden md:flex items-center gap-3">
534-
<Button className="rounded-xl" onClick={() => openTradeDialog('buy')}>
540+
<Button
541+
className="rounded-xl"
542+
onClick={() => openTradeDialog('buy')}
543+
disabled={isNetworkMismatch}
544+
>
535545
Buy
536546
</Button>
537547
<Button
538548
className="rounded-xl"
539549
variant="outline"
540550
onClick={() => openTradeDialog('sell')}
551+
disabled={isNetworkMismatch}
541552
>
542553
Sell
543554
</Button>
@@ -556,14 +567,20 @@ function LandingPage() {
556567
</div>
557568
</div>
558569
<div className="flex items-center gap-2">
559-
<Button className="rounded-xl" size="sm" onClick={() => openTradeDialog('buy')}>
570+
<Button
571+
className="rounded-xl"
572+
size="sm"
573+
onClick={() => openTradeDialog('buy')}
574+
disabled={isNetworkMismatch}
575+
>
560576
Buy
561577
</Button>
562578
<Button
563579
className="rounded-xl"
564580
size="sm"
565581
variant="outline"
566582
onClick={() => openTradeDialog('sell')}
583+
disabled={isNetworkMismatch}
567584
>
568585
Sell
569586
</Button>

0 commit comments

Comments
 (0)