-
+
}>
+
+
{/* Supported chains */}
diff --git a/src/app/solve/SolvePageClient.tsx b/src/app/solve/SolvePageClient.tsx
index 890745c..174b2a2 100644
--- a/src/app/solve/SolvePageClient.tsx
+++ b/src/app/solve/SolvePageClient.tsx
@@ -1,12 +1,14 @@
"use client";
-import { useState } from "react";
+import { useEffect, useState } from "react";
import { Nav } from "@/components/Nav";
import { SkeletonCard } from "@/components/Skeleton";
import { useSolvers } from "@/hooks/useSolvers";
import { useOpenIntents } from "@/hooks/useOpenIntents";
import { useAcceptIntent } from "@/hooks/useAcceptIntent";
import { useSolverRegistration } from "@/hooks/useSolverRegistration";
+import { useLocalStorageDraft } from "@/hooks/useLocalStorageDraft";
+import { useWalletStore } from "@/store/wallet";
import { timeRemaining } from "@/lib/time";
import { isValidStellarPublicKey } from "@/lib/stellarAddress";
import { getMessage } from "@/i18n/messages";
@@ -21,6 +23,12 @@ const usdCompact = (value: number) =>
const MIN_BOND_USD = 50;
+/** Shape of the persisted registration draft. */
+type RegistrationDraft = {
+ address: string;
+ bond: string;
+};
+
const REGISTRATION_LABEL: Record = {
connecting: getMessage("solve.register.states.connecting"),
building: getMessage("solve.register.states.building"),
@@ -34,11 +42,37 @@ export default function SolvePageClient() {
const { intents: openIntents, isLoading: intentsLoading, error: intentsError } = useOpenIntents();
const { accept, acceptingId, error: acceptError } = useAcceptIntent();
- const [address, setAddress] = useState("");
- const [bond, setBond] = useState("");
+ // Draft persistence — scoped to the currently connected wallet so that
+ // switching wallets never silently restores the wrong address.
+ const connectedAddress = useWalletStore((s) => s.address);
+ const [draft, setDraft, clearDraft] = useLocalStorageDraft(
+ "vortex:solver-registration-draft",
+ connectedAddress ?? null,
+ );
+
+ const [address, setAddress] = useState(draft?.address ?? "");
+ const [bond, setBond] = useState(draft?.bond ?? "");
+
+ // Sync form fields into the draft whenever they change.
+ const handleAddressChange = (value: string) => {
+ setAddress(value);
+ setDraft({ address: value, bond });
+ };
+ const handleBondChange = (value: string) => {
+ setBond(value);
+ setDraft({ address, bond: value });
+ };
+
const registration = useSolverRegistration();
const isRegistering = registration.status in REGISTRATION_LABEL;
+ // Clear draft after successful submission.
+ useEffect(() => {
+ if (registration.status === "success") {
+ clearDraft();
+ }
+ }, [registration.status, clearDraft]);
+
const addressError =
address && !isValidStellarPublicKey(address)
? getMessage("solve.register.validation.invalidAddress")
@@ -55,6 +89,7 @@ export default function SolvePageClient() {
registration.reset();
setAddress("");
setBond("");
+ clearDraft();
return;
}
if (!canRegister) return;
@@ -330,7 +365,7 @@ export default function SolvePageClient() {
id="solver-address"
type="text"
value={address}
- onChange={(e) => setAddress(e.target.value.trim())}
+ onChange={(e) => handleAddressChange(e.target.value.trim())}
placeholder={getMessage("solve.register.addressPlaceholder")}
aria-invalid={Boolean(addressError)}
aria-describedby={addressError ? "solver-address-error" : undefined}
@@ -353,7 +388,7 @@ export default function SolvePageClient() {
id="solver-bond"
type="number"
value={bond}
- onChange={(e) => setBond(e.target.value)}
+ onChange={(e) => handleBondChange(e.target.value)}
placeholder={getMessage("solve.register.bondPlaceholder")}
aria-invalid={Boolean(bondError)}
aria-describedby={bondError ? "solver-bond-error" : undefined}
diff --git a/src/app/solve/[address]/page.test.tsx b/src/app/solve/[address]/page.test.tsx
index 42f7990..c02818b 100644
--- a/src/app/solve/[address]/page.test.tsx
+++ b/src/app/solve/[address]/page.test.tsx
@@ -550,4 +550,4 @@ describe("SolverDetailPage", () => {
const skeletons = screen.queryAllByTestId("skeleton");
expect(skeletons.length).toBe(0);
});
-});
+});});
diff --git a/src/app/solve/page.tsx b/src/app/solve/page.tsx
index 9fc040e..dd0c88b 100644
--- a/src/app/solve/page.tsx
+++ b/src/app/solve/page.tsx
@@ -479,10 +479,5 @@ export default function SolvePage() {
- ),
- ssr: false,
-});
-
-export default function SolvePage() {
- return
;
+ );
}
diff --git a/src/components/SwapCard.tsx b/src/components/SwapCard.tsx
index 3524333..9685d70 100644
--- a/src/components/SwapCard.tsx
+++ b/src/components/SwapCard.tsx
@@ -24,20 +24,51 @@ const SUBMISSION_LABEL_KEY: Record
= {
export type SwapCardProps = {
initialAmount?: string;
+ /** Pre-select the source chain (must be a valid CHAINS id; falls back to "ethereum"). */
+ initialChain?: string;
+ /** Pre-select the source token symbol on the given chain (falls back to that chain's first token). */
+ initialSrcToken?: string;
+ /** Pre-select the destination token symbol (must be in DST_TOKENS; falls back to the first). */
+ initialDstToken?: string;
previewQuote?: Quote;
onPreviewSubmit?: (request: QuoteRequest) => void;
};
+/** Resolve a chain id from an untrusted string, falling back to "ethereum". */
+function resolveChain(raw: string | undefined): string {
+ if (!raw) return "ethereum";
+ const found = CHAINS.find(c => c.id === raw);
+ return found ? found.id : "ethereum";
+}
+
+/** Resolve a src token for a given chain, falling back to the chain's first token. */
+function resolveSrcToken(chainId: string, symbol: string | undefined) {
+ const tokens = SRC_TOKENS[chainId] ?? SRC_TOKENS["ethereum"] ?? [];
+ const first = tokens[0] ?? SRC_TOKENS["ethereum"]![0]!;
+ if (!symbol) return first;
+ return tokens.find(t => t.symbol === symbol) ?? first;
+}
+
+/** Resolve a dst token, falling back to the first available. */
+function resolveDstToken(symbol: string | undefined) {
+ if (!symbol) return DST_TOKENS[0];
+ return DST_TOKENS.find(t => t.symbol === symbol) ?? DST_TOKENS[0];
+}
+
export function SwapCard({
initialAmount = "",
+ initialChain,
+ initialSrcToken,
+ initialDstToken,
previewQuote,
onPreviewSubmit,
}: SwapCardProps = {}) {
const { t } = useTranslation();
- const [srcChain, setSrcChain] = useState("ethereum");
- const [srcToken, setSrcToken] = useState(SRC_TOKENS["ethereum"][0]);
- const [dstToken, setDstToken] = useState(DST_TOKENS[0]);
+ const resolvedChain = resolveChain(initialChain);
+ const [srcChain, setSrcChain] = useState(resolvedChain);
+ const [srcToken, setSrcToken] = useState(() => resolveSrcToken(resolvedChain, initialSrcToken));
+ const [dstToken, setDstToken] = useState(() => resolveDstToken(initialDstToken));
const [srcAmount, setSrcAmount] = useState(initialAmount);
const [showChainPicker, setShowChainPicker] = useState(false);
const [showTokenPicker, setShowTokenPicker] = useState(false);
diff --git a/src/hooks/useLocalStorageDraft.ts b/src/hooks/useLocalStorageDraft.ts
new file mode 100644
index 0000000..cb762e1
--- /dev/null
+++ b/src/hooks/useLocalStorageDraft.ts
@@ -0,0 +1,122 @@
+/**
+ * useLocalStorageDraft
+ *
+ * Provides debounced persistence of an arbitrary draft value to localStorage,
+ * with TTL-based expiry and a wallet-address guard so stale drafts from a
+ * different wallet are never silently restored.
+ *
+ * Usage:
+ * const [draft, setDraft, clearDraft] = useLocalStorageDraft(
+ * "solver-registration",
+ * connectedWalletAddress ?? null,
+ * );
+ *
+ * Behaviour:
+ * - Reads on mount; returns null when the key is absent, the TTL has elapsed,
+ * or the stored wallet address doesn't match the current one.
+ * - Writes are debounced (default 500 ms) to avoid thrashing localStorage on
+ * every keystroke.
+ * - `clearDraft()` removes the entry immediately (no debounce).
+ */
+
+import { useCallback, useEffect, useRef, useState } from "react";
+
+const DEFAULT_DEBOUNCE_MS = 500;
+const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
+
+type StoredEntry = {
+ value: T;
+ savedAt: number;
+ walletAddress: string | null;
+};
+
+function readEntry(key: string): StoredEntry | null {
+ try {
+ const raw = localStorage.getItem(key);
+ if (!raw) return null;
+ return JSON.parse(raw) as StoredEntry;
+ } catch {
+ return null;
+ }
+}
+
+function writeEntry(key: string, value: T, walletAddress: string | null): void {
+ try {
+ const entry: StoredEntry = { value, savedAt: Date.now(), walletAddress };
+ localStorage.setItem(key, JSON.stringify(entry));
+ } catch {
+ // Silently ignore quota errors — draft persistence is best-effort.
+ }
+}
+
+function removeEntry(key: string): void {
+ try {
+ localStorage.removeItem(key);
+ } catch {
+ // ignore
+ }
+}
+
+export function useLocalStorageDraft(
+ key: string,
+ walletAddress: string | null,
+ options?: { debounceMs?: number; ttlMs?: number },
+): [T | null, (value: T) => void, () => void] {
+ const debounceMs = options?.debounceMs ?? DEFAULT_DEBOUNCE_MS;
+ const ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS;
+
+ // Read on mount — return null for absent, expired, or wrong-wallet entries.
+ const [draft, setDraftState] = useState(() => {
+ if (typeof window === "undefined") return null;
+ const entry = readEntry(key);
+ if (!entry) return null;
+ if (Date.now() - entry.savedAt > ttlMs) {
+ removeEntry(key);
+ return null;
+ }
+ if (entry.walletAddress !== walletAddress) {
+ removeEntry(key);
+ return null;
+ }
+ return entry.value;
+ });
+
+ // Keep a stable ref to the latest wallet address so the debounced write
+ // always uses the current value (avoids stale closure issues).
+ const walletRef = useRef(walletAddress);
+ useEffect(() => {
+ walletRef.current = walletAddress;
+ }, [walletAddress]);
+
+ const timerRef = useRef | null>(null);
+
+ const setDraft = useCallback(
+ (value: T) => {
+ setDraftState(value);
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(() => {
+ writeEntry(key, value, walletRef.current);
+ timerRef.current = null;
+ }, debounceMs);
+ },
+ [key, debounceMs],
+ );
+
+ const clearDraft = useCallback(() => {
+ if (timerRef.current !== null) {
+ clearTimeout(timerRef.current);
+ timerRef.current = null;
+ }
+ setDraftState(null);
+ removeEntry(key);
+ }, [key]);
+
+ // Clean up any pending debounced write on unmount.
+ useEffect(() => {
+ return () => {
+ if (timerRef.current !== null) clearTimeout(timerRef.current);
+ };
+ }, []);
+
+ return [draft, setDraft, clearDraft];
+}