Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app/explore/ExplorePageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
11 changes: 5 additions & 6 deletions src/app/my-intents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -131,12 +132,10 @@ export default function MyIntentsPage() {
</div>

{!isConnected ? (
<div className="card p-8 text-center">
<p className="text-sm text-vx-muted mb-4">
Connect your wallet to view your swap history.
</p>
<ConnectWalletButton />
</div>
<EmptyState
message="Connect your wallet to view your swap history."
action={<ConnectWalletButton />}
/>
) : (
<>
{/* Filters */}
Expand Down
7 changes: 6 additions & 1 deletion src/app/solve/SolvePageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -584,6 +585,10 @@ export default function SolvePageClient() {
<div>{t("solve.register.info.withdraw")}</div>
</div>

{registration.status !== "idle" && registration.status !== "success" && (
<SubmissionStepper status={registration.status} errorStep={registration.errorStep} />
)}

{registration.status === "error" && (
<p role="alert" className="text-xs text-red-400">
{registration.error}
Expand Down
12 changes: 3 additions & 9 deletions src/app/solve/[address]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,22 +39,16 @@ export default function SolverDetailPage({ params }: { params: { address: string
</Link>

{!isValidAddress ? (
<div role="alert" className="card p-6 sm:p-8 text-center text-sm text-vx-muted">
Invalid solver address format.
</div>
<EmptyState variant="error" message="Invalid solver address format." />
) : isLoading ? (
<div className="card p-6 sm:p-8 space-y-3">
<div className="h-6 w-2/3 bg-vx-surface rounded animate-pulse" />
<div className="h-4 w-1/3 bg-vx-surface rounded animate-pulse" />
</div>
) : error ? (
<div role="alert" className="card p-6 sm:p-8 text-center text-sm text-vx-muted">
Couldn&apos;t load solver details right now. Try again shortly.
</div>
<EmptyState variant="error" message="Couldn't load solver details right now. Try again shortly." />
) : !solver ? (
<div role="alert" className="card p-6 sm:p-8 text-center text-sm text-vx-muted">
No solver found at that address.
</div>
<EmptyState variant="error" message="No solver found at that address." />
) : (
<>
{/* Header card */}
Expand Down
2 changes: 2 additions & 0 deletions src/components/ConnectWalletButton.tsx
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
12 changes: 7 additions & 5 deletions src/components/EmptyState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="card p-8 text-center">
<div role={variant === "error" ? "alert" : "status"} className="card p-8 text-center">
{icon && <div className="mb-3 flex justify-center text-vx-sage">{icon}</div>}
<h2 className="text-base font-semibold text-vx-text">{title}</h2>
<p className="mt-2 text-sm text-vx-muted">{message}</p>
{title && <h2 className="text-base font-semibold text-vx-text">{title}</h2>}
<p className={`text-sm text-vx-muted ${title ? "mt-2" : ""}`}>{message}</p>
{action && <div className="mt-4">{action}</div>}
</div>
);
Expand Down
1 change: 1 addition & 0 deletions src/components/Nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down
8 changes: 8 additions & 0 deletions src/components/SettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -22,6 +23,13 @@ export function SettingsPanel() {
const [motionPreference, setMotionPreference] = useState<MotionPreference>("system");
const locale = useLocale();
const setLocale = useSetLocale();
const toggleRef = useRef<HTMLButtonElement>(null);
const closeSettings = useCallback(() => setOpen(false), []);
const panelRef = useDismissableOverlay<HTMLDivElement>({
isOpen: open,
onClose: closeSettings,
triggerRef: toggleRef,
});

const toggleRef = useRef<HTMLButtonElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
Expand Down
79 changes: 79 additions & 0 deletions src/components/SubmissionStepper.tsx
Original file line number Diff line number Diff line change
@@ -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<StepId, string> = {
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 (
<ol className="flex items-start gap-2" aria-label="Submission progress">
{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 (
<li
key={step}
className="flex-1 flex flex-col items-center gap-1.5"
aria-current={isCurrent || isErrored ? "step" : undefined}
>
<div
aria-hidden="true"
className={`w-full h-1.5 rounded-full transition-colors ${
isErrored
? "bg-red-400"
: isComplete
? "bg-vx-sage"
: isCurrent
? "bg-vx-sage/60 animate-pulse"
: "bg-vx-line"
}`}
/>
<span
className={`text-[10px] font-medium text-center ${
isErrored ? "text-red-400" : isCurrent || isComplete ? "text-vx-text" : "text-vx-dim"
}`}
>
{STEP_LABELS[step]}
</span>
{isAwaitingSignature && (
<span className="text-[10px] text-vx-sage text-center leading-tight">
Check your wallet extension
</span>
)}
{isErrored && (
<span className="sr-only">Failed at this step</span>
)}
</li>
);
})}
</ol>
);
}
1 change: 0 additions & 1 deletion src/components/SwapCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<div className="eyebrow mb-3 px-1">{t("swap.chainPicker.title")}</div>
Expand Down
72 changes: 72 additions & 0 deletions src/hooks/useDismissableOverlay.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>;
};

/**
* 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<T extends HTMLElement>({
isOpen,
onClose,
triggerRef,
}: UseDismissableOverlayOptions) {
const containerRef = useRef<T>(null);

useEffect(() => {
if (!isOpen) return;

containerRef.current?.querySelector<HTMLElement>(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<HTMLElement>(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;
}
2 changes: 1 addition & 1 deletion src/hooks/useQuote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
25 changes: 18 additions & 7 deletions src/hooks/useSolverRegistration.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -49,22 +49,30 @@ function RegistrationErrorMessage(err: unknown): string {
export function useSolverRegistration() {
const [status, setStatus] = useState<SolverRegistrationStatus>("idle");
const [error, setError] = useState<string | null>(null);
const [errorStep, setErrorStep] = useState<SolverRegistrationStatus | null>(null);
const stepRef = useRef<SolverRegistrationStatus>("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) {
throw new Error(wallet.error ?? "Connect a wallet to register as a solver.");
}
}

setStatus("building");
advance("building");
const { registrationId, unsignedXdr } = await registerSolver({ address, bondUsd });

// ── #244: XDR review step ──────────────────────────────────────────────
Expand All @@ -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 };
}
Loading
Loading