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
5 changes: 5 additions & 0 deletions apps/web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
"title": "Meridian"
},

"toasts": {
"notifications": "Notifications",
"dismiss": "Dismiss {{kind}} notification"
},

"walletConnect": {
"disconnect": "Disconnect",
"copyAddress": "Copy Address",
Expand Down
5 changes: 5 additions & 0 deletions apps/web/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
"title": "Meridian"
},

"toasts": {
"notifications": "Notifications",
"dismiss": "Fermer la notification {{kind}}"
},

"walletConnect": {
"disconnect": "Déconnecter",
"copyAddress": "Copier l'adresse",
Expand Down
13 changes: 2 additions & 11 deletions apps/web/src/__tests__/components/AdminActionHistory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ function mockActions(actions: ReturnType<typeof action>[]) {
// 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({
Expand All @@ -63,7 +63,6 @@ function renderHistory(
<AdminActionHistory
vaultId="blend-usdc-fixed"
network={props.network ?? "testnet"}
isAdmin={props.isAdmin ?? true}
/>
</I18nextProvider>
);
Expand All @@ -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();

Expand Down
44 changes: 43 additions & 1 deletion apps/web/src/__tests__/components/Toasts.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<I18nextProvider i18n={i18n}>
<Toasts />
</I18nextProvider>
);
}

let unmountToasts: () => void;

beforeEach(() => {
vi.useFakeTimers();
useToastStore.setState({ toasts: [] });
render(<Toasts />);
unmountToasts = renderToasts().unmount;
});
afterEach(() => vi.useRealTimers());

Expand All @@ -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);
Expand Down
33 changes: 32 additions & 1 deletion apps/web/src/__tests__/components/WalletConnect.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(<WalletConnect />);

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();
});
});
22 changes: 22 additions & 0 deletions apps/web/src/__tests__/hooks/useVaultActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down
5 changes: 0 additions & 5 deletions apps/web/src/__tests__/store/wallet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 1 addition & 9 deletions apps/web/src/components/dashboard/AdminActionHistory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [];

Expand Down
11 changes: 10 additions & 1 deletion apps/web/src/components/onboarding/WalletConnect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function WalletConnect() {
cancelRiskDisclosure,
} = useWalletConnect();
const [copied, setCopied] = useState(false);
const copiedTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [installedById, setInstalledById] = useState<
Partial<Record<WalletId, boolean>>
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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"));
}
Expand Down
30 changes: 16 additions & 14 deletions apps/web/src/components/ui/Toasts.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { useToastStore } from "../../store/toast";

const ICON: Record<string, string> = {
Expand All @@ -14,38 +15,39 @@ const STYLES: Record<string, string> = {

export function Toasts() {
const { toasts, dismiss, pauseTimer, resumeTimer } = useToastStore();
const { t } = useTranslation();

return (
<div
className="fixed top-4 right-4 z-50 flex flex-col gap-2 w-72"
role="status"
aria-live="polite"
aria-relevant="additions"
aria-label="Notifications"
aria-label={t("toasts.notifications")}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

toasts.notifications and toasts.dismiss do not exist in apps/web/messages/en.json, so these lookups render the raw key. Add both entries to the English catalogue as well.

>
{toasts.map((t) => (
{toasts.map((toast) => (
<div
key={t.id}
role={t.kind === "error" ? "alert" : undefined}
aria-live={t.kind === "error" ? "assertive" : undefined}
key={toast.id}
role={toast.kind === "error" ? "alert" : undefined}
aria-live={toast.kind === "error" ? "assertive" : undefined}
aria-atomic="true"
// Pausing on hover and on focus (focus events bubble in React, so
// this also covers a keyboard user reaching the dismiss button)
// keeps a toast on screen for as long as it's being read.
onMouseEnter={() => 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]}`}
>
<span className="font-bold shrink-0" aria-hidden="true">
{ICON[t.kind]}
{ICON[toast.kind]}
</span>
<span className="flex-1 leading-snug">{t.message}</span>
<span className="flex-1 leading-snug">{toast.message}</span>
<button
onClick={() => dismiss(t.id)}
onClick={() => dismiss(toast.id)}
className="shrink-0 opacity-50 hover:opacity-100 transition-opacity duration-100 text-xs"
aria-label={`Dismiss ${t.kind} notification`}
aria-label={t("toasts.dismiss", { kind: toast.kind })}
>
<span aria-hidden="true">✕</span>
</button>
Expand Down
8 changes: 5 additions & 3 deletions apps/web/src/hooks/useVaultActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
1 change: 0 additions & 1 deletion apps/web/src/pages/AdminDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export function AdminDashboard() {
<AdminActionHistory
vaultId="meridian-usdc"
network={APP_NETWORK.network}
isAdmin
/>
</div>
);
Expand Down
1 change: 0 additions & 1 deletion apps/web/src/pages/StatusPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,6 @@ export function StatusPage() {
<AdminActionHistory
vaultId="meridian-usdc"
network={APP_NETWORK.network}
isAdmin
/>
</ErrorBoundary>

Expand Down
2 changes: 0 additions & 2 deletions apps/web/src/store/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}

Expand All @@ -24,7 +23,6 @@ export const useWalletStore = create<WalletStore>()(

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.
Expand Down
Loading