diff --git a/src/components/common/CreatorCard.tsx b/src/components/common/CreatorCard.tsx index 69d2c451..67ffead4 100644 --- a/src/components/common/CreatorCard.tsx +++ b/src/components/common/CreatorCard.tsx @@ -13,6 +13,7 @@ import CardMetaRow from '@/components/common/CardMetaRow'; import VerifiedBadge from '@/components/common/VerifiedBadge'; import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar'; import WalletConnectCalloutBanner from '@/components/common/WalletConnectCalloutBanner'; +import NetworkMismatchBanner from '@/components/common/NetworkMismatchBanner'; import CreatorSocialLinksList from '@/components/common/CreatorSocialLinksList'; import TransactionStatusIcon from '@/components/common/TransactionStatusIcon'; import MiniStatChip from '@/components/common/MiniStatChip'; @@ -22,6 +23,7 @@ import CreatorListRowDivider from '@/components/common/CreatorListRowDivider'; import BuyActionHelperText from '@/components/common/BuyActionHelperText'; import CreatorLabeledStatRow from '@/components/common/CreatorLabeledStatRow'; import { useTransactionTelemetry } from '@/hooks/useTransactionTelemetry'; +import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; interface CreatorCardProps { @@ -31,6 +33,7 @@ interface CreatorCardProps { const CreatorCard: React.FC = ({ creator, className }) => { const { isConnected } = useAccount(); + const { isMismatch: isNetworkMismatch, expectedChainName } = useNetworkMismatch(); const [transactionState, setTransactionState] = useState< 'idle' | 'submitting' | 'failed' | 'success' >('idle'); @@ -91,6 +94,13 @@ const CreatorCard: React.FC = ({ creator, className }) => { return; } + if (isNetworkMismatch) { + toast.error(`Switch to ${expectedChainName} to purchase keys`, { + duration: 4000, + }); + return; + } + toast.success(`Purchasing keys for ${creator.title}...`, { duration: 3000, }); @@ -221,6 +231,7 @@ const CreatorCard: React.FC = ({ creator, className }) => { size="sm" isPending={transactionState === 'submitting'} pendingText="Processing..." + disabled={isNetworkMismatch} className={cn( 'rounded-xl font-bold', !isConnected && 'border-white/10 hover:bg-white/5' @@ -241,10 +252,22 @@ const CreatorCard: React.FC = ({ creator, className }) => { - + {!isConnected && } + {isConnected && isNetworkMismatch && ( + + )} + {transactionState === 'failed' && ( = ({ + className, +}) => { + const { isMismatch, expectedChainName } = useNetworkMismatch(); + + if (!isMismatch) { + return null; + } + + return ( +
+
+
+
+
+
+ Network mismatch +
+

+ Wrong network detected +

+

+ Your wallet is connected to an unsupported network. Switch to{' '} + + {expectedChainName} + {' '} + to enable trade actions. You can still browse the marketplace in + read-only mode. +

+
+
+
+ ); +}; + +export default NetworkMismatchBanner; diff --git a/src/hooks/__tests__/useNetworkMismatch.test.ts b/src/hooks/__tests__/useNetworkMismatch.test.ts new file mode 100644 index 00000000..8069263b --- /dev/null +++ b/src/hooks/__tests__/useNetworkMismatch.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; + +// ── wagmi mocks ────────────────────────────────────────────────────────────── +vi.mock('wagmi', () => ({ + useAccount: vi.fn(), + useChainId: vi.fn(), +})); + +// ── wagmiConfig mock (provides defaultChain) ───────────────────────────────── +vi.mock('@/lib/web3/wagmiConfig', () => ({ + defaultChain: { id: 84532, name: 'Base Sepolia' }, +})); + +import { useAccount, useChainId } from 'wagmi'; + +const mockUseAccount = vi.mocked(useAccount); +const mockUseChainId = vi.mocked(useChainId); + +// ───────────────────────────────────────────────────────────────────────────── + +describe('useNetworkMismatch', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns isMismatch=false when no wallet is connected', () => { + mockUseAccount.mockReturnValue({ isConnected: false } as ReturnType); + mockUseChainId.mockReturnValue(1); // mainnet – irrelevant when disconnected + + const { result } = renderHook(() => useNetworkMismatch()); + + expect(result.current.isMismatch).toBe(false); + expect(result.current.connectedChainId).toBeUndefined(); + }); + + it('returns isMismatch=false when wallet is on the expected chain', () => { + mockUseAccount.mockReturnValue({ isConnected: true } as ReturnType); + mockUseChainId.mockReturnValue(84532); // matches defaultChain + + const { result } = renderHook(() => useNetworkMismatch()); + + expect(result.current.isMismatch).toBe(false); + expect(result.current.connectedChainId).toBe(84532); + }); + + it('returns isMismatch=true when wallet is on a different chain', () => { + mockUseAccount.mockReturnValue({ isConnected: true } as ReturnType); + mockUseChainId.mockReturnValue(1); // mainnet ≠ Base Sepolia + + const { result } = renderHook(() => useNetworkMismatch()); + + expect(result.current.isMismatch).toBe(true); + expect(result.current.connectedChainId).toBe(1); + }); + + it('always exposes the expected chain id and name', () => { + mockUseAccount.mockReturnValue({ isConnected: false } as ReturnType); + mockUseChainId.mockReturnValue(84532); + + const { result } = renderHook(() => useNetworkMismatch()); + + expect(result.current.expectedChainId).toBe(84532); + expect(result.current.expectedChainName).toBe('Base Sepolia'); + }); +}); diff --git a/src/hooks/useNetworkMismatch.ts b/src/hooks/useNetworkMismatch.ts new file mode 100644 index 00000000..5eea2964 --- /dev/null +++ b/src/hooks/useNetworkMismatch.ts @@ -0,0 +1,31 @@ +import { useAccount, useChainId } from 'wagmi'; +import { defaultChain } from '@/lib/web3/wagmiConfig'; + +/** + * Detects whether the connected wallet is on a different chain than the + * app's configured default chain. + * + * Returns: + * - `isMismatch` – true only when a wallet is connected AND its chain + * does not match the app's default chain + * - `connectedChainId` – the chain ID reported by the wallet (undefined when + * no wallet is connected) + * - `expectedChainId` – the app's configured default chain ID + * - `expectedChainName` – human-readable name of the expected chain + */ +export function useNetworkMismatch() { + const { isConnected } = useAccount(); + const connectedChainId = useChainId(); + + const expectedChainId = defaultChain.id; + const expectedChainName = defaultChain.name; + + const isMismatch = isConnected && connectedChainId !== expectedChainId; + + return { + isMismatch, + connectedChainId: isConnected ? connectedChainId : undefined, + expectedChainId, + expectedChainName, + }; +} diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx index 128f5859..be3210de 100644 --- a/src/pages/LandingPage.tsx +++ b/src/pages/LandingPage.tsx @@ -21,6 +21,8 @@ import TransactionRetryNotice from '@/components/common/TransactionRetryNotice'; import EmptyTransactionTimelineState from '@/components/common/EmptyTransactionTimelineState'; import TradeDialog, { type TradeSide } from '@/components/common/TradeDialog'; import PendingTxModal from '@/components/common/PendingTxModal'; +import NetworkMismatchBanner from '@/components/common/NetworkMismatchBanner'; +import { useNetworkMismatch } from '@/hooks/useNetworkMismatch'; import showToast from '@/utils/toast.util'; import { formatCompactNumber, formatNumber } from '@/utils/numberFormat.utils'; @@ -124,6 +126,7 @@ type SortOption = 'featured' | 'price-asc' | 'price-desc' | 'supply-desc'; function LandingPage() { const [creators, setCreators] = useState([]); + const { isMismatch: isNetworkMismatch } = useNetworkMismatch(); const [isLoading, setIsLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [activeProfileTab, setActiveProfileTab] = useState('overview'); @@ -530,14 +533,22 @@ function LandingPage() { label="Creator Share Supply" value={`${formatCompactNumber(250)} shares available`} /> + {isNetworkMismatch && ( + + )}
- @@ -556,7 +567,12 @@ function LandingPage() {
-