diff --git a/src/app/explore/ExplorePageClient.tsx b/src/app/explore/ExplorePageClient.tsx
index 695f5e4..acfa22e 100644
--- a/src/app/explore/ExplorePageClient.tsx
+++ b/src/app/explore/ExplorePageClient.tsx
@@ -8,6 +8,7 @@ import { Nav } from "@/components/Nav";
import { Footer } from "@/components/Footer";
import { IntentStatusBadge } from "@/components/IntentStatusBadge";
import { SkeletonCard } from "@/components/Skeleton";
+import { EmptyState } from "@/components/EmptyState";
import { useLiveIntents } from "@/hooks/useLiveIntents";
import { useTranslation } from "@/lib/i18n/I18nProvider";
import { timeAgo } from "@/lib/time";
diff --git a/src/app/my-intents/page.tsx b/src/app/my-intents/page.tsx
index fd7d58f..ddd8a4f 100644
--- a/src/app/my-intents/page.tsx
+++ b/src/app/my-intents/page.tsx
@@ -6,6 +6,7 @@ import { Nav } from "@/components/Nav";
import { Footer } from "@/components/Footer";
import { IntentStatusBadge } from "@/components/IntentStatusBadge";
import { ConnectWalletButton } from "@/components/ConnectWalletButton";
+import { EmptyState } from "@/components/EmptyState";
import { useWalletStore } from "@/store/wallet";
import { useMyLiveIntents } from "@/hooks/useMyLiveIntents";
import { useIntent } from "@/hooks/useIntent";
@@ -131,12 +132,10 @@ export default function MyIntentsPage() {
{!isConnected ? (
-
-
- Connect your wallet to view your swap history.
-
-
-
+ }
+ />
) : (
<>
{/* Filters */}
diff --git a/src/app/solve/SolvePageClient.tsx b/src/app/solve/SolvePageClient.tsx
index 011af8f..a763039 100644
--- a/src/app/solve/SolvePageClient.tsx
+++ b/src/app/solve/SolvePageClient.tsx
@@ -105,8 +105,9 @@ export default function SolvePageClient() {
bond && (isNaN(parseFloat(bond)) || parseFloat(bond) < MIN_BOND_USD)
? t("solve.register.validation.minimumBond", { minBond: MIN_BOND_USD })
: null;
+ const networkMismatch = useWalletStore((s) => s.networkMismatch);
const canRegister =
- Boolean(address) && Boolean(bond) && !addressError && !bondError && !isRegistering;
+ Boolean(address) && Boolean(bond) && !addressError && !bondError && !isRegistering && !networkMismatch;
const sortedSolvers = [...solvers].sort((a, b) => {
if (!sortKey || sortDir === "none") return 0;
@@ -584,6 +585,10 @@ export default function SolvePageClient() {
{t("solve.register.info.withdraw")}
+ {registration.status !== "idle" && registration.status !== "success" && (
+
+ )}
+
{registration.status === "error" && (
{registration.error}
diff --git a/src/app/solve/[address]/page.tsx b/src/app/solve/[address]/page.tsx
index 5df6229..5ce9fbc 100644
--- a/src/app/solve/[address]/page.tsx
+++ b/src/app/solve/[address]/page.tsx
@@ -39,22 +39,16 @@ export default function SolverDetailPage({ params }: { params: { address: string
{!isValidAddress ? (
-
- Invalid solver address format.
-
+
) : isLoading ? (
) : error ? (
-
- Couldn't load solver details right now. Try again shortly.
-
+
) : !solver ? (
-
- No solver found at that address.
-
+
) : (
<>
{/* Header card */}
diff --git a/src/components/ConnectWalletButton.tsx b/src/components/ConnectWalletButton.tsx
index 27cea27..77141e2 100644
--- a/src/components/ConnectWalletButton.tsx
+++ b/src/components/ConnectWalletButton.tsx
@@ -1,9 +1,11 @@
"use client";
+import { useEffect } from "react";
import { useWalletStore } from "@/store/wallet";
import { useToastStore } from "@/store/toast";
const FREIGHTER_INSTALL_URL = "https://www.freighter.app/";
+const NETWORK_CHECK_INTERVAL_MS = 8000;
export function ConnectWalletButton({ compact = false }: { compact?: boolean }) {
const { t } = useTranslation();
diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx
index 2f3b46b..ab0995b 100644
--- a/src/components/EmptyState.tsx
+++ b/src/components/EmptyState.tsx
@@ -2,17 +2,19 @@ import type { ReactNode } from "react";
type EmptyStateProps = {
icon?: ReactNode;
- title: string;
+ title?: string;
message: string;
action?: ReactNode;
+ /** Controls the ARIA role: "error" surfaces role="alert", "empty" (default) role="status". */
+ variant?: "empty" | "error";
};
-export function EmptyState({ icon, title, message, action }: EmptyStateProps) {
+export function EmptyState({ icon, title, message, action, variant = "empty" }: EmptyStateProps) {
return (
-
+
{icon &&
{icon}
}
-
{title}
-
{message}
+ {title &&
{title}
}
+
{message}
{action &&
{action}
}
);
diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx
index 34abffc..fe791e4 100644
--- a/src/components/Nav.tsx
+++ b/src/components/Nav.tsx
@@ -8,6 +8,7 @@ import { ConnectWalletButton } from "./ConnectWalletButton";
import { SettingsPanel } from "./SettingsPanel";
import { getMessage } from "@/lib/i18n-legacy";
import { useWalletStore } from "@/store/wallet";
+import { useDismissableOverlay } from "@/hooks/useDismissableOverlay";
type NavProps = { variant: "home" } | { variant: "breadcrumb"; label: string };
diff --git a/src/components/SettingsPanel.tsx b/src/components/SettingsPanel.tsx
index ef3588f..c49137a 100644
--- a/src/components/SettingsPanel.tsx
+++ b/src/components/SettingsPanel.tsx
@@ -3,6 +3,7 @@
import { useEffect, useRef, useState } from "react";
import { LOCALES, type Locale } from "@/lib/i18n";
import { useLocale, useSetLocale } from "@/lib/i18n/I18nProvider";
+import { useDismissableOverlay } from "@/hooks/useDismissableOverlay";
type MotionPreference = "system" | "reduce" | "allow";
@@ -22,6 +23,13 @@ export function SettingsPanel() {
const [motionPreference, setMotionPreference] = useState
("system");
const locale = useLocale();
const setLocale = useSetLocale();
+ const toggleRef = useRef(null);
+ const closeSettings = useCallback(() => setOpen(false), []);
+ const panelRef = useDismissableOverlay({
+ isOpen: open,
+ onClose: closeSettings,
+ triggerRef: toggleRef,
+ });
const toggleRef = useRef(null);
const panelRef = useRef(null);
diff --git a/src/components/SubmissionStepper.tsx b/src/components/SubmissionStepper.tsx
new file mode 100644
index 0000000..876b6fd
--- /dev/null
+++ b/src/components/SubmissionStepper.tsx
@@ -0,0 +1,79 @@
+const STEP_ORDER = ["connecting", "building", "awaiting-signature", "submitting"] as const;
+type StepId = (typeof STEP_ORDER)[number];
+export type SubmissionStatus = "idle" | StepId | "success" | "error";
+
+const STEP_LABELS: Record = {
+ connecting: "Connect",
+ building: "Build",
+ "awaiting-signature": "Sign",
+ submitting: "Submit",
+};
+
+export type SubmissionStepperProps = {
+ status: SubmissionStatus;
+ /** The step that was active when an error occurred, for the "error" status. */
+ errorStep?: StepId | null;
+};
+
+/**
+ * Visual stepper for hooks with the connecting → building → awaiting-signature
+ * → submitting → success/error state shape (useSwapSubmission,
+ * useSolverRegistration). Renders nothing at rest ("idle").
+ */
+export function SubmissionStepper({ status, errorStep }: SubmissionStepperProps) {
+ if (status === "idle") return null;
+
+ const activeIndex =
+ status === "success"
+ ? STEP_ORDER.length
+ : status === "error"
+ ? STEP_ORDER.indexOf(errorStep ?? STEP_ORDER[STEP_ORDER.length - 1])
+ : STEP_ORDER.indexOf(status);
+
+ return (
+
+ {STEP_ORDER.map((step, index) => {
+ const isCurrent = status !== "success" && status !== "error" && index === activeIndex;
+ const isErrored = status === "error" && index === activeIndex;
+ const isComplete = status === "success" || (index < activeIndex && !isErrored);
+ const isAwaitingSignature = step === "awaiting-signature" && isCurrent;
+
+ return (
+ -
+
+
+ {STEP_LABELS[step]}
+
+ {isAwaitingSignature && (
+
+ Check your wallet extension
+
+ )}
+ {isErrored && (
+ Failed at this step
+ )}
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/SwapCard.tsx b/src/components/SwapCard.tsx
index fa94933..adb4b45 100644
--- a/src/components/SwapCard.tsx
+++ b/src/components/SwapCard.tsx
@@ -296,7 +296,6 @@ export function SwapCard({ initialAmount = "", previewQuote, onPreviewSubmit }:
role="dialog"
aria-modal="true"
aria-label={t("swap.chainPicker.title")}
- onKeyDown={handleChainPickerKeyDown}
className="absolute top-0 left-0 right-0 z-20 bg-vx-card border border-vx-border rounded-xl p-3 shadow-2xl animate-fade-up"
>
{t("swap.chainPicker.title")}
diff --git a/src/hooks/useDismissableOverlay.ts b/src/hooks/useDismissableOverlay.ts
new file mode 100644
index 0000000..8b93119
--- /dev/null
+++ b/src/hooks/useDismissableOverlay.ts
@@ -0,0 +1,72 @@
+import { useEffect, useRef } from "react";
+
+const FOCUSABLE_SELECTOR =
+ "button, a[href], input, select, textarea, [tabindex]:not([tabindex='-1'])";
+
+type UseDismissableOverlayOptions = {
+ isOpen: boolean;
+ onClose: () => void;
+ /** The button that opens the overlay; focus returns here on close. */
+ triggerRef: React.RefObject;
+};
+
+/**
+ * Shared overlay dismissal behavior: closes on Escape (returning focus to the
+ * trigger), closes on outside click/tap, and traps Tab focus within the
+ * overlay while it's open. Used by the chain picker, mobile nav, and
+ * settings dropdown so the three don't reimplement the same logic.
+ */
+export function useDismissableOverlay({
+ isOpen,
+ onClose,
+ triggerRef,
+}: UseDismissableOverlayOptions) {
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ if (!isOpen) return;
+
+ containerRef.current?.querySelector(FOCUSABLE_SELECTOR)?.focus();
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ event.preventDefault();
+ onClose();
+ triggerRef.current?.focus();
+ return;
+ }
+ if (event.key !== "Tab") return;
+ const focusable = containerRef.current?.querySelectorAll(FOCUSABLE_SELECTOR);
+ if (!focusable || focusable.length === 0) return;
+ const first = focusable[0];
+ const last = focusable[focusable.length - 1];
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault();
+ last.focus();
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault();
+ first.focus();
+ }
+ };
+
+ // Ignore clicks on the trigger itself: its own onClick already toggles
+ // the overlay, so also closing it here would immediately reopen it.
+ const handlePointerDown = (event: MouseEvent | TouchEvent) => {
+ const target = event.target as Node;
+ if (containerRef.current?.contains(target)) return;
+ if (triggerRef.current?.contains(target)) return;
+ onClose();
+ };
+
+ document.addEventListener("keydown", handleKeyDown);
+ document.addEventListener("mousedown", handlePointerDown);
+ document.addEventListener("touchstart", handlePointerDown);
+ return () => {
+ document.removeEventListener("keydown", handleKeyDown);
+ document.removeEventListener("mousedown", handlePointerDown);
+ document.removeEventListener("touchstart", handlePointerDown);
+ };
+ }, [isOpen, onClose, triggerRef]);
+
+ return containerRef;
+}
diff --git a/src/hooks/useQuote.ts b/src/hooks/useQuote.ts
index 9c4bdc3..b2e7962 100644
--- a/src/hooks/useQuote.ts
+++ b/src/hooks/useQuote.ts
@@ -15,7 +15,7 @@ function quoteKey(params: QuoteRequest | null): string | null {
return `/quote?${search.toString()}`;
}
-function classifyQuoteError(err: unknown): QuoteErrorType {
+export function classifyQuoteError(err: unknown): QuoteErrorType {
if (err instanceof Error) {
const body = err.message.toLowerCase();
if (
diff --git a/src/hooks/useSolverRegistration.ts b/src/hooks/useSolverRegistration.ts
index 78c4f18..5fe216f 100644
--- a/src/hooks/useSolverRegistration.ts
+++ b/src/hooks/useSolverRegistration.ts
@@ -1,4 +1,4 @@
-import { useCallback, useState } from "react";
+import { useCallback, useRef, useState } from "react";
import { mutate } from "swr";
import { walletAdapter } from "@/lib/wallet";
import { registerSolver, submitSolverRegistration } from "@/lib/api";
@@ -49,14 +49,22 @@ function RegistrationErrorMessage(err: unknown): string {
export function useSolverRegistration() {
const [status, setStatus] = useState("idle");
const [error, setError] = useState(null);
+ const [errorStep, setErrorStep] = useState(null);
+ const stepRef = useRef("idle");
+
+ const advance = useCallback((next: SolverRegistrationStatus) => {
+ stepRef.current = next;
+ setStatus(next);
+ }, []);
const register = useCallback(async (address: string, bondUsd: number) => {
setError(null);
+ setErrorStep(null);
try {
let wallet = useWalletStore.getState();
if (!wallet.isConnected || !wallet.address) {
- setStatus("connecting");
+ advance("connecting");
await wallet.connect();
wallet = useWalletStore.getState();
if (!wallet.isConnected || !wallet.address) {
@@ -64,7 +72,7 @@ export function useSolverRegistration() {
}
}
- setStatus("building");
+ advance("building");
const { registrationId, unsignedXdr } = await registerSolver({ address, bondUsd });
// ── #244: XDR review step ──────────────────────────────────────────────
@@ -90,20 +98,23 @@ export function useSolverRegistration() {
await submitSolverRegistration(registrationId, signedXdr);
await mutate("/solvers");
- setStatus("success");
+ advance("success");
useToastStore.getState().addToast("Registered as a solver.", "success");
} catch (err) {
const message = RegistrationErrorMessage(err);
- setStatus("error");
+ setErrorStep(stepRef.current);
+ advance("error");
setError(message);
useToastStore.getState().addToast(message, "error");
}
- }, []);
+ }, [advance]);
const reset = useCallback(() => {
+ stepRef.current = "idle";
setStatus("idle");
setError(null);
+ setErrorStep(null);
}, []);
- return { status, error, register, reset };
+ return { status, error, errorStep, register, reset };
}
diff --git a/src/hooks/useSwapSubmission.ts b/src/hooks/useSwapSubmission.ts
index 06937ae..d0e465f 100644
--- a/src/hooks/useSwapSubmission.ts
+++ b/src/hooks/useSwapSubmission.ts
@@ -90,6 +90,12 @@ export function useSwapSubmission() {
const [error, setError] = useState(null);
const [errorKind, setErrorKind] = useState(null);
const [intentId, setIntentId] = useState(null);
+ const stepRef = useRef("idle");
+
+ const advance = useCallback((next: SwapSubmissionStatus) => {
+ stepRef.current = next;
+ setStatus(next);
+ }, []);
const submit = useCallback(async (params: QuoteRequest) => {
if (PENDING_STATUSES.includes(status)) {
@@ -103,7 +109,7 @@ export function useSwapSubmission() {
try {
let wallet = useWalletStore.getState();
if (!wallet.isConnected || !wallet.address) {
- setStatus("connecting");
+ advance("connecting");
await wallet.connect();
wallet = useWalletStore.getState();
if (!wallet.isConnected || !wallet.address) {
@@ -111,7 +117,7 @@ export function useSwapSubmission() {
}
}
- setStatus("building");
+ advance("building");
const { intentId: newIntentId, unsignedXdr } = await createIntent({
...params,
dstAddress: wallet.address,
@@ -144,7 +150,7 @@ export function useSwapSubmission() {
setStatus("submitting");
await submitIntent(newIntentId, signedXdr);
- setStatus("success");
+ advance("success");
useToastStore.getState().addToast("Swap submitted successfully.", "success");
} catch (err) {
const message =
@@ -161,6 +167,7 @@ export function useSwapSubmission() {
}, [status]);
const reset = useCallback(() => {
+ stepRef.current = "idle";
setStatus("idle");
setError(null);
setErrorKind(null);
diff --git a/src/store/wallet.ts b/src/store/wallet.ts
index db7c46a..2655f9b 100644
--- a/src/store/wallet.ts
+++ b/src/store/wallet.ts
@@ -123,6 +123,7 @@ export type WalletState = {
* use this to show an install link instead of a generic retry CTA.
*/
notInstalled: boolean;
+ errorKey: WalletErrorKey | null;
connect: () => Promise;
disconnect: () => void;
hydrate: () => Promise;
@@ -147,6 +148,7 @@ export const useWalletStore = create()(
errorKey: null,
networkMismatch: false,
notInstalled: false,
+ errorKey: null,
connect: async () => {
set({
@@ -208,6 +210,29 @@ export const useWalletStore = create()(
}
},
+ checkForChanges: async () => {
+ const state = get();
+ if (!state.isConnected) return;
+ try {
+ const isAppConnected = await freighterApi.isConnected();
+ const allowed = isAppConnected && (await freighterApi.isAllowed());
+ // Don't clear the session here: an extension that's momentarily
+ // locked isn't the same as the user revoking access, and connect()
+ // already owns the "not installed" flow.
+ if (!allowed) return;
+
+ const address = await freighterApi.getPublicKey();
+ const network = await freighterApi.getNetwork();
+ const mismatch = network.toUpperCase() !== EXPECTED_NETWORK;
+
+ if (address !== state.address || network !== state.network || mismatch !== state.networkMismatch) {
+ set({ address, lastKnownAddress: address, network, networkMismatch: mismatch });
+ }
+ } catch {
+ // Freighter unreachable mid-check; leave existing state as-is.
+ }
+ },
+
disconnect: () => {
set({
address: null,