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 ( +
  1. +
  2. + ); + })} +
+ ); +} 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,