diff --git a/frontend/src/components/NetworkMismatchGuideModal.test.tsx b/frontend/src/components/NetworkMismatchGuideModal.test.tsx new file mode 100644 index 00000000..94af06ad --- /dev/null +++ b/frontend/src/components/NetworkMismatchGuideModal.test.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import NetworkMismatchGuideModal from "./NetworkMismatchGuideModal"; + +function renderModal(overrides: Partial> = {}) { + const onClose = vi.fn(); + const onCheckNow = vi.fn(); + const utils = render( + , + ); + return { ...utils, onClose, onCheckNow }; +} + +describe("NetworkMismatchGuideModal", () => { + it("renders nothing when closed", () => { + renderModal({ isOpen: false }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("renders the guided steps and current/expected networks when open", () => { + renderModal(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + expect(screen.getByText(/switch your wallet's network/i)).toBeInTheDocument(); + expect(screen.getByText(/select testnet from the network list/i)).toBeInTheDocument(); + }); + + it("calls onCheckNow when the check-again button is clicked", () => { + const { onCheckNow } = renderModal(); + fireEvent.click(screen.getByRole("button", { name: /check again/i })); + expect(onCheckNow).toHaveBeenCalledTimes(1); + }); + + it("shows a still-mismatched message after a check that doesn't resolve it", () => { + const { rerender } = renderModal(); + fireEvent.click(screen.getByRole("button", { name: /check again/i })); + + // Simulate the check completing without resolving the mismatch. + rerender( + , + ); + expect(screen.getByText(/still on mainnet/i)).toBeInTheDocument(); + }); + + it("auto-closes once a check confirms the mismatch is resolved", async () => { + const onClose = vi.fn(); + const { rerender } = renderModal({ onClose }); + fireEvent.click(screen.getByRole("button", { name: /check again/i })); + + // Simulate the check completing and finding the mismatch resolved. + rerender( + , + ); + + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + }); + + it("does not auto-close on first render just because isMismatch is already false", () => { + const onClose = vi.fn(); + renderModal({ onClose, isMismatch: false }); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/NetworkMismatchGuideModal.tsx b/frontend/src/components/NetworkMismatchGuideModal.tsx new file mode 100644 index 00000000..8ad760c7 --- /dev/null +++ b/frontend/src/components/NetworkMismatchGuideModal.tsx @@ -0,0 +1,161 @@ +import React, { useEffect, useRef, useState } from "react"; +import { CheckCircle } from "lucide-react"; +import { AlertTriangle, RefreshCw } from "./icons"; +import { Modal } from "./Modal"; +import { useTranslation } from "../i18n"; + +interface NetworkMismatchGuideModalProps { + isOpen: boolean; + onClose: () => void; + isMismatch: boolean; + isChecking: boolean; + walletNetwork: string | null; + expectedNetwork: string; + onCheckNow: () => void; +} + +/** + * Step-by-step guided fix flow for a wallet/app network mismatch. Rendered + * from a "Show me how to fix this" action on the persistent warning banner. + * Closes itself once a recheck confirms the wallet is on the expected + * network, so the user gets a clear "you're all set" moment instead of just + * having the banner silently disappear. + */ +const NetworkMismatchGuideModal: React.FC = ({ + isOpen, + onClose, + isMismatch, + isChecking, + walletNetwork, + expectedNetwork, + onCheckNow, +}) => { + const { t } = useTranslation(); + const [hasChecked, setHasChecked] = useState(false); + const wasOpenRef = useRef(false); + + useEffect(() => { + if (isOpen && !wasOpenRef.current) { + setHasChecked(false); + } + wasOpenRef.current = isOpen; + }, [isOpen]); + + useEffect(() => { + if (isOpen && hasChecked && !isChecking && !isMismatch) { + onClose(); + } + }, [isOpen, hasChecked, isChecking, isMismatch, onClose]); + + if (!isOpen) return null; + + const handleCheckAgain = () => { + setHasChecked(true); + onCheckNow(); + }; + + return ( + +
+
+ +
+ +

+ {t("networkWarning.guide.title")} +

+

+ {t("networkWarning.guide.description") + .replace("{{wallet}}", walletNetwork ?? expectedNetwork) + .replace("{{expected}}", expectedNetwork)} +

+ +
    +
  1. {t("networkWarning.guide.step1")}
  2. +
  3. {t("networkWarning.guide.step2")}
  4. +
  5. {t("networkWarning.guide.step3").replace("{{expected}}", expectedNetwork)}
  6. +
+ + {hasChecked && !isChecking && isMismatch && ( +

+ {t("networkWarning.guide.stillMismatched").replace( + "{{wallet}}", + walletNetwork ?? expectedNetwork, + )} +

+ )} + + {hasChecked && !isChecking && !isMismatch && ( +

+ + {t("networkWarning.guide.resolved").replace("{{expected}}", expectedNetwork)} +

+ )} + + +
+
+ ); +}; + +export default NetworkMismatchGuideModal; diff --git a/frontend/src/components/NetworkWarningBanner.tsx b/frontend/src/components/NetworkWarningBanner.tsx index 066eb048..98f2706c 100644 --- a/frontend/src/components/NetworkWarningBanner.tsx +++ b/frontend/src/components/NetworkWarningBanner.tsx @@ -1,51 +1,83 @@ -import React from "react"; +import React, { useState } from "react"; import { AlertTriangle } from "./icons"; import { useWalletNetwork } from "../hooks/useWalletNetwork"; import { useTranslation } from "../i18n"; +import NetworkMismatchGuideModal from "./NetworkMismatchGuideModal"; interface NetworkWarningBannerProps { walletAddress: string | null; } const NetworkWarningBanner: React.FC = ({ walletAddress }) => { - const { isMismatch, walletNetwork, expectedNetwork } = useWalletNetwork(walletAddress); + const { isMismatch, walletNetwork, expectedNetwork, isChecking, checkNow } = + useWalletNetwork(walletAddress); const { t } = useTranslation(); + const [isGuideOpen, setIsGuideOpen] = useState(false); if (!isMismatch) return null; return ( -
- - - {t("networkWarning.wrongNetwork")}{" "} - {t("networkWarning.walletOn")}{" "} - {walletNetwork},{" "} - {t("networkWarning.appRequires")}{" "} - {expectedNetwork}.{" "} - {t("networkWarning.switchInstructions").replace("{{network}}", expectedNetwork ?? "")} - -
+ <> +
+ + + {t("networkWarning.wrongNetwork")}{" "} + {t("networkWarning.walletOn")}{" "} + {walletNetwork},{" "} + {t("networkWarning.appRequires")}{" "} + {expectedNetwork}.{" "} + {t("networkWarning.switchInstructions").replace("{{network}}", expectedNetwork ?? "")} + + +
+ setIsGuideOpen(false)} + isMismatch={isMismatch} + isChecking={isChecking} + walletNetwork={walletNetwork} + expectedNetwork={expectedNetwork} + onCheckNow={checkNow} + /> + ); }; diff --git a/frontend/src/hooks/useWalletNetwork.test.ts b/frontend/src/hooks/useWalletNetwork.test.ts new file mode 100644 index 00000000..76b6a6d5 --- /dev/null +++ b/frontend/src/hooks/useWalletNetwork.test.ts @@ -0,0 +1,90 @@ +import { renderHook, act, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { useWalletNetwork } from "./useWalletNetwork"; + +const TESTNET_PASSPHRASE = "Test SDF Network ; September 2015"; +const MAINNET_PASSPHRASE = "Public Global Stellar Network ; September 2015"; + +const getNetworkDetails = vi.fn(); + +vi.mock("@stellar/freighter-api", () => ({ + getNetworkDetails: (...args: unknown[]) => getNetworkDetails(...args), +})); + +describe("useWalletNetwork", () => { + beforeEach(() => { + getNetworkDetails.mockReset(); + }); + + it("reports no wallet network and no mismatch when no wallet is connected", () => { + const { result } = renderHook(() => useWalletNetwork(null)); + expect(result.current.walletNetwork).toBeNull(); + expect(result.current.isMismatch).toBe(false); + expect(result.current.expectedNetwork).toBe("Testnet"); + }); + + it("does not flag a mismatch when the wallet is on the expected (testnet) network", async () => { + getNetworkDetails.mockResolvedValue({ networkPassphrase: TESTNET_PASSPHRASE }); + const { result } = renderHook(() => useWalletNetwork("GABC123")); + await waitFor(() => expect(result.current.walletNetwork).toBe("Testnet")); + expect(result.current.isMismatch).toBe(false); + }); + + it("flags a mismatch when the wallet is on mainnet but the app expects testnet", async () => { + getNetworkDetails.mockResolvedValue({ networkPassphrase: MAINNET_PASSPHRASE }); + const { result } = renderHook(() => useWalletNetwork("GABC123")); + await waitFor(() => expect(result.current.walletNetwork).toBe("Mainnet")); + expect(result.current.isMismatch).toBe(true); + expect(result.current.expectedNetwork).toBe("Testnet"); + }); + + it("checkNow re-reads the network immediately, without waiting for the poll interval", async () => { + getNetworkDetails.mockResolvedValue({ networkPassphrase: TESTNET_PASSPHRASE }); + const { result } = renderHook(() => useWalletNetwork("GABC123")); + await waitFor(() => expect(result.current.walletNetwork).toBe("Testnet")); + + getNetworkDetails.mockResolvedValueOnce({ networkPassphrase: MAINNET_PASSPHRASE }); + await act(async () => { + await result.current.checkNow(); + }); + + expect(result.current.walletNetwork).toBe("Mainnet"); + expect(result.current.isMismatch).toBe(true); + }); + + it("exposes isChecking while a manual recheck triggered by checkNow is in flight", async () => { + getNetworkDetails.mockResolvedValue({ networkPassphrase: TESTNET_PASSPHRASE }); + const { result } = renderHook(() => useWalletNetwork("GABC123")); + await waitFor(() => expect(result.current.walletNetwork).toBe("Testnet")); + + let resolveDetails: (value: { networkPassphrase: string }) => void = () => {}; + getNetworkDetails.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveDetails = resolve; + }), + ); + + let checkPromise!: Promise; + act(() => { + checkPromise = result.current.checkNow(); + }); + await waitFor(() => expect(result.current.isChecking).toBe(true)); + + resolveDetails({ networkPassphrase: MAINNET_PASSPHRASE }); + await act(async () => { + await checkPromise; + }); + + expect(result.current.isChecking).toBe(false); + expect(result.current.walletNetwork).toBe("Mainnet"); + }); + + it("does not attempt a recheck when no wallet is connected", async () => { + const { result } = renderHook(() => useWalletNetwork(null)); + await act(async () => { + await result.current.checkNow(); + }); + expect(getNetworkDetails).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/hooks/useWalletNetwork.ts b/frontend/src/hooks/useWalletNetwork.ts index 41cbdd1b..3e774064 100644 --- a/frontend/src/hooks/useWalletNetwork.ts +++ b/frontend/src/hooks/useWalletNetwork.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { networkConfig } from "../config/network"; const POLL_MS = 10_000; @@ -10,6 +10,15 @@ export interface WalletNetworkState { isMismatch: boolean; /** Human-readable label of the network the app expects */ expectedNetwork: string; + /** True while a manually-triggered recheck (via checkNow) is in flight */ + isChecking: boolean; + /** + * Re-reads the wallet's network immediately instead of waiting for the next + * poll tick. Used by the guided fix flow so a user who just switched + * networks in their wallet gets instant feedback instead of up to + * POLL_MS of stale state. + */ + checkNow: () => Promise; } /** @@ -19,39 +28,51 @@ export interface WalletNetworkState { export function useWalletNetwork(walletAddress: string | null): WalletNetworkState { const expectedNetwork = networkConfig.isTestnet ? "Testnet" : "Mainnet"; const [walletNetwork, setWalletNetwork] = useState(null); + const [isChecking, setIsChecking] = useState(false); + const activeRef = useRef(true); + + const poll = useCallback(async () => { + try { + const { getNetworkDetails } = await import("@stellar/freighter-api"); + if (typeof getNetworkDetails !== "function") return; + const details = await getNetworkDetails(); + if (!activeRef.current || !details?.networkPassphrase) return; + const isMainnet = details.networkPassphrase.toLowerCase().includes("public"); + setWalletNetwork(isMainnet ? "Mainnet" : "Testnet"); + } catch { + // leave previous value; don't flash a false mismatch on transient errors + } + }, []); + + const checkNow = useCallback(async () => { + if (!walletAddress) return; + setIsChecking(true); + try { + await poll(); + } finally { + if (activeRef.current) setIsChecking(false); + } + }, [poll, walletAddress]); useEffect(() => { + activeRef.current = true; + if (!walletAddress) { queueMicrotask(() => setWalletNetwork(null)); return; } - let active = true; - - const poll = async () => { - try { - const { getNetworkDetails } = await import("@stellar/freighter-api"); - if (typeof getNetworkDetails !== "function") return; - const details = await getNetworkDetails(); - if (!active || !details?.networkPassphrase) return; - const isMainnet = details.networkPassphrase.toLowerCase().includes("public"); - setWalletNetwork(isMainnet ? "Mainnet" : "Testnet"); - } catch { - // leave previous value; don't flash a false mismatch on transient errors - } - }; - void poll(); const id = window.setInterval(() => void poll(), POLL_MS); return () => { - active = false; + activeRef.current = false; window.clearInterval(id); }; - }, [walletAddress]); + }, [walletAddress, poll]); const isMismatch = walletNetwork !== null && walletNetwork !== expectedNetwork; - return { walletNetwork, isMismatch, expectedNetwork }; + return { walletNetwork, isMismatch, expectedNetwork, isChecking, checkNow }; } diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 4349232a..027ee0de 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -224,6 +224,19 @@ export const en = { walletOn: "your wallet is on", appRequires: "but this app requires", switchInstructions: "Open Freighter → Settings → Network and switch to {{network}} to continue.", + fixNow: "Show me how to fix this", + guide: { + title: "Switch your wallet's network", + description: "Your wallet is on {{wallet}}, but this app needs {{expected}}. Follow these steps in Freighter, then check again.", + step1: "Open the Freighter extension.", + step2: "Go to Settings → Network.", + step3: "Select {{expected}} from the network list.", + checkAgain: "I've switched — check again", + checking: "Checking…", + stillMismatched: "Still on {{wallet}}. Double-check the steps above, then try again.", + resolved: "You're on {{expected}} now — you're all set!", + close: "Close", + }, }, accountStatement: { trigger: "Export Statement", diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index 3da96b49..2ae4ad44 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -212,6 +212,19 @@ export const es = { walletOn: "tu billetera está en", appRequires: "pero esta app requiere", switchInstructions: "Abre Freighter → Configuración → Red y cambia a {{network}} para continuar.", + fixNow: "Muéstrame cómo solucionarlo", + guide: { + title: "Cambia la red de tu billetera", + description: "Tu billetera está en {{wallet}}, pero esta app necesita {{expected}}. Sigue estos pasos en Freighter y luego vuelve a verificar.", + step1: "Abre la extensión de Freighter.", + step2: "Ve a Configuración → Red.", + step3: "Selecciona {{expected}} en la lista de redes.", + checkAgain: "Ya cambié — verificar de nuevo", + checking: "Verificando…", + stillMismatched: "Sigues en {{wallet}}. Revisa los pasos anteriores e inténtalo de nuevo.", + resolved: "Ahora estás en {{expected}} — ¡todo listo!", + close: "Cerrar", + }, }, accountStatement: { trigger: "Exportar extracto",