From 989d7e686d550f0ea137817270d77e8a1673ea53 Mon Sep 17 00:00:00 2001 From: samuelisi Date: Sun, 30 Aug 2026 22:10:54 +0100 Subject: [PATCH 1/5] feat(frontend): reconcile wallet state across browser tabs (#302) --- docs/wallet-hydration.md | 25 ++++++++ src/components/WalletHydrator.test.tsx | 41 +++++++++++- src/components/WalletHydrator.tsx | 22 ++++++- src/store/wallet.test.ts | 54 ++++++++++++++++ src/store/wallet.ts | 89 ++++++++++++++++++++++---- 5 files changed, 215 insertions(+), 16 deletions(-) diff --git a/docs/wallet-hydration.md b/docs/wallet-hydration.md index c3feb99..d9054d1 100644 --- a/docs/wallet-hydration.md +++ b/docs/wallet-hydration.md @@ -78,3 +78,28 @@ the only call that pops the Freighter approval UI. That call only happens in strictly a read of already-granted access — if the extension would need to prompt the user, hydration clears the session instead of prompting silently on page load. + +## Multi-tab reconciliation (#302) + +`useWalletStore` persists to `localStorage` under `PERSIST_KEY` (`vortex-wallet`). +The browser's `storage` event fires in **every other same-origin tab** whenever +one tab writes that key, so [`WalletHydrator`](../src/components/WalletHydrator.tsx) +also registers a `storage` listener (once, alongside the mount-time `hydrate()`). + +When another tab changes the persisted wallet slice, the listener parses the new +value and calls `useWalletStore.getState().syncFromStorage(persisted)`: + +- **Already in sync** (`isConnected` and `address` match this tab) — no-op. This + is what prevents a reconciliation loop: a tab's own reconciling write lands in + the other tabs as a `storage` event, but by then every tab already agrees, so + nothing further is written. +- **Another tab disconnected** (`persisted.isConnected === false`) — trusted + directly; this tab clears its wallet state. A user-initiated disconnect is + authoritative and there's nothing to re-verify. +- **Another tab connected or switched account** — the new address is adopted + optimistically and then `hydrate()` re-confirms it against the extension + (`isConnected` / `isAllowed` / `getPublicKey`), so a tab never trusts an + account it can't verify. + +The `storage` event never fires in the tab that made the change, so the +originating tab keeps the correct state from its own `set()` and is unaffected. diff --git a/src/components/WalletHydrator.test.tsx b/src/components/WalletHydrator.test.tsx index 483c67a..5b5ad4c 100644 --- a/src/components/WalletHydrator.test.tsx +++ b/src/components/WalletHydrator.test.tsx @@ -1,14 +1,24 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { render } from "@testing-library/react"; -const { hydrateMock } = vi.hoisted(() => ({ hydrateMock: vi.fn() })); +const { hydrateMock, syncFromStorageMock } = vi.hoisted(() => ({ + hydrateMock: vi.fn(), + syncFromStorageMock: vi.fn(), +})); vi.mock("@/store/wallet", () => ({ - useWalletStore: { getState: () => ({ hydrate: hydrateMock }) }, + PERSIST_KEY: "vortex-wallet", + useWalletStore: { + getState: () => ({ hydrate: hydrateMock, syncFromStorage: syncFromStorageMock }), + }, })); import { WalletHydrator } from "./WalletHydrator"; +function fireStorage(key: string | null, newValue: string | null) { + window.dispatchEvent(new StorageEvent("storage", { key: key ?? undefined, newValue })); +} + describe("WalletHydrator", () => { beforeEach(() => { vi.clearAllMocks(); @@ -23,4 +33,31 @@ describe("WalletHydrator", () => { expect(hydrateMock).toHaveBeenCalledTimes(1); expect(container).toBeEmptyDOMElement(); }); + + it("reconciles the store when another tab writes the persisted wallet key", () => { + render(); + + const persisted = { address: null, lastKnownAddress: "GABC", network: null, isConnected: false }; + fireStorage("vortex-wallet", JSON.stringify({ state: persisted, version: 0 })); + + expect(syncFromStorageMock).toHaveBeenCalledWith(persisted); + }); + + it("ignores storage events for other keys, key removal, and malformed JSON", () => { + render(); + + fireStorage("some-other-key", JSON.stringify({ state: {} })); + fireStorage("vortex-wallet", null); + fireStorage("vortex-wallet", "{not json"); + + expect(syncFromStorageMock).not.toHaveBeenCalled(); + }); + + it("removes the storage listener on unmount", () => { + const { unmount } = render(); + unmount(); + + fireStorage("vortex-wallet", JSON.stringify({ state: { isConnected: false } })); + expect(syncFromStorageMock).not.toHaveBeenCalled(); + }); }); diff --git a/src/components/WalletHydrator.tsx b/src/components/WalletHydrator.tsx index b580d8c..bacea7d 100644 --- a/src/components/WalletHydrator.tsx +++ b/src/components/WalletHydrator.tsx @@ -1,14 +1,32 @@ "use client"; import { useEffect } from "react"; -import { useWalletStore } from "@/store/wallet"; +import { useWalletStore, PERSIST_KEY, type PersistedWalletState } from "@/store/wallet"; // Mounted once in the root layout. On first client paint, attempts to // silently restore a previously-connected wallet session (persisted in -// localStorage) without triggering the Freighter popup. +// localStorage) without triggering the Freighter popup. Also keeps this tab's +// wallet state in step with connect/disconnect done in other tabs (#302). export function WalletHydrator() { useEffect(() => { useWalletStore.getState().hydrate(); + + const onStorage = (event: StorageEvent) => { + // `storage` fires only for changes made in *other* tabs. + if (event.key !== PERSIST_KEY || event.newValue === null) return; + try { + const parsed = JSON.parse(event.newValue) as { + state?: PersistedWalletState; + } & Partial; + const persisted = parsed.state ?? (parsed as PersistedWalletState); + useWalletStore.getState().syncFromStorage(persisted); + } catch { + // Ignore a malformed write rather than crashing the app. + } + }; + + window.addEventListener("storage", onStorage); + return () => window.removeEventListener("storage", onStorage); }, []); return null; diff --git a/src/store/wallet.test.ts b/src/store/wallet.test.ts index 4ea6624..fccaf4d 100644 --- a/src/store/wallet.test.ts +++ b/src/store/wallet.test.ts @@ -218,4 +218,58 @@ describe("useWalletStore", () => { expect(state.lastKnownAddress).toBe("GOLD123"); expect(state.wasSessionCleared).toBe(true); }); + + // ── Issue #302: multi-tab reconciliation ──────────────────────────────── + + it("syncFromStorage() disconnects this tab when another tab disconnected", () => { + useWalletStore.setState({ isConnected: true, address: "GABC123", network: "TESTNET" }); + + useWalletStore.getState().syncFromStorage({ + address: null, + lastKnownAddress: "GABC123", + network: null, + isConnected: false, + }); + + const state = useWalletStore.getState(); + expect(state.isConnected).toBe(false); + expect(state.address).toBeNull(); + expect(isConnectedMock).not.toHaveBeenCalled(); + }); + + it("syncFromStorage() re-verifies with the extension when another tab switched account", async () => { + useWalletStore.setState({ isConnected: true, address: "GOLD123", network: "TESTNET" }); + isConnectedMock.mockResolvedValue(true); + isAllowedMock.mockResolvedValue(true); + getPublicKeyMock.mockResolvedValue("GNEW456"); + getNetworkMock.mockResolvedValue("TESTNET"); + + useWalletStore.getState().syncFromStorage({ + address: "GNEW456", + lastKnownAddress: "GNEW456", + network: "TESTNET", + isConnected: true, + }); + await Promise.resolve(); + await Promise.resolve(); + + const state = useWalletStore.getState(); + expect(state.address).toBe("GNEW456"); + expect(state.isConnected).toBe(true); + expect(getPublicKeyMock).toHaveBeenCalled(); + }); + + it("syncFromStorage() is a no-op when already in sync (no reconciliation loop)", () => { + useWalletStore.setState({ isConnected: true, address: "GABC123", network: "TESTNET" }); + + useWalletStore.getState().syncFromStorage({ + address: "GABC123", + lastKnownAddress: "GABC123", + network: "TESTNET", + isConnected: true, + }); + + expect(isConnectedMock).not.toHaveBeenCalled(); + expect(useWalletStore.getState().address).toBe("GABC123"); + }); }); diff --git a/src/store/wallet.ts b/src/store/wallet.ts index 4c9327e..91b9e2d 100644 --- a/src/store/wallet.ts +++ b/src/store/wallet.ts @@ -1,12 +1,21 @@ import { create } from "zustand"; import { persist, createJSONStorage } from "zustand/middleware"; import freighterApi from "@stellar/freighter-api"; -import { DEFAULT_LOCALE, translate } from "@/lib/i18n"; export type WalletErrorKey = | "wallet.error.freighterUnavailable" | "wallet.error.connectFailed"; +/** Shape of the slice persisted to localStorage under `PERSIST_KEY`. */ +export type PersistedWalletState = { + address: string | null; + lastKnownAddress: string | null; + network: string | null; + isConnected: boolean; +}; + +export const PERSIST_KEY = "vortex-wallet"; + /** The network name the app expects, normalised to upper-case for comparison. */ const EXPECTED_NETWORK = (process.env.NEXT_PUBLIC_NETWORK ?? "testnet").toUpperCase(); @@ -18,6 +27,17 @@ export type WalletState = { isConnecting: boolean; /** Generic connection error message (e.g. user declined access). */ error: string | null; + /** + * Stable i18n key for the error when it maps to a known category, else null + * (a raw error message from Freighter is surfaced via `error` only). + */ + errorKey: WalletErrorKey | null; + /** + * `true` when a persisted session was dropped on hydrate because the + * extension no longer allows this site - the UI can offer a one-click + * reconnect keyed off `lastKnownAddress`. + */ + wasSessionCleared: boolean; /** * `true` when the wallet is connected but on a different network than the * one configured via NEXT_PUBLIC_NETWORK. The wallet is still treated as @@ -34,6 +54,12 @@ export type WalletState = { connect: () => Promise; disconnect: () => void; hydrate: () => Promise; + /** + * Reconcile this tab's state with a persisted snapshot written by another + * tab (delivered via the `storage` event). Trusts an explicit cross-tab + * disconnect; re-verifies a changed account against the extension. + */ + syncFromStorage: (persisted: PersistedWalletState) => void; }; export const useWalletStore = create()( @@ -46,11 +72,12 @@ export const useWalletStore = create()( isConnecting: false, wasSessionCleared: false, error: null, + errorKey: null, networkMismatch: false, notInstalled: false, connect: async () => { - set({ isConnecting: true, error: null, networkMismatch: false, notInstalled: false }); + set({ isConnecting: true, error: null, errorKey: null, networkMismatch: false, notInstalled: false }); try { const isAppConnected = await freighterApi.isConnected(); if (!isAppConnected) { @@ -60,6 +87,7 @@ export const useWalletStore = create()( isConnected: false, isConnecting: false, error: "Freighter extension is not installed or enabled.", + errorKey: "wallet.error.freighterUnavailable", notInstalled: true, }); return; @@ -77,21 +105,20 @@ export const useWalletStore = create()( isConnecting: false, wasSessionCleared: false, error: null, + errorKey: null, networkMismatch: mismatch, notInstalled: false, }); } catch (err) { const externalError = err instanceof Error ? err.message : null; - if (!externalError) { - errorKey = "wallet.error.connectFailed"; - } set({ address: null, network: null, isConnected: false, isConnecting: false, wasSessionCleared: false, - error: err instanceof Error ? err.message : "Failed to connect wallet.", + error: externalError ?? "Failed to connect wallet.", + errorKey: externalError ? null : "wallet.error.connectFailed", networkMismatch: false, notInstalled: false, }); @@ -106,6 +133,7 @@ export const useWalletStore = create()( isConnecting: false, wasSessionCleared: false, error: null, + errorKey: null, networkMismatch: false, }); }, @@ -116,12 +144,14 @@ export const useWalletStore = create()( // the stale persisted session. hydrate: async () => { if (!get().isConnected) return; - const previousAddress = get().address ?? get().lastKnownAddress; + // Preserve the address for a one-click reconnect if the session turns + // out to be stale. + const lastKnownAddress = get().address ?? get().lastKnownAddress; try { const isAppConnected = await freighterApi.isConnected(); const allowed = isAppConnected && (await freighterApi.isAllowed()); if (!allowed) { - set({ address: null, network: null, isConnected: false, error: null, networkMismatch: false, notInstalled: false }); + set({ address: null, lastKnownAddress, network: null, isConnected: false, error: null, errorKey: null, networkMismatch: false, notInstalled: false, wasSessionCleared: true }); return; } @@ -129,16 +159,51 @@ export const useWalletStore = create()( const network = await freighterApi.getNetwork(); const mismatch = network.toUpperCase() !== EXPECTED_NETWORK; - set({ address, network, isConnected: true, error: null, networkMismatch: mismatch, notInstalled: false }); + set({ address, network, isConnected: true, error: null, errorKey: null, networkMismatch: mismatch, notInstalled: false, wasSessionCleared: false }); } catch { - set({ address: null, network: null, isConnected: false, error: null, networkMismatch: false, notInstalled: false }); + set({ address: null, lastKnownAddress, network: null, isConnected: false, error: null, errorKey: null, networkMismatch: false, notInstalled: false, wasSessionCleared: true }); } }, + + // === Cross-tab reconciliation (#302) + // The `storage` event fires only in *other* tabs, so this never sees this + // tab's own writes. A cross-tab disconnect (persisted isConnected=false) + // is trusted; a changed account is re-verified against the extension. + syncFromStorage: (persisted) => { + const state = get(); + const inSync = + persisted.isConnected === state.isConnected && + persisted.address === state.address; + if (inSync) return; + + if (!persisted.isConnected) { + set({ + address: null, + network: null, + isConnected: false, + error: null, + errorKey: null, + networkMismatch: false, + notInstalled: false, + }); + return; + } + + // Another tab connected, or switched account: adopt the address + // optimistically, then let hydrate() confirm it with Freighter. + set({ + address: persisted.address, + lastKnownAddress: persisted.address ?? state.lastKnownAddress, + network: persisted.network, + isConnected: true, + }); + void get().hydrate(); + }, }), { - name: "vortex-wallet", + name: PERSIST_KEY, storage: createJSONStorage(() => localStorage), - partialize: (state) => ({ + partialize: (state): PersistedWalletState => ({ address: state.address, lastKnownAddress: state.lastKnownAddress, network: state.network, From f0b1344b17a987d7d7c1d264b6d97154748ca7af Mon Sep 17 00:00:00 2001 From: samuelisi Date: Sun, 30 Aug 2026 22:14:15 +0100 Subject: [PATCH 2/5] feat(frontend): add first-visit onboarding hint sequence (#303) --- src/app/page.test.tsx | 25 +++- src/app/page.tsx | 14 +- src/components/OnboardingHints.test.tsx | 91 ++++++++++++ src/components/OnboardingHints.tsx | 185 ++++++++++++++++++++++++ src/lib/i18n/messages/en.ts | 1 + src/lib/i18n/messages/es.ts | 1 + 6 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 src/components/OnboardingHints.test.tsx create mode 100644 src/components/OnboardingHints.tsx diff --git a/src/app/page.test.tsx b/src/app/page.test.tsx index 36ad9d8..bece0be 100644 --- a/src/app/page.test.tsx +++ b/src/app/page.test.tsx @@ -1,10 +1,16 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; const { useIntentFeedMock } = vi.hoisted(() => ({ useIntentFeedMock: vi.fn() })); vi.mock("@/hooks/useIntentFeed", () => ({ useIntentFeed: useIntentFeedMock })); // The swap card has its own suite; stub it so this one stays about the page shell. vi.mock("@/components/SwapCard", () => ({ SwapCard: () =>
})); +// Nav/Footer/ActivityFeed carry their own (currently broken on main) suites and +// wallet/i18n context this one doesn't set up. +vi.mock("@/components/Nav", () => ({ Nav: () => null })); +vi.mock("@/components/Footer", () => ({ Footer: () => null })); +vi.mock("@/components/ActivityFeed", () => ({ ActivityFeed: () =>
})); import HomePage from "./page"; @@ -14,6 +20,11 @@ function renderHome() { } describe("HomePage", () => { + beforeEach(() => { + // Suppress the first-visit onboarding sequence for the shell tests. + localStorage.setItem("vortex-onboarding-seen", "1"); + }); + it("renders content within a main landmark", () => { renderHome(); expect(screen.getByRole("main")).toHaveAttribute("id", "main-content"); @@ -71,4 +82,16 @@ describe("HomePage", () => { expect(screen.getByText("Supported chains")).toBeInTheDocument(); expect(screen.getByText("Stellar (dest.)")).toBeInTheDocument(); }); + + it("shows the first-visit onboarding without blocking access to the swap card", async () => { + localStorage.removeItem("vortex-onboarding-seen"); + renderHome(); + + expect(screen.getByRole("dialog", { name: "Start a swap here" })).toBeInTheDocument(); + // The swap card is still rendered and reachable behind the hint. + expect(screen.getByTestId("swap-card")).toBeInTheDocument(); + + await userEvent.click(screen.getByRole("button", { name: "Skip" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); }); diff --git a/src/app/page.tsx b/src/app/page.tsx index 1cc4788..c8826aa 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,6 +5,7 @@ import { Nav } from "@/components/Nav"; import { Footer } from "@/components/Footer"; import { SwapCard } from "@/components/SwapCard"; import { ActivityFeed } from "@/components/ActivityFeed"; +import { OnboardingHints } from "@/components/OnboardingHints"; import { CHAINS } from "@/lib/marketData"; import { useTranslation } from "@/lib/i18n/I18nProvider"; import type { MessageKey } from "@/lib/i18n/index"; @@ -86,6 +87,13 @@ export default function HomePage() {

{t("home.hero.body")}

+ + {t("home.hero.solverCta")} +
{/* Stats */} @@ -105,7 +113,7 @@ export default function HomePage() {
{/* Live feed */} -
+
{t("home.feed.title")}
@@ -117,7 +125,7 @@ export default function HomePage() {
{/* Right: swap card */} -
+
{/* Supported chains */} @@ -145,6 +153,8 @@ export default function HomePage() { {/* ── Footer ── */}
+ +
); } diff --git a/src/components/OnboardingHints.test.tsx b/src/components/OnboardingHints.test.tsx new file mode 100644 index 0000000..5aa1ffa --- /dev/null +++ b/src/components/OnboardingHints.test.tsx @@ -0,0 +1,91 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { OnboardingHints } from "./OnboardingHints"; + +const STORAGE_KEY = "vortex-onboarding-seen"; + +function Fixture({ withTargets = true }: { withTargets?: boolean }) { + return ( + <> + {withTargets && ( + <> +
swap
+
feed
+ + solver + + + )} + + + ); +} + +describe("OnboardingHints", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + it("shows the first step on a genuine first visit", () => { + render(); + expect(screen.getByRole("dialog", { name: "Start a swap here" })).toBeInTheDocument(); + expect(screen.getByText("1 / 3")).toBeInTheDocument(); + }); + + it("does not show for a returning visitor", () => { + localStorage.setItem(STORAGE_KEY, "1"); + render(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("does not start if a target element is missing", () => { + render(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("steps forward and back, then finishes - persisting the dismissal", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Next" })); + expect(screen.getByRole("dialog", { name: "Watch it settle live" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Back" })); + expect(screen.getByRole("dialog", { name: "Start a swap here" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Next" })); + await user.click(screen.getByRole("button", { name: "Next" })); + await user.click(screen.getByRole("button", { name: "Done" })); + + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(localStorage.getItem(STORAGE_KEY)).toBe("1"); + }); + + it("is skippable at any step and stays dismissed", async () => { + const user = userEvent.setup(); + const { unmount } = render(); + + await user.click(screen.getByRole("button", { name: "Skip" })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(localStorage.getItem(STORAGE_KEY)).toBe("1"); + + unmount(); + render(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("closes on Escape", async () => { + const user = userEvent.setup(); + render(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + await user.keyboard("{Escape}"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(localStorage.getItem(STORAGE_KEY)).toBe("1"); + }); +}); diff --git a/src/components/OnboardingHints.tsx b/src/components/OnboardingHints.tsx new file mode 100644 index 0000000..2452caa --- /dev/null +++ b/src/components/OnboardingHints.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; + +const STORAGE_KEY = "vortex-onboarding-seen"; + +type Step = { + targetId: string; + title: string; + body: string; +}; + +const STEPS: Step[] = [ + { + targetId: "swap-card-region", + title: "Start a swap here", + body: "Enter an amount on any supported chain. You're creating an intent, not a trade - competing solvers fill it and the funds land on Stellar.", + }, + { + targetId: "live-feed-region", + title: "Watch it settle live", + body: "The activity feed streams real fills as solvers complete them, so you can see the network working in real time.", + }, + { + targetId: "solver-portal-link", + title: "Run a solver", + body: "Solvers post a bond and compete to fill intents for a fee. If you want to provide liquidity, start from the solver portal.", + }, +]; + +function hasSeenOnboarding(): boolean { + try { + return localStorage.getItem(STORAGE_KEY) === "1"; + } catch { + // Private mode / storage disabled - treat as seen so we never nag. + return true; + } +} + +function markSeen() { + try { + localStorage.setItem(STORAGE_KEY, "1"); + } catch { + // Ignore - the sequence just won't be suppressed next load. + } +} + +export function OnboardingHints() { + const [stepIndex, setStepIndex] = useState(null); + const [rect, setRect] = useState(null); + const cardRef = useRef(null); + + // Only start the sequence for a genuine first visit, and only once the DOM the + // steps point at is present. + useEffect(() => { + if (hasSeenOnboarding()) return; + if (STEPS.some((step) => !document.getElementById(step.targetId))) return; + setStepIndex(0); + }, []); + + const dismiss = useCallback(() => { + markSeen(); + setStepIndex(null); + }, []); + + const step = stepIndex === null ? null : STEPS[stepIndex]; + const isLast = stepIndex === STEPS.length - 1; + + // Position the card just below its target; recompute on step change / resize. + useLayoutEffect(() => { + if (!step) return; + const measure = () => { + const el = document.getElementById(step.targetId); + setRect(el ? el.getBoundingClientRect() : null); + }; + measure(); + window.addEventListener("resize", measure); + window.addEventListener("scroll", measure, true); + return () => { + window.removeEventListener("resize", measure); + window.removeEventListener("scroll", measure, true); + }; + }, [step]); + + useEffect(() => { + if (!step) return; + cardRef.current?.focus(); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + dismiss(); + } + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [step, dismiss]); + + if (!step || stepIndex === null) return null; + + const animate = + typeof document !== "undefined" && document.documentElement.dataset.motion !== "reduce"; + + // Fall back to a centred card when the target isn't measurable. + const cardStyle: React.CSSProperties = rect + ? { + position: "fixed", + top: Math.min(rect.bottom + 12, window.innerHeight - 220), + left: Math.min(Math.max(rect.left, 12), window.innerWidth - 332), + width: 320, + } + : { position: "fixed", top: "40%", left: "50%", transform: "translate(-50%,-50%)", width: 320 }; + + return ( +
{ + if (event.target === event.currentTarget) dismiss(); + }} + > + {/* Spotlight ring around the current target. */} + {rect && ( + + ); +} diff --git a/src/lib/i18n/messages/en.ts b/src/lib/i18n/messages/en.ts index 0b3a108..6fafd28 100644 --- a/src/lib/i18n/messages/en.ts +++ b/src/lib/i18n/messages/en.ts @@ -61,6 +61,7 @@ export const en = { "home.hero.titleLine2": "directly to Stellar.", "home.hero.body": "Vortex is an intent-based cross-chain protocol. Express what you want, and competing solvers race to fill it — no bridges, no wrapped assets, no trust assumptions beyond the solver bond.", + "home.hero.solverCta": "Become a solver →", "home.stats.totalVolume": "Total Volume", "home.stats.intentsFilled": "Intents Filled", diff --git a/src/lib/i18n/messages/es.ts b/src/lib/i18n/messages/es.ts index cbcdbe7..ddbbccf 100644 --- a/src/lib/i18n/messages/es.ts +++ b/src/lib/i18n/messages/es.ts @@ -55,6 +55,7 @@ export const es = { "home.hero.titleLine2": "directamente a Stellar.", "home.hero.body": "Vortex es un protocolo cross-chain basado en intenciones. Expresa lo que quieres y los solvers compiten por cumplirlo — sin puentes, sin tokens envueltos, sin suposiciones de confianza más allá del bono del solver.", + "home.hero.solverCta": "Conviértete en solver →", "home.stats.totalVolume": "Volumen Total", "home.stats.intentsFilled": "Intenciones Completadas", From c23954328b84d1cbdedb91ceb1bfc9559acd612c Mon Sep 17 00:00:00 2001 From: samuelisi Date: Sun, 30 Aug 2026 22:17:58 +0100 Subject: [PATCH 3/5] feat(frontend): add column visibility preferences to my intents list (#300) --- src/app/my-intents/page.test.tsx | 85 +++++++++++++++++++++++++- src/app/my-intents/page.tsx | 87 +++++++++++++++++++++++---- src/hooks/useColumnVisibility.test.ts | 49 +++++++++++++++ src/hooks/useColumnVisibility.ts | 82 +++++++++++++++++++++++++ 4 files changed, 290 insertions(+), 13 deletions(-) create mode 100644 src/hooks/useColumnVisibility.test.ts create mode 100644 src/hooks/useColumnVisibility.ts diff --git a/src/app/my-intents/page.test.tsx b/src/app/my-intents/page.test.tsx index d8b1438..71a0e8b 100644 --- a/src/app/my-intents/page.test.tsx +++ b/src/app/my-intents/page.test.tsx @@ -1,5 +1,5 @@ -import { describe, expect, it, vi } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { FeedItem } from "@/lib/types"; @@ -11,6 +11,13 @@ const { useWalletStoreMock, useMyLiveIntentsMock } = vi.hoisted(() => ({ vi.mock("@/store/wallet", () => ({ useWalletStore: useWalletStoreMock })); vi.mock("@/store/toast", () => ({ useToastStore: vi.fn(() => ({ addToast: vi.fn() })) })); vi.mock("@/hooks/useMyLiveIntents", () => ({ useMyLiveIntents: useMyLiveIntentsMock })); +// Nav/Footer/ConnectWalletButton carry their own (currently broken on main) +// suites and wallet/i18n context this one does not set up. +vi.mock("@/components/Nav", () => ({ Nav: () => null })); +vi.mock("@/components/Footer", () => ({ Footer: () => null })); +vi.mock("@/components/ConnectWalletButton", () => ({ + ConnectWalletButton: () => , +})); import MyIntentsPage from "./page"; @@ -73,6 +80,14 @@ const manyIntents: FeedItem[] = Array.from({ length: 25 }, (_, i) => ({ })); describe("MyIntentsPage", () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + it("renders the main landmark with the correct id", () => { mockWallet(); useMyLiveIntentsMock.mockReturnValue({ intents: [], isLoading: false, error: undefined, isLive: false }); @@ -186,6 +201,72 @@ describe("MyIntentsPage", () => { expect(screen.getByRole("link", { name: /make your first swap/i })).toHaveAttribute("href", "/"); }); + describe("column visibility (#300)", () => { + function connectedWithIntents() { + mockWallet({ address: "GABC123", isConnected: true }); + useMyLiveIntentsMock.mockReturnValue({ intents, isLoading: false, error: undefined }); + } + + it("hides the solver column from every row when toggled off", async () => { + connectedWithIntents(); + const user = userEvent.setup(); + render(); + + expect(screen.getByText(/via Alpha/)).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Columns" })); + await user.click(screen.getByRole("checkbox", { name: "Solver" })); + + expect(screen.queryByText(/via Alpha/)).not.toBeInTheDocument(); + // The source chain is a separate column and stays. + expect(screen.getByText(/ethereum/)).toBeInTheDocument(); + }); + + it("persists the preference across a remount", async () => { + connectedWithIntents(); + const user = userEvent.setup(); + const { unmount } = render(); + + await user.click(screen.getByRole("button", { name: "Columns" })); + await user.click(screen.getByRole("checkbox", { name: "Source chain" })); + expect(screen.queryByText(/ethereum/)).not.toBeInTheDocument(); + + unmount(); + connectedWithIntents(); + render(); + + expect(screen.queryByText(/ethereum/)).not.toBeInTheDocument(); + }); + + it("keeps status and date columns non-toggleable", async () => { + connectedWithIntents(); + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Columns" })); + + expect(screen.getByRole("checkbox", { name: /Status/ })).toBeDisabled(); + expect(screen.getByRole("checkbox", { name: /Submitted/ })).toBeDisabled(); + // Still rendered in the rows. + const list = screen.getByTestId("intents-list"); + expect(within(list).getAllByText(/submitted .* ago/).length).toBeGreaterThan(0); + }); + + it("ignores unknown keys in a stale persisted preference", () => { + localStorage.setItem( + "vortex-my-intents-columns", + JSON.stringify({ solver: false, removedColumn: false }), + ); + connectedWithIntents(); + render(); + + // Known key still applied... + expect(screen.queryByText(/via Alpha/)).not.toBeInTheDocument(); + // ...and the page didn't crash on the unknown one. + expect(screen.getByTestId("intents-list")).toBeInTheDocument(); + }); + }); + it("links each intent row to the intent detail page", () => { mockWallet({ address: "GABC123", isConnected: true }); useMyLiveIntentsMock.mockReturnValue({ intents, isLoading: false, error: undefined }); diff --git a/src/app/my-intents/page.tsx b/src/app/my-intents/page.tsx index 41f9c75..39a41c4 100644 --- a/src/app/my-intents/page.tsx +++ b/src/app/my-intents/page.tsx @@ -8,21 +8,42 @@ import { IntentStatusBadge } from "@/components/IntentStatusBadge"; import { ConnectWalletButton } from "@/components/ConnectWalletButton"; import { useWalletStore } from "@/store/wallet"; import { useMyLiveIntents } from "@/hooks/useMyLiveIntents"; +import { useColumnVisibility } from "@/hooks/useColumnVisibility"; import { CHAINS } from "@/lib/marketData"; -import { SkeletonCard } from "@/components/Skeleton"; +import { buildIntentsCsv, downloadCsv } from "@/lib/csv"; +import { timeAgo } from "@/lib/time"; import type { IntentStatus } from "@/lib/types"; const STATUS_OPTIONS: Array = ["all", "pending", "accepted", "filled", "failed"]; const PAGE_SIZE = 10; +// On-screen row columns. `pair` and `status` and `submitted` are essential for +// scanning the list, so they can't be hidden; `chain` and `solver` are optional. +const MY_INTENTS_COLUMNS = ["pair", "chain", "solver", "status", "submitted"] as const; +type MyIntentsColumn = (typeof MY_INTENTS_COLUMNS)[number]; +const ALWAYS_VISIBLE_COLUMNS: MyIntentsColumn[] = ["pair", "status", "submitted"]; +const COLUMN_LABELS: Record = { + pair: "Swap", + chain: "Source chain", + solver: "Solver", + status: "Status", + submitted: "Submitted", +}; + export default function MyIntentsPage() { const address = useWalletStore((s) => s.address); const isConnected = useWalletStore((s) => s.isConnected); const { intents, isLoading, error, isLive } = useMyLiveIntents(address); + const { visibility, toggle, isToggleable } = useColumnVisibility( + "vortex-my-intents-columns", + MY_INTENTS_COLUMNS, + ALWAYS_VISIBLE_COLUMNS, + ); const [statusFilter, setStatusFilter] = useState("all"); const [chainFilter, setChainFilter] = useState("all"); + const [showColumnMenu, setShowColumnMenu] = useState(false); const [page, setPage] = useState(1); const filtered = useMemo(() => { @@ -87,7 +108,6 @@ export default function MyIntentsPage() { value={statusFilter} onChange={(e) => setStatusFilter(e.target.value as IntentStatus | "all")} className="bg-vx-surface border border-vx-border rounded-lg px-3 py-2 text-sm text-vx-text" - aria-label="Filter intents by status" > {STATUS_OPTIONS.map((s) => ( {CHAINS.map((c) => ( @@ -113,11 +132,48 @@ export default function MyIntentsPage() { +
+ + {showColumnMenu && ( +
+ {MY_INTENTS_COLUMNS.filter((c) => c !== "pair").map((column) => ( + + ))} +
+ )} +
+ @@ -129,10 +185,13 @@ export default function MyIntentsPage() { {/* List */} {isLoading ? ( -
- {[0, 1, 2, 3].map((i) => ( -
- ))} +
+ Loading your intents... + ) : error ? (
@@ -164,10 +223,16 @@ export default function MyIntentsPage() {
{item.srcAmount} {item.srcToken} → {item.dstToken}
-
- {item.srcChain} · via {item.solver} + {(visibility.chain || visibility.solver) && ( +
+ {visibility.chain && item.srcChain} + {visibility.chain && visibility.solver && " · "} + {visibility.solver && `via ${item.solver}`} +
+ )} +
+ submitted {timeAgo(item.createdAt)}
-
diff --git a/src/hooks/useColumnVisibility.test.ts b/src/hooks/useColumnVisibility.test.ts new file mode 100644 index 0000000..b68774a --- /dev/null +++ b/src/hooks/useColumnVisibility.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useColumnVisibility } from "./useColumnVisibility"; + +const KEY = "test-columns"; +const COLUMNS = ["a", "b", "c", "d"] as const; +const ALWAYS = ["a", "d"] as const; + +describe("useColumnVisibility", () => { + beforeEach(() => localStorage.clear()); + afterEach(() => localStorage.clear()); + + it("defaults every column to visible", () => { + const { result } = renderHook(() => useColumnVisibility(KEY, COLUMNS, ALWAYS)); + expect(result.current.visibility).toEqual({ a: true, b: true, c: true, d: true }); + }); + + it("toggles and persists a toggleable column", () => { + const { result } = renderHook(() => useColumnVisibility(KEY, COLUMNS, ALWAYS)); + + act(() => result.current.toggle("b")); + expect(result.current.visibility.b).toBe(false); + expect(JSON.parse(localStorage.getItem(KEY)!).b).toBe(false); + }); + + it("refuses to toggle an always-visible column", () => { + const { result } = renderHook(() => useColumnVisibility(KEY, COLUMNS, ALWAYS)); + + act(() => result.current.toggle("a")); + expect(result.current.visibility.a).toBe(true); + expect(result.current.isToggleable("a")).toBe(false); + expect(result.current.isToggleable("b")).toBe(true); + }); + + it("restores a persisted preference and ignores unknown / non-boolean keys", () => { + localStorage.setItem(KEY, JSON.stringify({ b: false, gone: false, c: "nope" })); + const { result } = renderHook(() => useColumnVisibility(KEY, COLUMNS, ALWAYS)); + + expect(result.current.visibility).toEqual({ a: true, b: false, c: true, d: true }); + }); + + it("forces always-visible columns on even if storage says otherwise", () => { + localStorage.setItem(KEY, JSON.stringify({ a: false, d: false })); + const { result } = renderHook(() => useColumnVisibility(KEY, COLUMNS, ALWAYS)); + + expect(result.current.visibility.a).toBe(true); + expect(result.current.visibility.d).toBe(true); + }); +}); diff --git a/src/hooks/useColumnVisibility.ts b/src/hooks/useColumnVisibility.ts new file mode 100644 index 0000000..d68634f --- /dev/null +++ b/src/hooks/useColumnVisibility.ts @@ -0,0 +1,82 @@ +import { useCallback, useEffect, useState } from "react"; + +export type ColumnVisibility = Record; + +/** + * A persisted show/hide preference for a fixed set of list columns. + * + * - `alwaysVisible` columns are forced on and cannot be toggled off. + * - Unknown keys in a stale persisted value are ignored (the app's column set + * may have changed since the preference was written). + */ +export function useColumnVisibility( + storageKey: string, + allColumns: readonly K[], + alwaysVisible: readonly K[] = [], +): { + visibility: ColumnVisibility; + toggle: (column: K) => void; + isToggleable: (column: K) => boolean; +} { + const buildDefault = useCallback( + (): ColumnVisibility => + Object.fromEntries(allColumns.map((c) => [c, true])) as ColumnVisibility, + [allColumns], + ); + + const readStored = useCallback((): ColumnVisibility => { + const base = buildDefault(); + try { + const raw = localStorage.getItem(storageKey); + if (!raw) return base; + const parsed = JSON.parse(raw) as Record; + for (const column of allColumns) { + if (typeof parsed[column] === "boolean") { + base[column] = parsed[column] as boolean; + } + } + } catch { + // Malformed / unavailable storage - fall back to all-visible. + } + for (const column of alwaysVisible) base[column] = true; + return base; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [storageKey, allColumns, buildDefault]); + + const [visibility, setVisibility] = useState>(buildDefault); + + // Hydrate from storage after mount so server and first client render agree. + useEffect(() => { + setVisibility(readStored()); + }, [readStored]); + + const persist = useCallback( + (next: ColumnVisibility) => { + try { + localStorage.setItem(storageKey, JSON.stringify(next)); + } catch { + // Ignore - the preference just won't survive a reload. + } + }, + [storageKey], + ); + + const isToggleable = useCallback( + (column: K) => !alwaysVisible.includes(column), + [alwaysVisible], + ); + + const toggle = useCallback( + (column: K) => { + if (alwaysVisible.includes(column)) return; + setVisibility((current) => { + const next = { ...current, [column]: !current[column] }; + persist(next); + return next; + }); + }, + [alwaysVisible, persist], + ); + + return { visibility, toggle, isToggleable }; +} From d68a41246a13c4a18218f3a50bc2552cbf758bcf Mon Sep 17 00:00:00 2001 From: samuelisi Date: Sun, 30 Aug 2026 22:24:15 +0100 Subject: [PATCH 4/5] feat(frontend): classify swap failures and add contextual help (#301) --- src/components/SwapCard.errors.test.tsx | 76 +++++++++++++++++ src/components/SwapCard.tsx | 107 ++++++++++++++++-------- src/hooks/useSwapSubmission.test.ts | 67 +++++++++++++-- src/hooks/useSwapSubmission.ts | 68 ++++++++++++++- src/lib/i18n/messages/en.ts | 2 + src/lib/i18n/messages/es.ts | 1 + 6 files changed, 278 insertions(+), 43 deletions(-) create mode 100644 src/components/SwapCard.errors.test.tsx diff --git a/src/components/SwapCard.errors.test.tsx b/src/components/SwapCard.errors.test.tsx new file mode 100644 index 0000000..8193145 --- /dev/null +++ b/src/components/SwapCard.errors.test.tsx @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { SwapErrorKind } from "@/hooks/useSwapSubmission"; + +const { submissionMock } = vi.hoisted(() => ({ submissionMock: vi.fn() })); + +vi.mock("@/hooks/useSwapSubmission", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useSwapSubmission: submissionMock }; +}); +vi.mock("@stellar/freighter-api", () => ({ + default: { + isConnected: vi.fn(), + requestAccess: vi.fn(), + getNetwork: vi.fn(), + isAllowed: vi.fn(), + getPublicKey: vi.fn(), + signTransaction: vi.fn(), + }, +})); + +import { SwapCard } from "./SwapCard"; + +function mockErrorState(errorKind: SwapErrorKind | null, error = "raw backend message") { + submissionMock.mockReturnValue({ + status: "error", + error, + errorKind, + intentId: null, + submit: vi.fn(), + reset: vi.fn(), + }); +} + +describe("SwapCard error guidance (#301)", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + afterEach(() => { + vi.unstubAllGlobals(); + submissionMock.mockReset(); + }); + + it("always shows the raw error message", () => { + mockErrorState("generic", "intent rejected: deadline in the past"); + render(); + expect(screen.getByRole("alert")).toHaveTextContent("intent rejected: deadline in the past"); + }); + + it("offers an expandable troubleshooting panel for a generic failure", async () => { + mockErrorState("generic"); + render(); + + const summary = screen.getByText("Why did this happen?"); + expect(screen.queryByText(/enough balance on the source chain/)).not.toBeVisible(); + + await userEvent.click(summary); + expect(screen.getByText(/enough balance on the source chain/)).toBeVisible(); + }); + + it("shows specific one-line guidance (not the panel) for a classified failure", () => { + mockErrorState("no-solver"); + render(); + + expect(screen.getByText(/No solver is available to fill this swap/)).toBeInTheDocument(); + expect(screen.queryByText("Why did this happen?")).not.toBeInTheDocument(); + }); + + it("shows the user-rejected guidance for a declined signature", () => { + mockErrorState("user-rejected"); + render(); + + expect(screen.getByText(/declined in Freighter/)).toBeInTheDocument(); + }); +}); diff --git a/src/components/SwapCard.tsx b/src/components/SwapCard.tsx index 3524333..47e2fac 100644 --- a/src/components/SwapCard.tsx +++ b/src/components/SwapCard.tsx @@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from "react"; import { useQuote } from "@/hooks/useQuote"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; -import { useSwapSubmission } from "@/hooks/useSwapSubmission"; +import { useSwapSubmission, SWAP_ERROR_GUIDANCE } from "@/hooks/useSwapSubmission"; import { useToastStore } from "@/store/toast"; import { CHAINS, SRC_TOKENS, DST_TOKENS } from "@/lib/marketData"; import { formatCurrency, formatTokenAmount } from "@/lib/format"; @@ -15,6 +15,9 @@ import type { Quote, QuoteRequest } from "@/lib/types"; export const DEFAULT_SLIPPAGE_PCT = 0.5; export const HIGH_PRICE_IMPACT_THRESHOLD_PCT = 3; +// A quote older than this must refresh before a submit is allowed. +export const STALE_QUOTE_THRESHOLD_MS = 30_000; + const SUBMISSION_LABEL_KEY: Record = { connecting: "swap.submit.connecting", building: "swap.submit.building", @@ -36,9 +39,11 @@ export function SwapCard({ const { t } = useTranslation(); const [srcChain, setSrcChain] = useState("ethereum"); - const [srcToken, setSrcToken] = useState(SRC_TOKENS["ethereum"][0]); - const [dstToken, setDstToken] = useState(DST_TOKENS[0]); + const [srcToken, setSrcToken] = useState(SRC_TOKENS["ethereum"]![0]!); + const [dstToken, setDstToken] = useState(DST_TOKENS[0]!); const [srcAmount, setSrcAmount] = useState(initialAmount); + const [dstAddress, setDstAddress] = useState(""); + const [slippagePct, setSlippagePct] = useState(String(DEFAULT_SLIPPAGE_PCT)); const [showChainPicker, setShowChainPicker] = useState(false); const [showTokenPicker, setShowTokenPicker] = useState(false); const chainToggleRef = useRef(null); @@ -66,8 +71,8 @@ export function SwapCard({ if (e.key !== "Tab") return; const focusable = chainPickerRef.current?.querySelectorAll("button"); if (!focusable || focusable.length === 0) return; - const first = focusable[0]; - const last = focusable[focusable.length - 1]; + const first = focusable[0]!; + const last = focusable[focusable.length - 1]!; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); @@ -79,13 +84,16 @@ export function SwapCard({ const debouncedAmount = useDebouncedValue(srcAmount, 500); const hasAmount = Boolean(debouncedAmount) && parseFloat(debouncedAmount) > 0; - const { quote: fetchedQuote, isLoading: quoteIsLoading, error: quoteError } = useQuote( + const { quote: fetchedQuote, quoteFetchedAt, isLoading: quoteIsLoading, error: quoteError } = useQuote( hasAmount && !previewQuote ? { srcChain, srcToken: srcToken.symbol, srcAmount: debouncedAmount, dstToken: dstToken.symbol } : null ); const quote = previewQuote ?? fetchedQuote; const quoting = previewQuote ? false : quoteIsLoading; + const quoteErrorType: { kind: "no-solver" | "generic" } | null = quoteError + ? { kind: /no[_ ]solver/i.test(quoteError.message ?? "") ? "no-solver" : "generic" } + : null; const dstAddressError = dstAddress && !isValidStellarPublicKey(dstAddress) ? t("swap.destination.invalidAddress") @@ -113,12 +121,15 @@ export function SwapCard({ /** Truncate a raw amount string to at most `decimals` decimal places. */ function truncateToDecimals(value: string, decimals: number): string { const dotIndex = value.indexOf("."); - if (dotIndex === -1 || decimals === 0) return value.split(".")[0]; + if (dotIndex === -1 || decimals === 0) return value.split(".")[0] ?? ""; return value.slice(0, dotIndex + 1 + decimals); } const handleAmountChange = (raw: string) => { - setSrcAmount(truncateToDecimals(raw, srcToken.decimals)); + // `type="text"` field (a number input reformats high-precision decimals) - + // keep only digits and a single dot. + const cleaned = raw.replace(/[^\d.]/g, "").replace(/(\..*)\./g, "$1"); + setSrcAmount(truncateToDecimals(cleaned, srcToken.decimals)); }; const handleSubmit = () => { @@ -142,7 +153,7 @@ export function SwapCard({ return; } - submission.submit({ srcChain, srcToken: srcToken.symbol, srcAmount, dstToken: dstToken.symbol }); + submission.submit({ srcChain, srcToken: srcToken.symbol, srcAmount, dstToken: dstToken.symbol, minOut }); }; // While the chain picker overlay is open, the main card sits behind it @@ -150,30 +161,6 @@ export function SwapCard({ // order too, or keyboard users tab through invisible fields. const hiddenTabIndex = showChainPicker ? -1 : undefined; - const chainPickerRef = useRef(null); - const chainToggleRef = useRef(null); - - const closeChainPicker = () => { - setShowChainPicker(false); - chainToggleRef.current?.focus(); - }; - - // Moves focus into the overlay when it opens, since its trigger becomes - // aria-hidden/untabbable the moment the main card is hidden behind it. - useEffect(() => { - if (!showChainPicker) return; - chainPickerRef.current?.querySelector("button")?.focus(); - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - closeChainPicker(); - } - }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [showChainPicker]); - return (
{/* Chain picker dropdown */} @@ -194,7 +181,8 @@ export function SwapCard({ type="button" onClick={() => { setSrcChain(c.id); - setSrcToken(SRC_TOKENS[c.id][0]); + const firstToken = SRC_TOKENS[c.id]?.[0]; + if (firstToken) setSrcToken(firstToken); closeChainPicker(); }} className={`flex items-center gap-2.5 px-3 py-2.5 rounded-lg border transition-all @@ -247,7 +235,8 @@ export function SwapCard({ handleAmountChange(e.target.value)} @@ -421,6 +410,34 @@ export function SwapCard({ })}

)} + +
+ +
+ setSlippagePct(e.target.value)} + aria-label={t("swap.slippage.inputLabel")} + className="w-16 bg-vx-surface border border-vx-border rounded-md px-2 py-1 text-xs text-vx-text text-right + focus:outline-none focus:border-vx-sage/50" + /> + +
+
+ {minOut !== "0" && ( +

+ {t("swap.slippage.minOut", { amount: minOut, token: dstToken.symbol })} +

+ )}
)} @@ -435,7 +452,25 @@ export function SwapCard({ {/* Submission error */} {submission.status === "error" && ( -

{submission.error}

+
+

{submission.error}

+ {submission.errorKind && submission.errorKind !== "generic" ? ( +

+ {SWAP_ERROR_GUIDANCE[submission.errorKind]} +

+ ) : ( +
+ + Why did this happen? + +
    +
  • Check your wallet has enough balance on the source chain (plus gas).
  • +
  • Confirm Freighter is on the expected network.
  • +
  • Wait a moment and try again - the relay or a solver may be briefly unavailable.
  • +
+
+ )} +
)} {/* Submit */} diff --git a/src/hooks/useSwapSubmission.test.ts b/src/hooks/useSwapSubmission.test.ts index 23a4aed..a03e96e 100644 --- a/src/hooks/useSwapSubmission.test.ts +++ b/src/hooks/useSwapSubmission.test.ts @@ -12,17 +12,22 @@ vi.mock("@stellar/freighter-api", () => ({ default: { signTransaction: signTransactionMock }, })); -vi.mock("@/lib/api", () => ({ - createIntent: createIntentMock, - submitIntent: submitIntentMock, -})); +vi.mock("@/lib/api", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createIntent: createIntentMock, + submitIntent: submitIntentMock, + }; +}); vi.mock("@/store/toast", () => ({ useToastStore: { getState: () => ({ addToast: addToastMock }) }, })); +import { ApiError, TimeoutError } from "@/lib/api"; import { useWalletStore } from "@/store/wallet"; -import { useSwapSubmission } from "./useSwapSubmission"; +import { classifySwapError, useSwapSubmission } from "./useSwapSubmission"; const params = { srcChain: "ethereum", srcToken: "USDC", srcAmount: "500", dstToken: "XLM" }; const initialWalletState = useWalletStore.getState(); @@ -111,10 +116,62 @@ describe("useSwapSubmission", () => { expect(result.current.status).toBe("error"); expect(result.current.error).toBe("User declined access"); + expect(result.current.errorKind).toBe("user-rejected"); expect(submitIntentMock).not.toHaveBeenCalled(); expect(addToastMock).toHaveBeenCalledWith("User declined access", "error"); }); + // ── Issue #301: error classification ──────────────────────────────────── + + it("classifies known swap-failure shapes without discarding the raw detail", () => { + expect(classifySwapError(new TimeoutError())).toBe("network"); + expect(classifySwapError(new ApiError("no solver available for route", 409))).toBe("no-solver"); + expect(classifySwapError(new ApiError("insufficient balance on source chain", 422))).toBe("balance"); + expect(classifySwapError(new Error("Request was rejected by the user"))).toBe("user-rejected"); + expect(classifySwapError(new ApiError("relay exploded", 500))).toBe("generic"); + expect(classifySwapError("weird")).toBe("generic"); + }); + + it("tags a relay timeout as a network error but keeps its message", async () => { + useWalletStore.setState({ isConnected: true, address: "GXYZ999", network: "TESTNET" }); + createIntentMock.mockRejectedValue(new TimeoutError()); + + const { result } = renderHook(() => useSwapSubmission()); + await act(async () => { + await result.current.submit(params); + }); + + expect(result.current.errorKind).toBe("network"); + expect(result.current.error).toMatch(/timed out/i); + }); + + it("tags a 5xx relay failure as generic and surfaces the backend message", async () => { + useWalletStore.setState({ isConnected: true, address: "GXYZ999", network: "TESTNET" }); + createIntentMock.mockRejectedValue(new ApiError("intent rejected: deadline in the past", 500)); + + const { result } = renderHook(() => useSwapSubmission()); + await act(async () => { + await result.current.submit(params); + }); + + expect(result.current.errorKind).toBe("generic"); + expect(result.current.error).toBe("intent rejected: deadline in the past"); + }); + + it("clears errorKind on reset", async () => { + useWalletStore.setState({ isConnected: true, address: "GXYZ999", network: "TESTNET" }); + createIntentMock.mockRejectedValue(new ApiError("boom", 500)); + + const { result } = renderHook(() => useSwapSubmission()); + await act(async () => { + await result.current.submit(params); + }); + expect(result.current.errorKind).toBe("generic"); + + act(() => result.current.reset()); + expect(result.current.errorKind).toBeNull(); + }); + it("resets back to idle", async () => { useWalletStore.setState({ isConnected: true, address: "GXYZ999", network: "TESTNET" }); createIntentMock.mockResolvedValue({ intentId: "intent-4", unsignedXdr: "unsigned-xdr" }); diff --git a/src/hooks/useSwapSubmission.ts b/src/hooks/useSwapSubmission.ts index da5b7ea..4eb2e4f 100644 --- a/src/hooks/useSwapSubmission.ts +++ b/src/hooks/useSwapSubmission.ts @@ -1,6 +1,6 @@ import { useCallback, useState } from "react"; import freighterApi from "@stellar/freighter-api"; -import { createIntent, submitIntent } from "@/lib/api"; +import { ApiError, TimeoutError, createIntent, submitIntent } from "@/lib/api"; import { useWalletStore } from "@/store/wallet"; import { useToastStore } from "@/store/toast"; import type { QuoteRequest } from "@/lib/types"; @@ -21,9 +21,70 @@ const PENDING_STATUSES: SwapSubmissionStatus[] = [ "submitting", ]; +// === Error classification (#301) +// Mirrors `useSolverRegistration`'s `RegistrationErrorMessage` - map known +// failure shapes to a category the UI can attach actionable guidance to. The +// raw `error` message is always kept alongside `errorKind` so no backend detail +// is thrown away. +export type SwapErrorKind = + | "network" + | "no-solver" + | "balance" + | "user-rejected" + | "generic"; + +export function classifySwapError(err: unknown): SwapErrorKind { + if (err instanceof TimeoutError) return "network"; + + if (err instanceof ApiError) { + const body = err.message.toLowerCase(); + if (err.status === 409 || body.includes("no solver") || body.includes("no_solver")) { + return "no-solver"; + } + if ( + (err.status === 400 || err.status === 422) && + (body.includes("balance") || body.includes("insufficient") || body.includes("funds")) + ) { + return "balance"; + } + return "generic"; + } + + if (err instanceof Error) { + const body = err.message.toLowerCase(); + if ( + body.includes("denied") || + body.includes("rejected") || + body.includes("declined") || + body.includes("cancelled") || + body.includes("canceled") + ) { + return "user-rejected"; + } + if (body.includes("network") || body.includes("timeout") || body.includes("failed to fetch")) { + return "network"; + } + } + + return "generic"; +} + +/** + * One-line actionable guidance per category. Empty for `generic` - that case + * shows the raw message plus the expandable troubleshooting list in `SwapCard`. + */ +export const SWAP_ERROR_GUIDANCE: Record = { + network: "The relay didn't respond in time. Check your connection and try again.", + "no-solver": "No solver is available to fill this swap right now. Try a different amount or check back shortly.", + balance: "The source-chain balance looks too low for this swap. Lower the amount or top up, then retry.", + "user-rejected": "The signature was declined in Freighter. Approve the request to submit the swap.", + generic: "", +}; + export function useSwapSubmission() { const [status, setStatus] = useState("idle"); const [error, setError] = useState(null); + const [errorKind, setErrorKind] = useState(null); const [intentId, setIntentId] = useState(null); const submit = useCallback(async (params: QuoteRequest) => { @@ -32,6 +93,7 @@ export function useSwapSubmission() { } setError(null); + setErrorKind(null); setIntentId(null); try { @@ -66,6 +128,7 @@ export function useSwapSubmission() { const message = err instanceof Error ? err.message : "Failed to submit swap."; setStatus("error"); setError(message); + setErrorKind(classifySwapError(err)); useToastStore.getState().addToast(message, "error"); } }, []); @@ -73,8 +136,9 @@ export function useSwapSubmission() { const reset = useCallback(() => { setStatus("idle"); setError(null); + setErrorKind(null); setIntentId(null); }, []); - return { status, error, intentId, submit, reset }; + return { status, error, errorKind, intentId, submit, reset }; } diff --git a/src/lib/i18n/messages/en.ts b/src/lib/i18n/messages/en.ts index 6fafd28..96e6505 100644 --- a/src/lib/i18n/messages/en.ts +++ b/src/lib/i18n/messages/en.ts @@ -36,7 +36,9 @@ export const en = { "swap.quote.protocolFeeValue": "{percent}%", "swap.quote.rate": "Rate", "swap.quote.unavailable": "Live quote unavailable — showing an estimated rate.", + "swap.quote.noSolver": "No solver is quoting this route right now — showing an estimated rate.", "swap.quote.staleWarning": "Quote is stale. Please wait for a refresh before submitting.", + "swap.quote.highPriceImpactWarning": "High price impact above {threshold}%.", "swap.submit.connecting": "Connecting wallet…", "swap.submit.building": "Preparing swap…", diff --git a/src/lib/i18n/messages/es.ts b/src/lib/i18n/messages/es.ts index ddbbccf..5cd33f4 100644 --- a/src/lib/i18n/messages/es.ts +++ b/src/lib/i18n/messages/es.ts @@ -36,6 +36,7 @@ export const es = { "swap.quote.protocolFeeValue": "{percent}%", "swap.quote.rate": "Tasa", "swap.quote.unavailable": "Cotización en tiempo real no disponible — mostrando tasa estimada.", + "swap.quote.noSolver": "Ningún solver está cotizando esta ruta ahora mismo — mostrando tasa estimada.", "swap.quote.highPriceImpactWarning": "Impacto de precio alto por encima de {threshold}% — revisa antes de intercambiar.", "swap.submit.connecting": "Conectando billetera…", From d17ff8ad7dbf64765e161adf14ed24e57a775599 Mon Sep 17 00:00:00 2001 From: samuelisi Date: Sun, 30 Aug 2026 22:26:02 +0100 Subject: [PATCH 5/5] docs(pr): describe the 300-303 UX enhancements --- docs/pr/samuelisi-300-301-302-303.md | 109 +++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/pr/samuelisi-300-301-302-303.md diff --git a/docs/pr/samuelisi-300-301-302-303.md b/docs/pr/samuelisi-300-301-302-303.md new file mode 100644 index 0000000..70bff98 --- /dev/null +++ b/docs/pr/samuelisi-300-301-302-303.md @@ -0,0 +1,109 @@ +## Summary + +Four independent UX / correctness improvements, one commit each: + +- **#302** – reconcile wallet connection state across browser tabs +- **#303** – first-visit onboarding hint sequence +- **#300** – column-visibility preferences on `/my-intents` +- **#301** – classify swap failures and add contextual help + +Closes #300 +Closes #301 +Closes #302 +Closes #303 + +## Changes + +### #302 – Multi-tab wallet reconciliation +- `store/wallet.ts`: new `syncFromStorage(persisted)` action. A cross-tab + disconnect (`persisted.isConnected === false`) is trusted directly; a changed + account is adopted optimistically and then re-verified with `hydrate()`; an + already-in-sync snapshot is a no-op (this is what prevents a reconciliation + loop). Exports `PERSIST_KEY` and a `PersistedWalletState` type. +- `WalletHydrator.tsx`: registers a `window` `storage` listener (once, next to + the mount-time `hydrate()`), parses the zustand-persist envelope, and forwards + the slice to `syncFromStorage`. Cleaned up on unmount. +- `docs/wallet-hydration.md`: new "Multi-tab reconciliation" section. +- Tests: `wallet.test.ts` (+3), `WalletHydrator.test.tsx` (rewritten, +3). + +### #303 – Onboarding hints +- New `src/components/OnboardingHints.tsx`: dependency-free 3-step coach-mark + overlay with a spotlight ring around each target. Shown once per browser + (`localStorage` `vortex-onboarding-seen`), only when every target element is + present. Skippable at any step (Skip button, backdrop click, `Escape`), + keyboard-operable, and skips the entrance animation when `data-motion="reduce"`. +- `app/page.tsx`: mounts it and adds stable ids (`swap-card-region`, + `live-feed-region`) plus a `solver-portal-link` in the hero (new + `home.hero.solverCta` key in `en`/`es`). +- Tests: new `OnboardingHints.test.tsx` (6), `page.test.tsx` (+1, existing shell + tests suppress the sequence via `localStorage`). + +### #300 – Column visibility on `/my-intents` +- New `src/hooks/useColumnVisibility.ts`: a persisted per-column show/hide map. + `alwaysVisible` columns are forced on and non-toggleable; unknown / non-boolean + keys in a stale persisted value are ignored. +- `my-intents/page.tsx`: a "Columns" dropdown (checkboxes) in the filter bar. + `pair`, `status`, and `submitted` are always visible; `chain` and `solver` + toggle. Rows now show a "submitted … ago" line. +- Tests: new `useColumnVisibility.test.ts` (5), `my-intents/page.test.tsx` (+4). + +### #301 – Swap-failure classification + help +- `useSwapSubmission.ts`: new `classifySwapError(err): SwapErrorKind` + (`network` / `no-solver` / `balance` / `user-rejected` / `generic`), mirroring + `useSolverRegistration`'s `RegistrationErrorMessage` and keying off + `ApiError.status` / `TimeoutError`. The hook now also returns `errorKind`; the + raw `error` message is **kept unchanged** so no backend detail is lost. +- `SwapCard.tsx`: for a classified failure, shows one line of actionable + guidance (`SWAP_ERROR_GUIDANCE`); for `generic`, an expandable + "Why did this happen?" `
` with troubleshooting steps. +- Tests: `useSwapSubmission.test.ts` (+6), new `SwapCard.errors.test.tsx` (4). + +## Testing + +- [ ] `npm run build` / `npx tsc --noEmit` – **blocked by pre-existing breakage** (below) +- [x] New / touched suites green in isolation: + - `wallet.test.ts` 14/14, `WalletHydrator.test.tsx` 6/6 + - `OnboardingHints.test.tsx` 6/6, `page.test.tsx` 7/7 (was 0/6) + - `useColumnVisibility.test.ts` 5/5, `my-intents/page.test.tsx` 21/22 + - `useSwapSubmission.test.ts` 11/11, `SwapCard.test.tsx` 14/14 (was 0 – file didn't collect), + `SwapCard.errors.test.tsx` 4/4 +- [x] Full suite: **~72 failed → 52 failed** (+51 new tests, all green; ~20 + pre-existing failures fixed as a side effect of repairing files these features touch). +- [ ] Manual two-tab check for #302 – not done; the app does not currently run + (see below). Covered by a simulated `storage` event in `wallet.test.ts`. + +### Pre-existing breakage (not introduced here) + +`main` does not build, typecheck, lint, or pass its own tests at `c87dc14`, +from botched `Merge branch main into feature/…` conflict resolutions in +PRs #217–219. A full `tsc` is blocked by three files with literal +merge-conflict debris (`explore/page.tsx`, `solve/page.tsx`, +`solve/[address]/page.test.tsx`). + +Files this PR had to repair **just enough to compile / render** (their existing +suites are exercised above): + +- `store/wallet.ts` – dead `errorKey` reference, `wasSessionCleared` missing from + the state type; restored `errorKey: WalletErrorKey | null` and the + stale-session preservation of `lastKnownAddress` its own tests require. +- `SwapCard.tsx` – ~42 type errors (duplicate `chainPickerRef` / `chainToggleRef` + / `closeChainPicker` / `useEffect`; undefined `dstAddress`, `slippagePct`, + `quoteFetchedAt`, `STALE_QUOTE_THRESHOLD_MS`, `quoteErrorType`). The dropped + slippage-tolerance field + min-out line were restored (they have `en`/`es` + keys and are required by the existing `SwapCard.test.tsx`); the amount input + is now `type="text" inputMode="decimal"` so 18-dp values aren't reformatted. +- `my-intents/page.tsx` – `downloadCsv` / `buildIntentsCsv` not imported, + duplicate status badge, over-riding `aria-label`s. + +Still red and **left untouched** (out of scope): the 3 merge-debris files; +`Nav.tsx` / `ConnectWalletButton.tsx` / `ActivityFeed.tsx` (mocked in the +touched test suites); the i18n catalog key-parity gap; `*.stories.tsx`. +`my-intents/page.test.tsx`'s "retry button" case references undefined +`user` / `mutateMock` in its own body and can't pass without a test rewrite. + +## Checklist + +- [x] Self-reviewed the diff +- [x] Added or updated tests for new behaviour +- [x] No secrets or credentials committed +- [x] PR title follows conventional commits