From 041999a5b441fb953cf61873aa0e3be0a05468f7 Mon Sep 17 00:00:00 2001 From: Markodiba Date: Mon, 31 Aug 2026 18:14:12 +0100 Subject: [PATCH] feat: swap-progress stepper, dismissable overlays, EmptyState consolidation, live wallet mismatch detection Closes #235 Closes #234 Closes #233 Closes #232 - Add SubmissionStepper, a shared visual stepper for the connecting/building/ awaiting-signature/submitting state machine, wired into SwapCard and the solver registration tab. The awaiting-signature step calls out "check your wallet", and errors show which step failed. - Add useDismissableOverlay, a shared hook for Escape-to-close, outside-click, and focus-trap behavior; applied to the mobile nav menu and settings dropdown, and used to replace SwapCard's chain-picker overlay logic (which had become duplicated and non-functional after a prior merge) with a single implementation that also gets outside-click support. - Extend EmptyState with a variant prop (role="alert" vs role="status") and consolidate the hand-rolled loading/error/empty markup in ExplorePageClient, my-intents, and solve/[address] onto it. - Add live Freighter account/network mismatch detection: useWalletStore gains checkForChanges(), polled on an interval and on focus/visibility from ConnectWalletButton while connected, without ever calling requestAccess() unprompted. The mismatch banner now offers a one-click reconnect, and canSwap/canRegister block submission while a mismatch is unresolved. While implementing these, found Nav.tsx, SwapCard.tsx, ConnectWalletButton.tsx and solve/[address]/page.tsx in a non-compiling state (undeclared locale/ overlay refs, a duplicated chain-picker effect, missing slippage/stale-quote/ dst-address state, and missing CopyButton/SkeletonCard/isValidStellarPublicKey imports) left over from an incomplete prior merge. Restored them to a working baseline with minimal, non-crashing state so these components render. Pre-existing issues found outside this scope, left unfixed: explore/page.tsx and solve/page.tsx contain duplicated/conflicting content with a leftover merge-conflict artifact, and solve/[address]/page.test.tsx has a syntax error - all 3 unrelated to the assigned issues, but they currently fail project-wide `tsc --noEmit` (and so the pre-commit hook) regardless of what this change touches; --no-verify used for this commit only, by request. --- src/app/explore/ExplorePageClient.tsx | 9 +-- src/app/my-intents/page.tsx | 39 ++++++------ src/app/solve/SolvePageClient.tsx | 9 ++- src/app/solve/[address]/page.tsx | 16 +++-- src/components/ConnectWalletButton.tsx | 48 ++++++++++++--- src/components/EmptyState.tsx | 12 ++-- src/components/Nav.tsx | 17 +++++- src/components/SettingsPanel.tsx | 15 ++++- src/components/SubmissionStepper.tsx | 79 ++++++++++++++++++++++++ src/components/SwapCard.tsx | 85 ++++++++------------------ src/hooks/useDismissableOverlay.ts | 72 ++++++++++++++++++++++ src/hooks/useQuote.ts | 2 +- src/hooks/useSolverRegistration.ts | 29 ++++++--- src/hooks/useSwapSubmission.ts | 29 ++++++--- src/store/wallet.ts | 42 +++++++++++-- 15 files changed, 370 insertions(+), 133 deletions(-) create mode 100644 src/components/SubmissionStepper.tsx create mode 100644 src/hooks/useDismissableOverlay.ts diff --git a/src/app/explore/ExplorePageClient.tsx b/src/app/explore/ExplorePageClient.tsx index 10af3f1..b44d1cb 100644 --- a/src/app/explore/ExplorePageClient.tsx +++ b/src/app/explore/ExplorePageClient.tsx @@ -6,6 +6,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 { timeAgo } from "@/lib/time"; import { CHAINS } from "@/lib/marketData"; @@ -123,13 +124,9 @@ export default function ExplorePageClient() { {isLoading && intents.length === 0 ? ( ) : error ? ( -
- Couldn't load intents right now. Try again shortly. -
+ ) : filtered.length === 0 ? ( -
- No intents match your filters. -
+ ) : ( <>
diff --git a/src/app/my-intents/page.tsx b/src/app/my-intents/page.tsx index 41f9c75..31201fa 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 { CHAINS } from "@/lib/marketData"; @@ -69,12 +70,10 @@ export default function MyIntentsPage() {
{!isConnected ? ( -
-

- Connect your wallet to view your swap history. -

- -
+ } + /> ) : ( <> {/* Filters */} @@ -135,23 +134,21 @@ export default function MyIntentsPage() { ))} ) : error ? ( -
- Couldn't load intents right now. Try again shortly. -
+ ) : intents.length === 0 ? ( -
-

You haven't submitted any swaps yet.

- - Make your first swap - -
+ + Make your first swap + + } + /> ) : filtered.length === 0 ? ( -
- No intents match your filters. -
+ ) : (
{filtered.map((item) => ( diff --git a/src/app/solve/SolvePageClient.tsx b/src/app/solve/SolvePageClient.tsx index 890745c..e700885 100644 --- a/src/app/solve/SolvePageClient.tsx +++ b/src/app/solve/SolvePageClient.tsx @@ -7,6 +7,8 @@ import { useSolvers } from "@/hooks/useSolvers"; import { useOpenIntents } from "@/hooks/useOpenIntents"; import { useAcceptIntent } from "@/hooks/useAcceptIntent"; import { useSolverRegistration } from "@/hooks/useSolverRegistration"; +import { SubmissionStepper } from "@/components/SubmissionStepper"; +import { useWalletStore } from "@/store/wallet"; import { timeRemaining } from "@/lib/time"; import { isValidStellarPublicKey } from "@/lib/stellarAddress"; import { getMessage } from "@/i18n/messages"; @@ -47,8 +49,9 @@ export default function SolvePageClient() { bond && (isNaN(parseFloat(bond)) || parseFloat(bond) < MIN_BOND_USD) ? getMessage("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 handleRegister = () => { if (registration.status === "success") { @@ -374,6 +377,10 @@ export default function SolvePageClient() {
{getMessage("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 0fb501b..7cdb522 100644 --- a/src/app/solve/[address]/page.tsx +++ b/src/app/solve/[address]/page.tsx @@ -4,10 +4,14 @@ import Link from "next/link"; import { Nav } from "@/components/Nav"; import { Footer } from "@/components/Footer"; import { IntentStatusBadge } from "@/components/IntentStatusBadge"; +import { CopyButton } from "@/components/CopyButton"; +import { SkeletonCard } from "@/components/Skeleton"; import { useSolver } from "@/hooks/useSolver"; import { useIntentFeed } from "@/hooks/useIntentFeed"; import { timeAgo } from "@/lib/time"; import { CHAINS } from "@/lib/marketData"; +import { EmptyState } from "@/components/EmptyState"; +import { isValidStellarPublicKey } from "@/lib/stellarAddress"; function truncateAddress(address: string) { if (address.length <= 12) return address; @@ -39,22 +43,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 a0f7150..30c54bb 100644 --- a/src/components/ConnectWalletButton.tsx +++ b/src/components/ConnectWalletButton.tsx @@ -1,10 +1,12 @@ "use client"; +import { useEffect } from "react"; import { useWalletStore } from "@/store/wallet"; import { useToastStore } from "@/store/toast"; import { useTranslation } from "@/lib/i18n/I18nProvider"; const FREIGHTER_INSTALL_URL = "https://www.freighter.app/"; +const NETWORK_CHECK_INTERVAL_MS = 8000; function truncateAddress(address: string) { return `${address.slice(0, 4)}...${address.slice(-4)}`; @@ -13,6 +15,32 @@ function truncateAddress(address: string) { export function ConnectWalletButton({ compact = false }: { compact?: boolean }) { const { address, isConnected, isConnecting, error, networkMismatch, notInstalled, connect, disconnect } = useWalletStore(); + const { t } = useTranslation(); + const displayError = error; + + // Detect a Freighter account/network switch that happens after connect, + // since the extension doesn't push change events. Only polls while + // connected, and never calls requestAccess() — that would pop the + // Freighter approval UI unprompted (see docs/wallet-hydration.md). + useEffect(() => { + if (!isConnected) return; + + const check = () => useWalletStore.getState().checkForChanges(); + check(); + const intervalId = setInterval(check, NETWORK_CHECK_INTERVAL_MS); + + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") check(); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + window.addEventListener("focus", check); + + return () => { + clearInterval(intervalId); + document.removeEventListener("visibilitychange", handleVisibilityChange); + window.removeEventListener("focus", check); + }; + }, [isConnected]); const handleConnect = async () => { await connect(); @@ -41,13 +69,19 @@ export function ConnectWalletButton({ compact = false }: { compact?: boolean }) {networkMismatch && ( -

- ⚠ Wrong network. Switch Freighter to{" "} - {process.env.NEXT_PUBLIC_NETWORK ?? "testnet"}. -

+
+

+ ⚠ Wrong network. Switch Freighter to{" "} + {process.env.NEXT_PUBLIC_NETWORK ?? "testnet"}. +

+ +
)}
); 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 84b02b5..be80db2 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { VortexLogo } from "./VortexLogo"; @@ -10,6 +10,7 @@ import { getMessage } from "@/lib/i18n-legacy"; import { useLocale, useSetLocale } from "@/lib/i18n/I18nProvider"; import { LOCALES, type Locale } from "@/lib/i18n"; import { useWalletStore } from "@/store/wallet"; +import { useDismissableOverlay } from "@/hooks/useDismissableOverlay"; type NavProps = { variant: "home" } | { variant: "breadcrumb"; label: string }; @@ -18,11 +19,25 @@ const NAV_LINKS = [ { href: "/solve", label: "becomeSolver" as const }, ]; +const LOCALE_LABELS: Record = { + en: "English", + es: "Español", +}; + export function Nav(props: NavProps) { const maxWidth = props.variant === "home" ? "max-w-6xl" : "max-w-5xl"; const [mobileOpen, setMobileOpen] = useState(false); const isConnected = useWalletStore((s) => s.isConnected); const pathname = usePathname(); + const locale = useLocale(); + const setLocale = useSetLocale(); + const toggleRef = useRef(null); + const closeMobileMenu = useCallback(() => setMobileOpen(false), []); + const panelRef = useDismissableOverlay({ + isOpen: mobileOpen, + onClose: closeMobileMenu, + triggerRef: toggleRef, + }); return (