diff --git a/frontend/src/app/settings/__tests__/settings-content.test.tsx b/frontend/src/app/settings/__tests__/settings-content.test.tsx
new file mode 100644
index 00000000..e32d6244
--- /dev/null
+++ b/frontend/src/app/settings/__tests__/settings-content.test.tsx
@@ -0,0 +1,192 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, act, fireEvent } from "@testing-library/react";
+import React from "react";
+
+// ─── Mocks ──────────────────────────────────────────────────────────────
+
+const push = vi.fn();
+const disconnect = vi.fn();
+
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push }),
+}));
+
+vi.mock("react-hot-toast", () => ({
+ default: { success: vi.fn(), error: vi.fn(), loading: vi.fn() },
+}));
+
+vi.mock("@/context/wallet-context", () => ({
+ useWallet: () => ({
+ session: {
+ publicKey: "GAV4A377RAEV6YVAWZVHXF4VZD5ZBXGIKEMNHV5YIMV5LIKSNQVYUBR7",
+ network: "TESTNET",
+ walletName: "Freighter",
+ },
+ disconnect,
+ isHydrated: true,
+ }),
+}));
+
+vi.mock("@/lib/wallet", () => ({
+ shortenPublicKey: (key: string) => `${key.slice(0, 4)}...${key.slice(-4)}`,
+ formatNetwork: (n: string) => n,
+ STELLAR_NETWORK: "TESTNET",
+}));
+
+vi.mock("@/lib/api/_shared", () => ({
+ getApiBaseUrl: () => "http://localhost:4000",
+}));
+
+import SettingsContent from "../settings-content";
+
+function getThemeButtons(): HTMLElement[] {
+ const buttons = screen.getAllByRole("button");
+ return buttons.filter(
+ (b) => b.textContent === "Light" || b.textContent === "Dark" || b.textContent === "System"
+ );
+}
+
+function getCurrencySelect(): HTMLSelectElement | null {
+ return screen.queryByLabelText(/default token/i) as HTMLSelectElement | null;
+}
+
+describe("SettingsContent dirty-state detection", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Set default localStorage values
+ localStorage.clear();
+ localStorage.setItem("flowfi-theme", "dark");
+ localStorage.setItem("flowfi-currency", "USD");
+ localStorage.setItem("flowfi-amount-format", "full");
+ localStorage.setItem("flowfi-decimal-places", "7");
+ vi.spyOn(window, "confirm").mockReturnValue(false);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("starts clean (not dirty) on initial render", () => {
+ render();
+
+ // Navigate away without changes — should proceed without confirmation
+ act(() => {
+ fireEvent.click(screen.getByText(/connect wallet/i));
+ });
+
+ expect(push).toHaveBeenCalledWith("/");
+ });
+
+ it("detects dirty state when theme is changed", () => {
+ render();
+
+ const themeButtons = getThemeButtons();
+ const lightButton = themeButtons.find((b) => b.textContent === "Light");
+ expect(lightButton).toBeDefined();
+
+ // Change theme to Light (different from initial "dark")
+ act(() => {
+ fireEvent.click(lightButton!);
+ });
+
+ // Try to navigate away — confirm should fire (mock returns false = declined)
+ act(() => {
+ fireEvent.click(screen.getByText(/connect wallet/i));
+ });
+
+ expect(window.confirm).toHaveBeenCalled();
+ expect(push).not.toHaveBeenCalled(); // Declined navigation
+ });
+
+ it("detects dirty state when display currency is changed", () => {
+ // Accept the confirm dialog
+ vi.mocked(window.confirm).mockReturnValue(true);
+
+ render();
+
+ const select = getCurrencySelect();
+ expect(select).not.toBeNull();
+
+ // Change currency value
+ act(() => {
+ fireEvent.change(select!, { target: { value: "XLM" } });
+ });
+
+ // Try to navigate away — confirm should fire
+ act(() => {
+ fireEvent.click(screen.getByText(/connect wallet/i));
+ });
+
+ expect(window.confirm).toHaveBeenCalled();
+ expect(push).toHaveBeenCalled(); // Accepted navigation
+ });
+
+ it("detects dirty state when amount format is changed", () => {
+ render();
+
+ // Find the amount format buttons
+ const allButtons = screen.getAllByRole("button");
+ const compactButton = allButtons.find((b) => b.textContent?.includes("Compact"));
+ expect(compactButton).toBeDefined();
+
+ act(() => {
+ fireEvent.click(compactButton!);
+ });
+
+ // Navigate with the disconnect button
+ const disconnectBtn = screen.getByText(/disconnect wallet/i);
+ act(() => {
+ fireEvent.click(disconnectBtn);
+ });
+
+ expect(window.confirm).toHaveBeenCalled();
+ });
+
+ it("detects dirty state when decimal places is changed", () => {
+ render();
+
+ // Find the decimal places buttons
+ const allButtons = screen.getAllByRole("button");
+ const fourDecimalsBtn = allButtons.find((b) => b.textContent?.includes("4 decimals"));
+ expect(fourDecimalsBtn).toBeDefined();
+
+ act(() => {
+ fireEvent.click(fourDecimalsBtn!);
+ });
+
+ // Navigate with the disconnect button
+ const disconnectBtn = screen.getByText(/disconnect wallet/i);
+ act(() => {
+ fireEvent.click(disconnectBtn);
+ });
+
+ expect(window.confirm).toHaveBeenCalled();
+ });
+
+ it("does not show confirm when no changes were made", () => {
+ render();
+
+ // Navigate away with disconnect — no changes made
+ const disconnectBtn = screen.getByText(/disconnect wallet/i);
+ act(() => {
+ fireEvent.click(disconnectBtn);
+ });
+
+ // Should NOT show confirm
+ expect(window.confirm).not.toHaveBeenCalled();
+ expect(disconnect).toHaveBeenCalled();
+ });
+
+ it("restores dirty state to clean after disconnect", () => {
+ render();
+
+ // No changes were made, so disconnect should work without confirm
+ const disconnectBtn = screen.getByText(/disconnect wallet/i);
+ act(() => {
+ fireEvent.click(disconnectBtn);
+ });
+
+ expect(window.confirm).not.toHaveBeenCalled();
+ expect(disconnect).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/frontend/src/app/settings/settings-content.tsx b/frontend/src/app/settings/settings-content.tsx
index 5483dd9b..7d62e751 100644
--- a/frontend/src/app/settings/settings-content.tsx
+++ b/frontend/src/app/settings/settings-content.tsx
@@ -1,11 +1,10 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { Copy, Check, LogOut, Moon, Sun, Bell, Globe } from "lucide-react";
import { STELLAR_NETWORK, shortenPublicKey } from "@/lib/wallet";
import { useWallet } from "@/context/wallet-context";
import { useRouter } from "next/navigation";
-import Link from "next/link";
import { formatNetwork } from "@/lib/wallet";
import toast from "react-hot-toast";
import { getApiBaseUrl } from "@/lib/api/_shared";
@@ -18,6 +17,12 @@ const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "1.0.0";
const CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_STREAMING_CONTRACT || "CDV4K...7ZQY";
const INDEXER_URL = `${getApiBaseUrl()}/v1`;
+function useDirtyTracker(initial: Record, current: Record): boolean {
+ return useMemo(() => {
+ return Object.keys(initial).some((key) => initial[key] !== current[key]);
+ }, [initial, current]);
+}
+
export default function SettingsContent() {
const router = useRouter();
const { session, disconnect, isHydrated } = useWallet();
@@ -64,6 +69,51 @@ export default function SettingsContent() {
const [copied, setCopied] = useState(false);
+ // ── Track initial values for dirty detection ──────────────────────────
+ const initialValuesRef = useRef({
+ browserPush: false,
+ theme: theme as string,
+ displayCurrency: displayCurrency as string,
+ amountFormat: amountFormat as string,
+ decimalPlaces: decimalPlaces as number,
+ });
+
+ const currentValues = {
+ browserPush,
+ theme,
+ displayCurrency,
+ amountFormat,
+ decimalPlaces,
+ };
+
+ const isDirty = useDirtyTracker(initialValuesRef.current, currentValues);
+
+ // ── beforeunload handler for tab close / refresh / back ───────────────
+ useEffect(() => {
+ if (!isDirty) return;
+
+ const handleBeforeUnload = (e: BeforeUnloadEvent) => {
+ e.preventDefault();
+ e.returnValue = "";
+ };
+
+ window.addEventListener("beforeunload", handleBeforeUnload);
+ return () => window.removeEventListener("beforeunload", handleBeforeUnload);
+ }, [isDirty]);
+
+ // ── Confirm before internal navigation ────────────────────────────────
+ const navigateWithConfirm = useCallback(
+ (path: string) => {
+ if (isDirty) {
+ const confirmed = window.confirm(
+ "You have unsaved changes. Are you sure you want to leave?"
+ );
+ if (!confirmed) return;
+ }
+ router.push(path);
+ },[isDirty, router]
+ );
+
const toggleTheme = (newTheme: "light" | "dark" | "system") => {
setTheme(newTheme);
localStorage.setItem("flowfi-theme", newTheme);
@@ -75,6 +125,19 @@ export default function SettingsContent() {
}
};
+ // ── Confirm-aware disconnect ──────────────────────────────────────────
+ const handleDisconnectWithConfirm = useCallback(() => {
+ if (isDirty) {
+ const confirmed = window.confirm(
+ "You have unsaved changes. Are you sure you want to disconnect?"
+ );
+ if (!confirmed) return;
+ }
+ disconnect();
+ toast.success("Wallet disconnected");
+ router.push("/");
+ }, [isDirty, disconnect, router]);
+
const copyAddress = async () => {
if (session?.publicKey) {
await navigator.clipboard.writeText(session.publicKey);
@@ -84,13 +147,10 @@ export default function SettingsContent() {
}
};
- const handleDisconnect = () => {
- disconnect();
- toast.success("Wallet disconnected");
- router.push("/");
- };
+
const handleBrowserPushToggle = async () => {
+ // toggling notifications is tracked by isDirty via browserPush state
if (!browserPush) {
try {
await Notification.requestPermission();
@@ -360,9 +420,12 @@ export default function SettingsContent() {
Not connected
-
+
)}
@@ -422,7 +485,7 @@ export default function SettingsContent() {
{/* Disconnect */}
{session && (