From 187713b1c4abbabc1de9b5d2d644a83e0f2d1cc4 Mon Sep 17 00:00:00 2001 From: mikewinnergodsfavour-prog Date: Sun, 25 Oct 2026 17:35:46 +0100 Subject: [PATCH] fix(web): remove cross-vault fallback and dead UI state --- apps/web/messages/en.json | 5 +++ apps/web/messages/fr.json | 5 +++ .../components/AdminActionHistory.test.tsx | 13 +----- .../src/__tests__/components/Toasts.test.tsx | 44 ++++++++++++++++++- .../components/WalletConnect.test.tsx | 33 +++++++++++++- .../__tests__/hooks/useVaultActions.test.ts | 22 ++++++++++ apps/web/src/__tests__/store/wallet.test.ts | 5 --- .../dashboard/AdminActionHistory.tsx | 10 +---- .../components/onboarding/WalletConnect.tsx | 11 ++++- apps/web/src/components/ui/Toasts.tsx | 30 +++++++------ apps/web/src/hooks/useVaultActions.ts | 8 ++-- apps/web/src/pages/AdminDashboard.tsx | 1 - apps/web/src/pages/StatusPage.tsx | 1 - apps/web/src/store/wallet.ts | 2 - 14 files changed, 141 insertions(+), 49 deletions(-) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index afaf14b3..40b0ad18 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -3,6 +3,11 @@ "title": "Meridian" }, + "toasts": { + "notifications": "Notifications", + "dismiss": "Dismiss {{kind}} notification" + }, + "walletConnect": { "disconnect": "Disconnect", "copyAddress": "Copy Address", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 2a14c387..bb32e53d 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -3,6 +3,11 @@ "title": "Meridian" }, + "toasts": { + "notifications": "Notifications", + "dismiss": "Fermer la notification {{kind}}" + }, + "walletConnect": { "disconnect": "Déconnecter", "copyAddress": "Copier l'adresse", diff --git a/apps/web/src/__tests__/components/AdminActionHistory.test.tsx b/apps/web/src/__tests__/components/AdminActionHistory.test.tsx index fd49dfe1..73d632fa 100644 --- a/apps/web/src/__tests__/components/AdminActionHistory.test.tsx +++ b/apps/web/src/__tests__/components/AdminActionHistory.test.tsx @@ -48,7 +48,7 @@ function mockActions(actions: ReturnType[]) { // Real catalogues so the assertions cover the shipped en.json / fr.json. function renderHistory( lng: "en" | "fr" = "en", - props: { isAdmin?: boolean; network?: "testnet" | "mainnet" } = {} + props: { network?: "testnet" | "mainnet" } = {} ) { const i18n = createInstance(); void i18n.init({ @@ -63,7 +63,6 @@ function renderHistory( ); @@ -78,15 +77,7 @@ beforeEach(() => { afterEach(() => vi.useRealTimers()); describe("AdminActionHistory", () => { - it("renders nothing and skips the fetch when the user is not an admin", () => { - mockActions([action()]); - const { container } = renderHistory("en", { isAdmin: false }); - - expect(container.firstChild).toBeNull(); - expect(useAdminHistory).toHaveBeenCalledWith(null); - }); - - it("requests history for the vault when the user is an admin", () => { + it("requests history for the vault", () => { mockActions([]); renderHistory(); diff --git a/apps/web/src/__tests__/components/Toasts.test.tsx b/apps/web/src/__tests__/components/Toasts.test.tsx index c93cc10d..0a1fe0f0 100644 --- a/apps/web/src/__tests__/components/Toasts.test.tsx +++ b/apps/web/src/__tests__/components/Toasts.test.tsx @@ -1,12 +1,34 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { act, fireEvent, render, screen } from "@testing-library/react"; +import { createInstance } from "i18next"; +import { I18nextProvider } from "react-i18next"; import { Toasts } from "../../components/ui/Toasts"; import { TOAST_DURATION_MS, useToastStore } from "../../store/toast"; +import en from "../../../messages/en.json"; +import fr from "../../../messages/fr.json"; + +function renderToasts(lng: "en" | "fr" = "en") { + const i18n = createInstance(); + void i18n.init({ + resources: { en: { translation: en }, fr: { translation: fr } }, + lng, + fallbackLng: "en", + initAsync: false, + interpolation: { escapeValue: false }, + }); + return render( + + + + ); +} + +let unmountToasts: () => void; beforeEach(() => { vi.useFakeTimers(); useToastStore.setState({ toasts: [] }); - render(); + unmountToasts = renderToasts().unmount; }); afterEach(() => vi.useRealTimers()); @@ -22,6 +44,26 @@ function pushToast(kind: "success" | "error" | "info", message: string) { } describe("Toasts", () => { + it("labels the region and dismiss buttons from the catalogue", () => { + expect(screen.getByRole("status", { name: "Notifications" })).toBeDefined(); + pushToast("info", "Network switched"); + expect( + screen.getByRole("button", { name: "Dismiss info notification" }) + ).toBeDefined(); + }); + + it("uses the French catalogue for the dismiss label", () => { + unmountToasts(); + renderToasts("fr"); + pushToast("info", "Réseau changé"); + + expect( + screen.getByRole("button", { + name: "Fermer la notification info", + }) + ).toBeDefined(); + }); + it("dismisses on its own when left alone", () => { pushToast("success", "Deposited"); advance(TOAST_DURATION_MS.success); diff --git a/apps/web/src/__tests__/components/WalletConnect.test.tsx b/apps/web/src/__tests__/components/WalletConnect.test.tsx index 86b359f7..59893e0e 100644 --- a/apps/web/src/__tests__/components/WalletConnect.test.tsx +++ b/apps/web/src/__tests__/components/WalletConnect.test.tsx @@ -1,5 +1,11 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { + act, + render, + screen, + fireEvent, + waitFor, +} from "@testing-library/react"; import { WalletConnect } from "../../components/onboarding/WalletConnect"; import { useWalletStore } from "../../store/wallet"; import { useToastStore } from "../../store/toast"; @@ -367,4 +373,29 @@ describe("WalletConnect — copy address", () => { vi.unstubAllGlobals(); }); + + it("clears the copied reset timer on unmount", async () => { + vi.useFakeTimers(); + useWalletStore.setState({ + publicKey: "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + connected: true, + }); + vi.stubGlobal("navigator", { + clipboard: { writeText: vi.fn(async () => {}) }, + }); + const clearSpy = vi.spyOn(globalThis, "clearTimeout"); + const { unmount } = render(); + + fireEvent.click(screen.getByLabelText("walletConnect.copyAddress")); + await act(async () => { + await Promise.resolve(); + }); + expect(screen.getByLabelText("walletConnect.copied")).toBeDefined(); + + unmount(); + expect(clearSpy).toHaveBeenCalled(); + + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); }); diff --git a/apps/web/src/__tests__/hooks/useVaultActions.test.ts b/apps/web/src/__tests__/hooks/useVaultActions.test.ts index 82948ada..abcf3eec 100644 --- a/apps/web/src/__tests__/hooks/useVaultActions.test.ts +++ b/apps/web/src/__tests__/hooks/useVaultActions.test.ts @@ -453,6 +453,28 @@ describe("useVaultActions — fresh vault state slippage", () => { }); }); + it("does not price a withdrawal against another vault's cached position", async () => { + getQueryData.mockImplementation((key: unknown[]) => + key[0] === "positions" + ? [{ vaultId: "other-vault", shares: 99, deposited: 1, earned: 0 }] + : undefined + ); + const { result } = renderHook(() => useVaultActions()); + + let ok: boolean | undefined; + await act(async () => { + ok = await result.current.withdraw("10", "blend-usdc-fixed", "USDC"); + }); + + expect(ok).toBe(true); + expect(api.buildWithdraw).toHaveBeenCalledWith({ + walletAddress: KEY, + vaultId: "blend-usdc-fixed", + shares: "10", + min_usdc_out: "17.9100000", + }); + }); + it("keeps an explicit minSharesOut override without re-fetching price math", async () => { const { result } = renderHook(() => useVaultActions()); diff --git a/apps/web/src/__tests__/store/wallet.test.ts b/apps/web/src/__tests__/store/wallet.test.ts index ede6513a..98afc304 100644 --- a/apps/web/src/__tests__/store/wallet.test.ts +++ b/apps/web/src/__tests__/store/wallet.test.ts @@ -41,11 +41,6 @@ describe("useWalletStore", () => { }); }); - it("setNetwork updates the active network", () => { - useWalletStore.getState().setNetwork("mainnet"); - expect(useWalletStore.getState().network).toBe("mainnet"); - }); - it("revalidate does nothing when no publicKey is set", async () => { await useWalletStore.getState().revalidate(); expect(wallet.isAuthorized).not.toHaveBeenCalled(); diff --git a/apps/web/src/components/dashboard/AdminActionHistory.tsx b/apps/web/src/components/dashboard/AdminActionHistory.tsx index b18efda2..0b44aba9 100644 --- a/apps/web/src/components/dashboard/AdminActionHistory.tsx +++ b/apps/web/src/components/dashboard/AdminActionHistory.tsx @@ -53,22 +53,14 @@ function formatTimestamp(iso: string, t: TFunction, locale: string): string { interface AdminActionHistoryProps { vaultId: string; network: "testnet" | "mainnet"; - isAdmin?: boolean; } export function AdminActionHistory({ vaultId, network, - isAdmin, }: AdminActionHistoryProps) { const { t, i18n } = useTranslation(); - const { data, isLoading, isError } = useAdminHistory( - isAdmin ? vaultId : null - ); - - if (!isAdmin) { - return null; - } + const { data, isLoading, isError } = useAdminHistory(vaultId); const actions = data?.actions ?? []; diff --git a/apps/web/src/components/onboarding/WalletConnect.tsx b/apps/web/src/components/onboarding/WalletConnect.tsx index 2a533a3f..821de687 100644 --- a/apps/web/src/components/onboarding/WalletConnect.tsx +++ b/apps/web/src/components/onboarding/WalletConnect.tsx @@ -20,6 +20,7 @@ export function WalletConnect() { cancelRiskDisclosure, } = useWalletConnect(); const [copied, setCopied] = useState(false); + const copiedTimeoutRef = useRef | null>(null); const [pickerOpen, setPickerOpen] = useState(false); const [installedById, setInstalledById] = useState< Partial> @@ -31,6 +32,13 @@ export function WalletConnect() { // ArrowDown, the last for ArrowUp (WAI-ARIA menu button pattern). const openFocusRef = useRef<"first" | "last">("first"); + useEffect( + () => () => { + if (copiedTimeoutRef.current) clearTimeout(copiedTimeoutRef.current); + }, + [] + ); + // Refreshed on every open rather than once on mount: extension install // state can change between opens without a page reload. useEffect(() => { @@ -71,7 +79,8 @@ export function WalletConnect() { try { await navigator.clipboard.writeText(publicKey); setCopied(true); - setTimeout(() => setCopied(false), 2000); + if (copiedTimeoutRef.current) clearTimeout(copiedTimeoutRef.current); + copiedTimeoutRef.current = setTimeout(() => setCopied(false), 2000); } catch { push("error", t("walletConnect.copyFailed")); } diff --git a/apps/web/src/components/ui/Toasts.tsx b/apps/web/src/components/ui/Toasts.tsx index c624ab95..3c09c68f 100644 --- a/apps/web/src/components/ui/Toasts.tsx +++ b/apps/web/src/components/ui/Toasts.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { useToastStore } from "../../store/toast"; const ICON: Record = { @@ -14,6 +15,7 @@ const STYLES: Record = { export function Toasts() { const { toasts, dismiss, pauseTimer, resumeTimer } = useToastStore(); + const { t } = useTranslation(); return (
- {toasts.map((t) => ( + {toasts.map((toast) => (
pauseTimer(t.id)} - onMouseLeave={() => resumeTimer(t.id)} - onFocus={() => pauseTimer(t.id)} - onBlur={() => resumeTimer(t.id)} - className={`flex items-center gap-3 rounded-xl border px-4 py-3 text-sm shadow-lg backdrop-blur-sm animate-in fade-in slide-in-from-right-4 duration-200 ${STYLES[t.kind]}`} + onMouseEnter={() => pauseTimer(toast.id)} + onMouseLeave={() => resumeTimer(toast.id)} + onFocus={() => pauseTimer(toast.id)} + onBlur={() => resumeTimer(toast.id)} + className={`flex items-center gap-3 rounded-xl border px-4 py-3 text-sm shadow-lg backdrop-blur-sm animate-in fade-in slide-in-from-right-4 duration-200 ${STYLES[toast.kind]}`} > - {t.message} + {toast.message} diff --git a/apps/web/src/hooks/useVaultActions.ts b/apps/web/src/hooks/useVaultActions.ts index a534fed9..3a51dc2a 100644 --- a/apps/web/src/hooks/useVaultActions.ts +++ b/apps/web/src/hooks/useVaultActions.ts @@ -192,9 +192,11 @@ export function useVaultActions() { "positions", publicKey, ]); - const matchedBefore = - positionsBefore?.find((p) => p.vaultId === vaultId) ?? - positionsBefore?.[0]; + // Only this vault's position is a valid baseline. Falling back to + // another vault would price the withdrawal against the wrong cost + // basis and share count, so a missing match is treated as no baseline, + // matching the deposit path. + const matchedBefore = positionsBefore?.find((p) => p.vaultId === vaultId); const sharesBefore = matchedBefore?.shares ?? Infinity; const withdrawnShares = parseFloat(shares); diff --git a/apps/web/src/pages/AdminDashboard.tsx b/apps/web/src/pages/AdminDashboard.tsx index 6ec09f5b..f443b475 100644 --- a/apps/web/src/pages/AdminDashboard.tsx +++ b/apps/web/src/pages/AdminDashboard.tsx @@ -30,7 +30,6 @@ export function AdminDashboard() {
); diff --git a/apps/web/src/pages/StatusPage.tsx b/apps/web/src/pages/StatusPage.tsx index 4f39f27d..b2577c8f 100644 --- a/apps/web/src/pages/StatusPage.tsx +++ b/apps/web/src/pages/StatusPage.tsx @@ -131,7 +131,6 @@ export function StatusPage() { diff --git a/apps/web/src/store/wallet.ts b/apps/web/src/store/wallet.ts index 96223ba3..9c6e0826 100644 --- a/apps/web/src/store/wallet.ts +++ b/apps/web/src/store/wallet.ts @@ -7,7 +7,6 @@ import { APP_NETWORK } from "@meridian/shared"; interface WalletStore extends WalletState { connect: (publicKey: string) => void; disconnect: () => void; - setNetwork: (network: WalletState["network"]) => void; revalidate: () => Promise; } @@ -24,7 +23,6 @@ export const useWalletStore = create()( connect: (publicKey) => set({ publicKey, connected: true }), disconnect: () => set({ publicKey: null, connected: false }), - setNetwork: (network) => set({ network }), // Re-check the persisted key against Freighter. Clears stale state when // the extension is gone or the user revoked site access between sessions.