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
25 changes: 24 additions & 1 deletion src/components/common/CreatorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 {
Expand All @@ -31,6 +33,7 @@ interface CreatorCardProps {

const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
const { isConnected } = useAccount();
const { isMismatch: isNetworkMismatch, expectedChainName } = useNetworkMismatch();
const [transactionState, setTransactionState] = useState<
'idle' | 'submitting' | 'failed' | 'success'
>('idle');
Expand Down Expand Up @@ -91,6 +94,13 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ 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,
});
Expand Down Expand Up @@ -221,6 +231,7 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ 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'
Expand All @@ -241,10 +252,22 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
</AsyncButton>
</div>

<BuyActionHelperText state={transactionState} className="mt-4" />
<BuyActionHelperText
state={transactionState}
className="mt-4"
disabledReason={
isNetworkMismatch
? `Switch to ${expectedChainName} to enable purchases.`
: undefined
}
/>

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

{isConnected && isNetworkMismatch && (
<NetworkMismatchBanner className="mt-4" />
)}

{transactionState === 'failed' && (
<TransactionRetryNotice
className="mt-4"
Expand Down
59 changes: 59 additions & 0 deletions src/components/common/NetworkMismatchBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { AlertTriangle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useNetworkMismatch } from '@/hooks/useNetworkMismatch';

interface NetworkMismatchBannerProps {
className?: string;
}

/**
* Displays a warning banner when the connected wallet is on a different
* network than the app's configured default chain.
*
* Renders nothing when there is no mismatch or no wallet is connected,
* so it is safe to place unconditionally in any layout.
*/
const NetworkMismatchBanner: React.FC<NetworkMismatchBannerProps> = ({
className,
}) => {
const { isMismatch, expectedChainName } = useNetworkMismatch();

if (!isMismatch) {
return null;
}

return (
<div
role="alert"
aria-live="polite"
className={cn(
'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',
className
)}
>
<div className="flex items-start gap-3">
<div className="mt-0.5 shrink-0 rounded-full bg-red-500/15 p-2 text-red-300">
<AlertTriangle className="size-4" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 inline-flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.18em] text-red-300/85">
Network mismatch
</div>
<p className="font-jakarta text-sm font-bold text-red-100">
Wrong network detected
</p>
<p className="mt-1 text-xs text-red-100/75">
Your wallet is connected to an unsupported network. Switch to{' '}
<span className="font-semibold text-red-100">
{expectedChainName}
</span>{' '}
to enable trade actions. You can still browse the marketplace in
read-only mode.
</p>
</div>
</div>
</div>
);
};

export default NetworkMismatchBanner;
67 changes: 67 additions & 0 deletions src/hooks/__tests__/useNetworkMismatch.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof useAccount>);
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<typeof useAccount>);
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<typeof useAccount>);
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<typeof useAccount>);
mockUseChainId.mockReturnValue(84532);

const { result } = renderHook(() => useNetworkMismatch());

expect(result.current.expectedChainId).toBe(84532);
expect(result.current.expectedChainName).toBe('Base Sepolia');
});
});
31 changes: 31 additions & 0 deletions src/hooks/useNetworkMismatch.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
21 changes: 19 additions & 2 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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

function LandingPage() {
const [creators, setCreators] = useState<Course[]>([]);
const { isMismatch: isNetworkMismatch } = useNetworkMismatch();
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [activeProfileTab, setActiveProfileTab] = useState('overview');
Expand Down Expand Up @@ -530,14 +533,22 @@ function LandingPage() {
label="Creator Share Supply"
value={`${formatCompactNumber(250)} shares available`}
/>
{isNetworkMismatch && (
<NetworkMismatchBanner />
)}
<div className="hidden md:flex items-center gap-3">
<Button className="rounded-xl" onClick={() => openTradeDialog('buy')}>
<Button
className="rounded-xl"
onClick={() => openTradeDialog('buy')}
disabled={isNetworkMismatch}
>
Buy
</Button>
<Button
className="rounded-xl"
variant="outline"
onClick={() => openTradeDialog('sell')}
disabled={isNetworkMismatch}
>
Sell
</Button>
Expand All @@ -556,14 +567,20 @@ function LandingPage() {
</div>
</div>
<div className="flex items-center gap-2">
<Button className="rounded-xl" size="sm" onClick={() => openTradeDialog('buy')}>
<Button
className="rounded-xl"
size="sm"
onClick={() => openTradeDialog('buy')}
disabled={isNetworkMismatch}
>
Buy
</Button>
<Button
className="rounded-xl"
size="sm"
variant="outline"
onClick={() => openTradeDialog('sell')}
disabled={isNetworkMismatch}
>
Sell
</Button>
Expand Down
Loading