diff --git a/docs/components.md b/docs/components.md index 506beca..93bdbb6 100644 --- a/docs/components.md +++ b/docs/components.md @@ -77,3 +77,39 @@ import { ConnectWalletButton } from "@/components/ConnectWalletButton"; - On a failed `connect()` call, it also pushes an error toast via [`useToastStore`](../src/store/toast.ts) — you don't need to handle connection errors yourself when using this component. + +## `Tooltip` + +[`src/components/Tooltip.tsx`](../src/components/Tooltip.tsx) + +Accessible WAI-ARIA tooltip component. Shown on hover and keyboard focus +(never mouse-only), dismissed via Escape, and associated to its trigger via +`aria-describedby`. Includes basic viewport-edge collision handling and a +tap-to-toggle affordance for touch devices. + +```tsx +import { Tooltip } from "@/components/Tooltip"; + + + Protocol fee + +``` + +**Props** + +| prop | type | required | default | notes | +| ----------- | ----------- | -------- | -------- | ------------------------------------------------------------------------------------ | +| `content` | `ReactNode` | yes | — | The tooltip text or element shown in the popover. | +| `children` | `ReactElement` | yes | — | Single trigger element. Must accept `ref`, `aria-describedby`, focus/blur/mouse handlers. | +| `placement` | `"top" \| "bottom"` | no | `"top"` | Preferred placement; auto-flips when close to viewport edge. | + +**Behaviour** + +- Hover or keyboard focus opens the tooltip; losing either closes it. +- Pressing Escape dismisses the tooltip from anywhere on the page. +- Touch: tap the trigger to toggle the tooltip open/closed. +- The trigger receives `aria-describedby` pointing to the tooltip while it is visible. +- Does not trap focus or interfere with Tab order. +- Currently applied to `Price impact`, `Protocol fee`, and `Est. fill time` in + SwapCard's quote details panel. See `Tooltip.stories.tsx` for interactive examples. + diff --git a/src/components/SwapCard.tsx b/src/components/SwapCard.tsx index 3524333..5053624 100644 --- a/src/components/SwapCard.tsx +++ b/src/components/SwapCard.tsx @@ -4,9 +4,11 @@ import { useEffect, useRef, useState } from "react"; import { useQuote } from "@/hooks/useQuote"; import { useDebouncedValue } from "@/hooks/useDebouncedValue"; import { useSwapSubmission } from "@/hooks/useSwapSubmission"; +import { useRecentChains } from "@/hooks/useRecentChains"; import { useToastStore } from "@/store/toast"; -import { CHAINS, SRC_TOKENS, DST_TOKENS } from "@/lib/marketData"; -import { formatCurrency, formatTokenAmount } from "@/lib/format"; +import { Tooltip } from "@/components/Tooltip"; +import { CHAINS, SRC_TOKENS, DST_TOKENS, PRICES_AS_OF } from "@/lib/marketData"; +import { formatTokenAmount } from "@/lib/format"; import { useTranslation } from "@/lib/i18n/I18nProvider"; import { isValidStellarPublicKey } from "@/lib/stellarAddress"; import type { MessageKey } from "@/lib/i18n"; @@ -14,6 +16,8 @@ import type { Quote, QuoteRequest } from "@/lib/types"; export const DEFAULT_SLIPPAGE_PCT = 0.5; export const HIGH_PRICE_IMPACT_THRESHOLD_PCT = 3; +// A quote older than 30 s is considered stale; warn the user before submission. +export const STALE_QUOTE_THRESHOLD_MS = 30_000; const SUBMISSION_LABEL_KEY: Record = { connecting: "swap.submit.connecting", @@ -39,11 +43,17 @@ export function SwapCard({ const [srcToken, setSrcToken] = useState(SRC_TOKENS["ethereum"][0]); const [dstToken, setDstToken] = useState(DST_TOKENS[0]); const [srcAmount, setSrcAmount] = useState(initialAmount); + const [dstAddress, setDstAddress] = useState(""); + const [slippagePct, setSlippagePct] = useState(String(DEFAULT_SLIPPAGE_PCT)); const [showChainPicker, setShowChainPicker] = useState(false); const [showTokenPicker, setShowTokenPicker] = useState(false); + const chainToggleRef = useRef(null); const chainPickerRef = useRef(null); + // #284 – recent chains + const { recentChains, addRecentChain } = useRecentChains(); + const chain = CHAINS.find(c => c.id === srcChain)!; const closeChainPicker = () => { @@ -51,10 +61,20 @@ export function SwapCard({ chainToggleRef.current?.focus(); }; + // Moves focus into the overlay when it opens. useEffect(() => { - if (showChainPicker) { - chainPickerRef.current?.querySelector("button")?.focus(); - } + if (!showChainPicker) return; + chainPickerRef.current?.querySelector("button")?.focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + closeChainPicker(); + } + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [showChainPicker]); const handleChainPickerKeyDown = (e: React.KeyboardEvent) => { @@ -77,19 +97,40 @@ export function SwapCard({ } }; + /** Select a chain from either the quick-row or the full grid. */ + const handleSelectChain = (chainId: string) => { + setSrcChain(chainId); + setSrcToken(SRC_TOKENS[chainId][0]); + addRecentChain(chainId); // #284 – record recency + closeChainPicker(); + }; + const debouncedAmount = useDebouncedValue(srcAmount, 500); const hasAmount = Boolean(debouncedAmount) && parseFloat(debouncedAmount) > 0; - const { quote: fetchedQuote, isLoading: quoteIsLoading, error: quoteError } = useQuote( + const { + quote: fetchedQuote, + quoteFetchedAt, + isLoading: quoteIsLoading, + error: quoteError, + quoteErrorType, + } = useQuote( hasAmount && !previewQuote - ? { srcChain, srcToken: srcToken.symbol, srcAmount: debouncedAmount, dstToken: dstToken.symbol } - : null + ? { + srcChain, + srcToken: srcToken.symbol, + srcAmount: debouncedAmount, + dstToken: dstToken.symbol, + } + : null, ); + const quote = previewQuote ?? fetchedQuote; const quoting = previewQuote ? false : quoteIsLoading; - const dstAddressError = dstAddress && !isValidStellarPublicKey(dstAddress) - ? t("swap.destination.invalidAddress") - : null; + const dstAddressError = + dstAddress && !isValidStellarPublicKey(dstAddress) + ? t("swap.destination.invalidAddress") + : null; const dstAmount = quote ? parseFloat(quote.dstAmount) @@ -99,16 +140,25 @@ export function SwapCard({ const srcValueUSD = srcAmount ? parseFloat(srcAmount) * srcToken.priceUSD : 0; const parsedSlippagePct = Math.max(0, Math.min(50, parseFloat(slippagePct) || 0)); - const minOut = dstAmount > 0 - ? (dstAmount * (1 - parsedSlippagePct / 100)).toFixed(dstToken.symbol === "XLM" ? 2 : 4) - : "0"; + const minOut = + dstAmount > 0 + ? (dstAmount * (1 - parsedSlippagePct / 100)).toFixed(dstToken.symbol === "XLM" ? 2 : 4) + : "0"; const hasHighPriceImpact = quote ? quote.priceImpactPct > HIGH_PRICE_IMPACT_THRESHOLD_PCT : false; + // #285 – show the "as of" notice only when showing the estimate (no live quote). + const showPriceEstimateNotice = !quote && srcValueUSD > 0; + const submission = useSwapSubmission(); const isSubmitting = submission.status in SUBMISSION_LABEL_KEY; - const canSwap = Boolean(srcAmount) && parseFloat(srcAmount) > 0 && !quoting && !isSubmitting && !dstAddressError; + const canSwap = + Boolean(srcAmount) && + parseFloat(srcAmount) > 0 && + !quoting && + !isSubmitting && + !dstAddressError; /** Truncate a raw amount string to at most `decimals` decimal places. */ function truncateToDecimals(value: string, decimals: number): string { @@ -142,41 +192,20 @@ export function SwapCard({ return; } - submission.submit({ srcChain, srcToken: srcToken.symbol, srcAmount, dstToken: dstToken.symbol }); + submission.submit({ + srcChain, + srcToken: srcToken.symbol, + srcAmount, + dstToken: dstToken.symbol, + }); }; - // While the chain picker overlay is open, the main card sits behind it - // (opacity-0, pointer-events-none) — keep its controls out of the tab - // order too, or keyboard users tab through invisible fields. + // While the chain picker overlay is open, keep main card controls out of tab order. const hiddenTabIndex = showChainPicker ? -1 : undefined; - const chainPickerRef = useRef(null); - const chainToggleRef = useRef(null); - - const closeChainPicker = () => { - setShowChainPicker(false); - chainToggleRef.current?.focus(); - }; - - // Moves focus into the overlay when it opens, since its trigger becomes - // aria-hidden/untabbable the moment the main card is hidden behind it. - useEffect(() => { - if (!showChainPicker) return; - chainPickerRef.current?.querySelector("button")?.focus(); - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - event.preventDefault(); - closeChainPicker(); - } - }; - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [showChainPicker]); - return (
- {/* Chain picker dropdown */} + {/* Chain picker overlay */} {showChainPicker && (
{t("swap.chainPicker.title")}
+ + {/* ── #284 Recent chains quick-select row ── */} + {recentChains.length > 0 && ( +
+
+ {t("swap.chainPicker.recent")} +
+
+ {recentChains.map(c => ( + + ))} +
+
+
+ )} + + {/* Full grid (always visible) */}
{CHAINS.map(c => (
- + -
@@ -280,9 +362,14 @@ export function SwapCard({ key={token.symbol} type="button" tabIndex={hiddenTabIndex} - onClick={() => { setSrcToken(token); setShowTokenPicker(false); }} + onClick={() => { + setSrcToken(token); + setShowTokenPicker(false); + }} className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors - ${token.symbol === srcToken.symbol ? "bg-vx-lav-bg text-vx-lav" : "hover:bg-vx-surface text-vx-muted hover:text-vx-text"}`} + ${token.symbol === srcToken.symbol + ? "bg-vx-lav-bg text-vx-lav" + : "hover:bg-vx-surface text-vx-muted hover:text-vx-text"}`} > {token.symbol} ${token.priceUSD.toLocaleString()} @@ -292,20 +379,38 @@ export function SwapCard({ )} {srcValueUSD > 0 && ( -
+
{/* Number formatting stays locale-hardcoded here; issue #63 owns making it locale-aware. */} {t("swap.from.approxValue", { value: srcValueUSD.toLocaleString("en-US", { maximumFractionDigits: 2 }), })} + {/* #285 – show "estimated" badge when showing a price-derived value (no live quote yet) */} + {showPriceEstimateNotice && ( + + {t("swap.prices.estimated")} + + )}
)}
{/* Swap direction arrow */}
- @@ -315,7 +420,12 @@ export function SwapCard({
{t("swap.to.label")} -
{quoting ? (
- + {/* Slippage */} +
+ {t("swap.slippage.label")} + + setSlippagePct(e.target.value)} + tabIndex={hiddenTabIndex} + className="w-16 bg-vx-surface border border-vx-border rounded-lg px-2 py-1 text-xs text-right text-vx-text focus:outline-none focus:border-vx-sage/50" + /> +
+ {dstAmount > 0 && ( +
+ {t("swap.slippage.minOut", { amount: minOut, token: dstToken.symbol })} +
+ )} + {/* Quote details */} {quote && srcAmount && (
- {([ - ["swap.quote.solver", quote.solver], - ["swap.quote.fillTime", t("swap.quote.fillTimeValue", { seconds: quote.fillTimeSeconds })], - ["swap.quote.priceImpact", t("swap.quote.priceImpactValue", { - percent: quote.priceImpactPct < 0.01 - ? t("swap.quote.priceImpactBelowMin") - : quote.priceImpactPct.toFixed(2), - })], - ["swap.quote.protocolFee", t("swap.quote.protocolFeeValue", { percent: quote.protocolFeePct.toFixed(2) })], - ["swap.quote.rate", quote.rate], - ] as const).map(([labelKey, value]) => ( + {( + [ + ["swap.quote.solver", quote.solver, null], + [ + "swap.quote.fillTime", + t("swap.quote.fillTimeValue", { seconds: quote.fillTimeSeconds }), + "swap.quote.fillTime.tooltip", + ], + [ + "swap.quote.priceImpact", + t("swap.quote.priceImpactValue", { + percent: + quote.priceImpactPct < 0.01 + ? t("swap.quote.priceImpactBelowMin") + : quote.priceImpactPct.toFixed(2), + }), + "swap.quote.priceImpact.tooltip", + ], + [ + "swap.quote.protocolFee", + t("swap.quote.protocolFeeValue", { + percent: quote.protocolFeePct.toFixed(2), + }), + "swap.quote.protocolFee.tooltip", + ], + ["swap.quote.rate", quote.rate, null], + ] as const + ).map(([labelKey, value, tooltipKey]) => (
- {t(labelKey)} + {tooltipKey ? ( + + + {t(labelKey)} + + + ) : ( + {t(labelKey)} + )} {submission.error}

+

+ {submission.error} +

)} {/* Submit */} @@ -449,8 +614,21 @@ export function SwapCard({ > {isSubmitting ? ( - @@ -458,8 +636,21 @@ export function SwapCard({ t("swap.submit.success") ) : quoting ? ( - @@ -474,9 +665,7 @@ export function SwapCard({ )} -

- {t("swap.disclaimer")} -

+

{t("swap.disclaimer")}

); diff --git a/src/components/Tooltip.stories.tsx b/src/components/Tooltip.stories.tsx new file mode 100644 index 0000000..78cea58 --- /dev/null +++ b/src/components/Tooltip.stories.tsx @@ -0,0 +1,98 @@ +import type { Meta, StoryObj } from "@storybook/nextjs-vite"; +import { Tooltip } from "./Tooltip"; + +const meta = { + title: "Components/Tooltip", + component: Tooltip, + tags: ["autodocs"], + parameters: { + layout: "centered", + docs: { + description: { + component: + "Accessible WAI-ARIA tooltip. Shown on hover and keyboard focus, " + + "dismissed with Escape. Associated to its trigger via `aria-describedby`. " + + "Tap-to-toggle on touch devices.", + }, + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + content: "This is an explanatory tooltip.", + placement: "top", + }, + render: (args) => ( + + + Hover or focus me + + + ), +}; + +export const PriceImpact: Story = { + args: { + content: + "How much your trade moves the effective price relative to the mid-market rate. " + + "A high impact means you receive less than the quoted mid-market rate.", + placement: "top", + }, + render: (args) => ( + + + Price impact + + + ), +}; + +export const ProtocolFee: Story = { + args: { + content: + "A small percentage fee charged by the Vortex protocol on each settled swap. " + + "It is deducted from the destination amount.", + placement: "top", + }, + render: (args) => ( + + + Protocol fee + + + ), +}; + +export const FillTime: Story = { + args: { + content: + "Estimated time for a solver to fill your swap after you submit. " + + "Actual time may vary.", + placement: "top", + }, + render: (args) => ( + + + Est. fill time + + + ), +}; + +export const PlacementBottom: Story = { + args: { + content: "This tooltip opens below the trigger.", + placement: "bottom", + }, + render: (args) => ( + + + Below placement + + + ), +}; diff --git a/src/components/Tooltip.test.tsx b/src/components/Tooltip.test.tsx new file mode 100644 index 0000000..eda105e --- /dev/null +++ b/src/components/Tooltip.test.tsx @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { render, screen, act, cleanup } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Tooltip } from "./Tooltip"; + +afterEach(cleanup); + +describe("Tooltip", () => { + it("does not render tooltip content initially", () => { + render( + + + + ); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("shows the tooltip on mouse hover", async () => { + const user = userEvent.setup(); + render( + + + + ); + await user.hover(screen.getByRole("button", { name: "Trigger" })); + expect(await screen.findByRole("tooltip")).toBeInTheDocument(); + expect(screen.getByRole("tooltip")).toHaveTextContent("Hover tooltip content"); + }); + + it("hides the tooltip when the mouse leaves", async () => { + const user = userEvent.setup(); + render( + + + + ); + const trigger = screen.getByRole("button", { name: "Trigger" }); + await user.hover(trigger); + expect(await screen.findByRole("tooltip")).toBeInTheDocument(); + await user.unhover(trigger); + // Small delay for the hide timeout. + await new Promise(r => setTimeout(r, 200)); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("shows the tooltip on keyboard focus", async () => { + const user = userEvent.setup(); + render( + + + + ); + await user.tab(); // focus the button + expect(await screen.findByRole("tooltip")).toBeInTheDocument(); + expect(screen.getByRole("tooltip")).toHaveTextContent("Focus tooltip content"); + }); + + it("hides the tooltip when the trigger loses focus (blur)", async () => { + const user = userEvent.setup(); + render( + <> + + + + + + ); + await user.tab(); // focus trigger + expect(await screen.findByRole("tooltip")).toBeInTheDocument(); + await user.tab(); // move to next element + await new Promise(r => setTimeout(r, 200)); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("dismisses the tooltip with the Escape key", async () => { + const user = userEvent.setup(); + render( + + + + ); + await user.hover(screen.getByRole("button", { name: "Trigger" })); + expect(await screen.findByRole("tooltip")).toBeInTheDocument(); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("sets aria-describedby on the trigger element while the tooltip is visible", async () => { + const user = userEvent.setup(); + render( + + + + ); + const trigger = screen.getByRole("button", { name: "Trigger" }); + expect(trigger).not.toHaveAttribute("aria-describedby"); + + await user.hover(trigger); + const tooltip = await screen.findByRole("tooltip"); + + expect(trigger).toHaveAttribute("aria-describedby", tooltip.id); + }); + + it("removes aria-describedby from the trigger when the tooltip closes", async () => { + const user = userEvent.setup(); + render( + + + + ); + const trigger = screen.getByRole("button", { name: "Trigger" }); + await user.hover(trigger); + await screen.findByRole("tooltip"); + await user.unhover(trigger); + await new Promise(r => setTimeout(r, 200)); + expect(trigger).not.toHaveAttribute("aria-describedby"); + }); +}); diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx new file mode 100644 index 0000000..bf9352b --- /dev/null +++ b/src/components/Tooltip.tsx @@ -0,0 +1,197 @@ +"use client"; + +/** + * Tooltip — accessible WAI-ARIA tooltip pattern. + * + * Usage: + * + * Price impact + * + * + * Behaviour: + * - Shown on hover and keyboard focus (never mouse-only). + * - Dismissed by pressing Escape or when the trigger loses focus/hover. + * - The trigger element receives `aria-describedby` pointing to the tooltip. + * - Basic viewport-edge collision handling (flips to left/right when close + * to the screen edge, and flips above when not enough space below). + * - Touch devices: tap the trigger once to toggle the tooltip; tap again or + * elsewhere to dismiss. + * - Does not trap focus or interfere with Tab order. + */ + +import { + cloneElement, + isValidElement, + useCallback, + useEffect, + useId, + useRef, + useState, + type ReactElement, + type ReactNode, +} from "react"; + +export type TooltipProps = { + /** The explanatory text shown in the tooltip. */ + content: ReactNode; + /** + * The element that triggers the tooltip. Must be a single React element + * that accepts `ref`, `aria-describedby`, `onMouseEnter`, `onMouseLeave`, + * `onFocus`, and `onBlur` props. + */ + children: ReactElement; + /** Preferred placement. Falls back via collision detection. @default "top" */ + placement?: "top" | "bottom"; +}; + +export function Tooltip({ content, children, placement = "top" }: TooltipProps) { + const [visible, setVisible] = useState(false); + const id = useId(); + const triggerRef = useRef(null); + const tooltipRef = useRef(null); + const hideTimeoutRef = useRef | null>(null); + const [resolvedPlacement, setResolvedPlacement] = useState(placement); + + const show = useCallback(() => { + if (hideTimeoutRef.current) { + clearTimeout(hideTimeoutRef.current); + hideTimeoutRef.current = null; + } + setVisible(true); + }, []); + + const hide = useCallback(() => { + // Small delay so moving from trigger → tooltip doesn't flicker. + hideTimeoutRef.current = setTimeout(() => setVisible(false), 80); + }, []); + + // Resolve actual placement using viewport collision detection. + useEffect(() => { + if (!visible || !triggerRef.current || !tooltipRef.current) return; + + const trigger = triggerRef.current.getBoundingClientRect(); + const tip = tooltipRef.current.getBoundingClientRect(); + const vw = window.innerWidth; + const vh = window.innerHeight; + + let p: "top" | "bottom" = placement; + + if (p === "top" && trigger.top - tip.height - 8 < 0) p = "bottom"; + if (p === "bottom" && trigger.bottom + tip.height + 8 > vh) p = "top"; + + // Horizontal clip guard: tooltip is absolutely positioned relative to + // trigger, so we only need to ensure it fits within the viewport here. + const tipLeft = trigger.left + trigger.width / 2 - tip.width / 2; + if (tipLeft < 4 || tipLeft + tip.width > vw - 4) { + // handled via CSS clamp in the style below — nothing needed here. + } + + setResolvedPlacement(p); + }, [visible, placement]); + + // Escape key dismisses the tooltip. + useEffect(() => { + if (!visible) return; + const handle = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + setVisible(false); + } + }; + document.addEventListener("keydown", handle, { capture: true }); + return () => document.removeEventListener("keydown", handle, { capture: true }); + }, [visible]); + + // Touch: tap to toggle. + const handleTouchEnd = useCallback( + (e: React.TouchEvent) => { + e.preventDefault(); // Prevent the synthetic click that would immediately re-open. + setVisible(v => !v); + }, + [], + ); + + if (!isValidElement(children)) return children as unknown as ReactElement; + + const trigger = cloneElement(children as ReactElement>, { + ref: triggerRef, + "aria-describedby": visible ? id : undefined, + onMouseEnter: (...args: unknown[]) => { + show(); + // Forward original handler if present. + const orig = (children.props as Record).onMouseEnter; + if (typeof orig === "function") orig(...args); + }, + onMouseLeave: (...args: unknown[]) => { + hide(); + const orig = (children.props as Record).onMouseLeave; + if (typeof orig === "function") orig(...args); + }, + onFocus: (...args: unknown[]) => { + show(); + const orig = (children.props as Record).onFocus; + if (typeof orig === "function") orig(...args); + }, + onBlur: (...args: unknown[]) => { + hide(); + const orig = (children.props as Record).onBlur; + if (typeof orig === "function") orig(...args); + }, + onTouchEnd: handleTouchEnd, + }); + + return ( + + {trigger} + {visible && ( + + )} + + ); +} diff --git a/src/hooks/useAcceptIntent.ts b/src/hooks/useAcceptIntent.ts index 3be2ff1..570fa1f 100644 --- a/src/hooks/useAcceptIntent.ts +++ b/src/hooks/useAcceptIntent.ts @@ -1,38 +1,67 @@ import { useCallback, useState } from "react"; import { mutate } from "swr"; import { acceptIntent } from "@/lib/api"; +import { useRetry } from "@/hooks/useRetry"; import { useWalletStore } from "@/store/wallet"; import { useToastStore } from "@/store/toast"; +/** + * useAcceptIntent + * + * Accepts an open intent on behalf of the connected solver. The `accept()` + * call is wrapped with `withRetry` from useRetry so transient 5xx errors or + * brief network blips are automatically retried with exponential back-off, + * without the solver needing to act again. + * + * Retry policy (from useRetry defaults): + * - Up to 3 retry attempts. + * - Exponential back-off: 1 s, 2 s, 4 s. + * - 4xx errors are NOT retried — they represent a definitive server rejection + * (e.g. intent already claimed) and should surface to the user immediately. + * + * Signature-requiring flows are intentionally excluded from retry logic — + * see useRetry.ts for rationale. + */ export function useAcceptIntent() { const [acceptingId, setAcceptingId] = useState(null); const [error, setError] = useState(null); + const { withRetry } = useRetry(); - const accept = useCallback(async (intentId: string) => { - setError(null); - setAcceptingId(intentId); + const accept = useCallback( + async (intentId: string) => { + setError(null); + setAcceptingId(intentId); - try { - let wallet = useWalletStore.getState(); - if (!wallet.isConnected || !wallet.address) { - await wallet.connect(); - wallet = useWalletStore.getState(); + try { + let wallet = useWalletStore.getState(); if (!wallet.isConnected || !wallet.address) { - throw new Error(wallet.error ?? "Connect a wallet to accept an intent."); + await wallet.connect(); + wallet = useWalletStore.getState(); + if (!wallet.isConnected || !wallet.address) { + throw new Error(wallet.error ?? "Connect a wallet to accept an intent."); + } } - } - await acceptIntent(intentId, wallet.address); - await mutate("/intents/open"); - useToastStore.getState().addToast("Intent accepted — you have exclusive fill rights.", "success"); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to accept intent."; - setError(message); - useToastStore.getState().addToast(message, "error"); - } finally { - setAcceptingId(null); - } - }, []); + const solverAddress = wallet.address; + // Wrap the accept call with retry so transient failures are handled + // automatically without requiring a manual retry from the solver. + await withRetry(() => acceptIntent(intentId, solverAddress)); + + await mutate("/intents/open"); + useToastStore + .getState() + .addToast("Intent accepted — you have exclusive fill rights.", "success"); + } catch (err) { + const message = + err instanceof Error ? err.message : "Failed to accept intent."; + setError(message); + useToastStore.getState().addToast(message, "error"); + } finally { + setAcceptingId(null); + } + }, + [withRetry], + ); return { accept, acceptingId, error }; } diff --git a/src/hooks/useActivityFeed.ts b/src/hooks/useActivityFeed.ts index 4a73936..6e5e7e2 100644 --- a/src/hooks/useActivityFeed.ts +++ b/src/hooks/useActivityFeed.ts @@ -1,5 +1,6 @@ import useSWR from "swr"; import { fetcher } from "@/lib/api"; +import { swrRetryConfig } from "@/hooks/useRetry"; import type { FeedItem } from "@/lib/types"; // refreshInterval is intentionally 0 (disabled) because useIntentFeed layers @@ -14,6 +15,7 @@ export function useActivityFeed() { const { data, error, isLoading } = useSWR("/intents/feed", fetcher, { refreshInterval: 0, dedupingInterval: 8_000, + ...swrRetryConfig, }); return { items: data ?? [], isLoading, error }; diff --git a/src/hooks/useIntent.ts b/src/hooks/useIntent.ts index 7ec6c29..d49faa8 100644 --- a/src/hooks/useIntent.ts +++ b/src/hooks/useIntent.ts @@ -1,5 +1,6 @@ import useSWR from "swr"; import { fetcher } from "@/lib/api"; +import { swrRetryConfig } from "@/hooks/useRetry"; import type { IntentDetail } from "@/lib/types"; // Single-intent detail fetch. No WebSocket or polling needed — the user @@ -18,6 +19,7 @@ export function useIntent(id: string | null) { refreshInterval: 0, dedupingInterval: 5_000, revalidateOnFocus: true, + ...swrRetryConfig, }, ); diff --git a/src/hooks/useIntents.ts b/src/hooks/useIntents.ts index 15140bf..576363a 100644 --- a/src/hooks/useIntents.ts +++ b/src/hooks/useIntents.ts @@ -1,5 +1,6 @@ import useSWR from "swr"; import { fetcher } from "@/lib/api"; +import { swrRetryConfig } from "@/hooks/useRetry"; import type { FeedItem } from "@/lib/types"; // No polling needed — useLiveIntents layers a WebSocket subscription on top @@ -13,6 +14,7 @@ export function useIntents() { const { data, error, isLoading } = useSWR("/intents", fetcher, { refreshInterval: 0, dedupingInterval: 8_000, + ...swrRetryConfig, }); return { intents: data ?? [], isLoading, error }; diff --git a/src/hooks/useMyIntents.ts b/src/hooks/useMyIntents.ts index e180ec4..83bc9ef 100644 --- a/src/hooks/useMyIntents.ts +++ b/src/hooks/useMyIntents.ts @@ -1,5 +1,6 @@ import useSWR from "swr"; import { fetcher } from "@/lib/api"; +import { swrRetryConfig } from "@/hooks/useRetry"; import type { FeedItem } from "@/lib/types"; // The /intents endpoint does not currently support an address filter, so we @@ -16,6 +17,7 @@ export function useMyIntents(address: string | null) { { refreshInterval: 0, dedupingInterval: 8_000, + ...swrRetryConfig, }, ); diff --git a/src/hooks/useOpenIntents.ts b/src/hooks/useOpenIntents.ts index e72860e..fa773dd 100644 --- a/src/hooks/useOpenIntents.ts +++ b/src/hooks/useOpenIntents.ts @@ -1,5 +1,6 @@ import useSWR from "swr"; import { fetcher } from "@/lib/api"; +import { swrRetryConfig } from "@/hooks/useRetry"; import type { OpenIntent } from "@/lib/types"; // Open intents are NOT covered by a WebSocket subscription (the WS feed is @@ -21,6 +22,7 @@ export function useOpenIntents() { refreshInterval: 5_000, dedupingInterval: 5_000, revalidateOnFocus: false, + ...swrRetryConfig, }); return { intents: data ?? [], isLoading, error }; diff --git a/src/hooks/useQuote.ts b/src/hooks/useQuote.ts index 8568349..3987cb9 100644 --- a/src/hooks/useQuote.ts +++ b/src/hooks/useQuote.ts @@ -1,6 +1,7 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import useSWR from "swr"; import { fetcher } from "@/lib/api"; +import { swrRetryConfig } from "@/hooks/useRetry"; import type { Quote, QuoteRequest, QuoteErrorType } from "@/lib/types"; function quoteKey(params: QuoteRequest | null): string | null { @@ -17,7 +18,11 @@ function quoteKey(params: QuoteRequest | null): string | null { function classifyQuoteError(err: unknown): QuoteErrorType { if (err instanceof Error) { const body = err.message.toLowerCase(); - if (body.includes("no solver available") || body.includes("no_solver_available") || body.includes("no solver found")) { + if ( + body.includes("no solver available") || + body.includes("no_solver_available") || + body.includes("no solver found") + ) { return { kind: "no-solver", message: err.message }; } } @@ -28,14 +33,14 @@ export function useQuote(params: QuoteRequest | null) { const [quoteFetchedAt, setQuoteFetchedAt] = useState(null); const { data, error, isLoading } = useSWR(quoteKey(params), fetcher, { revalidateOnFocus: false, - onErrorRetry(error, _key, _config, revalidate, { retryCount }) { - // Do not retry on 4xx client errors — they won't self-heal. - if (error?.status >= 400 && error?.status < 500) return; - // Cap at 3 retries with exponential back-off: 1s, 2s, 4s. - if (retryCount >= 3) return; - setTimeout(() => revalidate({ retryCount }), 1000 * 2 ** retryCount); - }, + ...swrRetryConfig, }); - return { quote: data, quoteFetchedAt, isLoading, error }; + useEffect(() => { + if (data) setQuoteFetchedAt(Date.now()); + }, [data]); + + const quoteError = error ? classifyQuoteError(error) : null; + + return { quote: data, quoteFetchedAt, isLoading, error, quoteErrorType: quoteError }; } diff --git a/src/hooks/useRecentChains.test.ts b/src/hooks/useRecentChains.test.ts new file mode 100644 index 0000000..2f0023e --- /dev/null +++ b/src/hooks/useRecentChains.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { buildRecentList, MAX_RECENT, RECENT_CHAINS_KEY, useRecentChains } from "./useRecentChains"; + +// ─── buildRecentList (pure logic) ────────────────────────────────────────── + +describe("buildRecentList", () => { + it("prepends a new chain to an empty list", () => { + expect(buildRecentList([], "base")).toEqual(["base"]); + }); + + it("prepends a new chain to an existing list", () => { + expect(buildRecentList(["ethereum"], "base")).toEqual(["base", "ethereum"]); + }); + + it("moves a duplicate to the front (dedup)", () => { + expect(buildRecentList(["base", "ethereum"], "ethereum")).toEqual([ + "ethereum", + "base", + ]); + }); + + it("caps the list at MAX_RECENT entries", () => { + const initial = ["base", "polygon", "arbitrum"]; + const result = buildRecentList(initial, "optimism"); + expect(result).toHaveLength(MAX_RECENT); + expect(result[0]).toBe("optimism"); + }); + + it("does not include a chain more than once even when it was first", () => { + const result = buildRecentList(["ethereum", "base"], "ethereum"); + expect(result.filter(id => id === "ethereum")).toHaveLength(1); + }); +}); + +// ─── useRecentChains (hook) ────────────────────────────────────────────────── + +describe("useRecentChains", () => { + // Stub localStorage for each test. + let store: Record = {}; + + beforeEach(() => { + store = {}; + vi.stubGlobal("localStorage", { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns an empty list when localStorage is empty", () => { + const { result } = renderHook(() => useRecentChains()); + expect(result.current.recentChains).toEqual([]); + }); + + it("persists a selected chain to localStorage", () => { + const { result } = renderHook(() => useRecentChains()); + act(() => result.current.addRecentChain("base")); + expect(store[RECENT_CHAINS_KEY]).toContain("base"); + }); + + it("surfaces the stored chain as a full chain object", () => { + const { result } = renderHook(() => useRecentChains()); + act(() => result.current.addRecentChain("base")); + expect(result.current.recentChains[0]).toMatchObject({ id: "base", name: "Base" }); + }); + + it("keeps the most recently used chain first", () => { + const { result } = renderHook(() => useRecentChains()); + act(() => result.current.addRecentChain("ethereum")); + act(() => result.current.addRecentChain("base")); + expect(result.current.recentChains[0].id).toBe("base"); + }); + + it("deduplicates — re-selecting a chain moves it to the top", () => { + const { result } = renderHook(() => useRecentChains()); + act(() => result.current.addRecentChain("ethereum")); + act(() => result.current.addRecentChain("base")); + act(() => result.current.addRecentChain("ethereum")); + const ids = result.current.recentChains.map(c => c.id); + expect(ids[0]).toBe("ethereum"); + expect(ids.filter(id => id === "ethereum")).toHaveLength(1); + }); + + it("caps at MAX_RECENT entries", () => { + const { result } = renderHook(() => useRecentChains()); + act(() => result.current.addRecentChain("ethereum")); + act(() => result.current.addRecentChain("base")); + act(() => result.current.addRecentChain("polygon")); + act(() => result.current.addRecentChain("arbitrum")); + expect(result.current.recentChains).toHaveLength(MAX_RECENT); + }); + + it("filters out stale chain IDs that no longer exist in CHAINS", () => { + // Seed localStorage with a chain that doesn't exist. + store[RECENT_CHAINS_KEY] = JSON.stringify(["ethereum", "nonexistent-chain"]); + const { result } = renderHook(() => useRecentChains()); + const ids = result.current.recentChains.map(c => c.id); + expect(ids).not.toContain("nonexistent-chain"); + expect(ids).toContain("ethereum"); + }); + + it("handles corrupt localStorage gracefully (returns empty list)", () => { + store[RECENT_CHAINS_KEY] = "not-valid-json{{{{"; + const { result } = renderHook(() => useRecentChains()); + expect(result.current.recentChains).toEqual([]); + }); +}); diff --git a/src/hooks/useRecentChains.ts b/src/hooks/useRecentChains.ts new file mode 100644 index 0000000..35ca406 --- /dev/null +++ b/src/hooks/useRecentChains.ts @@ -0,0 +1,79 @@ +/** + * useRecentChains + * + * Tracks the last MAX_RECENT distinct chains the user has selected in the + * SwapCard chain picker. Backed by localStorage so the list survives page + * reloads. Automatically filters out chain IDs that no longer exist in + * the canonical CHAINS list (e.g. a chain was removed after an app update). + * + * Usage: + * const { recentChains, addRecentChain } = useRecentChains(); + * + * // On chain select: + * addRecentChain(chainId); + * + * // In the picker UI, render recentChains only when length > 0. + */ + +import { useCallback, useState } from "react"; +import { CHAINS } from "@/lib/marketData"; + +export const RECENT_CHAINS_KEY = "vortex:recentChains"; +export const MAX_RECENT = 3; + +/** Returns the valid (still-in-CHAINS), deduped, capped recent chain IDs. */ +function readFromStorage(): string[] { + if (typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(RECENT_CHAINS_KEY); + if (!raw) return []; + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return []; + const validIds = new Set(CHAINS.map(c => c.id)); + // Filter out any IDs that are no longer in CHAINS, preserve order. + return (parsed as unknown[]) + .filter((item): item is string => typeof item === "string" && validIds.has(item)) + .slice(0, MAX_RECENT); + } catch { + return []; + } +} + +function writeToStorage(ids: string[]): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(RECENT_CHAINS_KEY, JSON.stringify(ids)); + } catch { + // localStorage may be unavailable (private browsing, quota, etc.) — fail silently. + } +} + +/** + * Prepends `id` to the list, deduplicates, and caps at MAX_RECENT. + * Returns the new array without mutating the input. + */ +export function buildRecentList(current: string[], id: string): string[] { + const deduped = [id, ...current.filter(c => c !== id)]; + return deduped.slice(0, MAX_RECENT); +} + +export function useRecentChains() { + const [recentIds, setRecentIds] = useState(() => readFromStorage()); + + // Resolve the full chain objects, filtering out any that no longer exist. + const validIds = new Set(CHAINS.map(c => c.id)); + const recentChains = recentIds + .filter(id => validIds.has(id)) + .map(id => CHAINS.find(c => c.id === id)!) + .filter(Boolean); + + const addRecentChain = useCallback((chainId: string) => { + setRecentIds(prev => { + const next = buildRecentList(prev, chainId); + writeToStorage(next); + return next; + }); + }, []); + + return { recentChains, addRecentChain }; +} diff --git a/src/hooks/useRetry.ts b/src/hooks/useRetry.ts new file mode 100644 index 0000000..cc6cd15 --- /dev/null +++ b/src/hooks/useRetry.ts @@ -0,0 +1,130 @@ +/** + * useRetry — shared SWR retry configuration for API call resilience. + * + * Provides a consistent `onErrorRetry` handler that: + * - Does NOT retry 4xx client errors (they won't self-heal and reflect a + * genuine problem with the request). + * - Retries up to MAX_RETRIES times on 5xx / network errors with exponential + * back-off: 1 s, 2 s, 4 s … (capped by MAX_RETRIES). + * + * Integration point: `useAcceptIntent`'s `accept()` call benefits from this + * hook to handle transient network blips without requiring the solver to + * manually retry — see useAcceptIntent.ts for the wiring. + * + * Usage with SWR (hook form): + * const { onErrorRetry } = useRetry(); + * useSWR(key, fetcher, { onErrorRetry }); + * + * Usage with SWR (static form, usable outside components): + * import { swrRetryConfig } from "@/hooks/useRetry"; + * useSWR(key, fetcher, { ...swrRetryConfig }); + * + * Usage as a plain async wrapper (non-SWR): + * const { withRetry } = useRetry(); + * await withRetry(() => acceptIntent(id, address)); + * + * NOTE: Signature-requiring flows (createIntent → sign → submitIntent) must + * NEVER be auto-retried — a failed or rejected signature should not silently + * replay without fresh user consent. + */ + +import { useCallback } from "react"; +import type { SWRConfiguration } from "swr"; + +export const MAX_RETRIES = 3; +/** Base delay in milliseconds; doubles on each attempt (exponential back-off). */ +export const BASE_DELAY_MS = 1_000; + +/** Returns true if the error represents a 4xx client error that should not be retried. */ +export function isClientError(err: unknown): boolean { + if (err && typeof err === "object" && "status" in err) { + const status = (err as { status: number }).status; + return status >= 400 && status < 500; + } + return false; +} + +export type RetryOptions = { + /** Maximum number of retry attempts. Defaults to MAX_RETRIES. */ + maxRetries?: number; + /** Base delay in ms for exponential back-off. Defaults to BASE_DELAY_MS. */ + baseDelayMs?: number; +}; + +export type UseRetryReturn = { + /** + * SWR-compatible `onErrorRetry` handler. Pass directly to `useSWR` options. + * Respects the 4xx no-retry rule and the configured attempt cap. + */ + onErrorRetry: NonNullable; + + /** + * Wraps an async function with exponential-backoff retry logic. + * Suitable for imperative call sites (e.g. useAcceptIntent's accept()). + * Does not retry 4xx client errors. + */ + withRetry: (fn: () => Promise) => Promise; +}; + +/** + * Stable, module-level `onErrorRetry` for SWR hooks. + * Identical behaviour to the hook form but callable outside React components + * (e.g. directly in `useSWR` option objects at the top level of a hook file). + */ +export function makeOnErrorRetry( + maxRetries = MAX_RETRIES, + baseDelayMs = BASE_DELAY_MS, +): NonNullable { + return (error, _key, _config, revalidate, { retryCount }) => { + if (isClientError(error)) return; + if (retryCount >= maxRetries) return; + setTimeout(() => revalidate({ retryCount }), baseDelayMs * 2 ** retryCount); + }; +} + +/** Drop-in SWR config spread for standard retry behaviour. */ +export const swrRetryConfig: Pick = { + onErrorRetry: makeOnErrorRetry(), +}; + +export function useRetry(options: RetryOptions = {}): UseRetryReturn { + const maxRetries = options.maxRetries ?? MAX_RETRIES; + const baseDelayMs = options.baseDelayMs ?? BASE_DELAY_MS; + + const onErrorRetry: NonNullable = useCallback( + (error, _key, _config, revalidate, { retryCount }) => { + // Never retry client errors. + if (isClientError(error)) return; + // Cap at maxRetries. + if (retryCount >= maxRetries) return; + // Exponential back-off. + setTimeout(() => revalidate({ retryCount }), baseDelayMs * 2 ** retryCount); + }, + [maxRetries, baseDelayMs], + ); + + const withRetry = useCallback( + async (fn: () => Promise): Promise => { + let lastError: unknown; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + try { + return await fn(); + } catch (err) { + lastError = err; + // Do not retry 4xx client errors. + if (isClientError(err)) throw err; + // If we've exhausted attempts, throw. + if (attempt === maxRetries) break; + // Wait with exponential back-off before next attempt. + await new Promise(resolve => + setTimeout(resolve, baseDelayMs * 2 ** attempt), + ); + } + } + throw lastError; + }, + [maxRetries, baseDelayMs], + ); + + return { onErrorRetry, withRetry }; +} diff --git a/src/hooks/useSolvers.ts b/src/hooks/useSolvers.ts index 9547824..cd4b7ed 100644 --- a/src/hooks/useSolvers.ts +++ b/src/hooks/useSolvers.ts @@ -1,5 +1,6 @@ import useSWR from "swr"; import { fetcher } from "@/lib/api"; +import { swrRetryConfig } from "@/hooks/useRetry"; import type { Solver } from "@/lib/types"; // The solver list has no WebSocket coverage; it changes slowly (new @@ -12,6 +13,7 @@ export function useSolvers() { const { data, error, isLoading } = useSWR("/solvers", fetcher, { refreshInterval: 30_000, dedupingInterval: 30_000, + ...swrRetryConfig, }); return { solvers: data ?? [], isLoading, error }; diff --git a/src/lib/i18n/messages/en.ts b/src/lib/i18n/messages/en.ts index 0b3a108..b93a221 100644 --- a/src/lib/i18n/messages/en.ts +++ b/src/lib/i18n/messages/en.ts @@ -10,6 +10,8 @@ export const en = { "wallet.error.connectFailed": "Failed to connect wallet.", "swap.chainPicker.title": "Select source chain", + "swap.chainPicker.recent": "Recent", + "swap.chainPicker.selectChain": "Select {name}", "swap.from.label": "From", "swap.from.amountLabel": "Amount to swap", @@ -18,6 +20,9 @@ export const en = { "swap.from.selectToken": "Select source token, currently {symbol}", "swap.from.approxValue": "≈ ${value}", + "swap.prices.estimated": "est.", + "swap.prices.asOf": "Estimated price as of {date}. Live quote will update this once available.", + "swap.to.label": "To", "swap.to.tokenGroup": "Destination token", "swap.to.quoteLoading": "Loading quote…", @@ -35,7 +40,13 @@ export const en = { "swap.quote.protocolFee": "Protocol fee", "swap.quote.protocolFeeValue": "{percent}%", "swap.quote.rate": "Rate", + + "swap.quote.fillTime.tooltip": "Estimated time for a solver to fill your swap after you submit. Actual time may vary.", + "swap.quote.priceImpact.tooltip": "How much your trade moves the effective price relative to the mid-market rate. A high impact means you receive less than the quoted mid-market rate.", + "swap.quote.protocolFee.tooltip": "A small percentage fee charged by the Vortex protocol on each settled swap. It is deducted from the destination amount.", + "swap.quote.highPriceImpactWarning": "High price impact above {threshold}% — review before swapping.", "swap.quote.unavailable": "Live quote unavailable — showing an estimated rate.", + "swap.quote.noSolver": "No solver available for this route right now.", "swap.quote.staleWarning": "Quote is stale. Please wait for a refresh before submitting.", "swap.submit.connecting": "Connecting wallet…", diff --git a/src/lib/i18n/messages/es.ts b/src/lib/i18n/messages/es.ts index cbcdbe7..128c531 100644 --- a/src/lib/i18n/messages/es.ts +++ b/src/lib/i18n/messages/es.ts @@ -10,6 +10,8 @@ export const es = { "wallet.error.connectFailed": "No se pudo conectar la billetera.", "swap.chainPicker.title": "Seleccionar cadena origen", + "swap.chainPicker.recent": "Recientes", + "swap.chainPicker.selectChain": "Seleccionar {name}", "swap.from.label": "De", "swap.from.amountLabel": "Cantidad a intercambiar", @@ -18,6 +20,9 @@ export const es = { "swap.from.selectToken": "Seleccionar token origen, actualmente {symbol}", "swap.from.approxValue": "≈ ${value}", + "swap.prices.estimated": "est.", + "swap.prices.asOf": "Precio estimado al {date}. La cotización en vivo actualizará esto cuando esté disponible.", + "swap.to.label": "A", "swap.to.tokenGroup": "Token de destino", "swap.to.quoteLoading": "Cargando cotización…", @@ -35,7 +40,13 @@ export const es = { "swap.quote.protocolFee": "Comisión de protocolo", "swap.quote.protocolFeeValue": "{percent}%", "swap.quote.rate": "Tasa", + + "swap.quote.fillTime.tooltip": "Tiempo estimado para que un solver complete tu swap después de enviarlo. El tiempo real puede variar.", + "swap.quote.priceImpact.tooltip": "Cuánto mueve tu operación el precio efectivo respecto al precio de mercado. Un impacto alto significa que recibirás menos que la tasa de mercado.", + "swap.quote.protocolFee.tooltip": "Pequeño porcentaje de comisión que cobra el protocolo Vortex en cada swap liquidado. Se deduce del monto de destino.", "swap.quote.unavailable": "Cotización en tiempo real no disponible — mostrando tasa estimada.", + "swap.quote.noSolver": "No hay solver disponible para esta ruta en este momento.", + "swap.quote.staleWarning": "La cotización está desactualizada. Espera a que se actualice antes de enviar.", "swap.quote.highPriceImpactWarning": "Impacto de precio alto por encima de {threshold}% — revisa antes de intercambiar.", "swap.submit.connecting": "Conectando billetera…", @@ -50,6 +61,10 @@ export const es = { "swap.disclaimer": "El swap se liquida directamente en Stellar · Sin tokens envueltos · Protegido por bonos de solver", + "swap.destination.label": "Dirección de destino", + "swap.destination.placeholder": "G...", + "swap.destination.invalidAddress": "Introduce una dirección Stellar válida (empieza con G).", + "home.hero.eyebrow": "Stellar Agentic Hackathon 2025", "home.hero.titleLine1": "Intercambia desde cualquier cadena", "home.hero.titleLine2": "directamente a Stellar.", diff --git a/src/lib/marketData.ts b/src/lib/marketData.ts index e569c02..9e03826 100644 --- a/src/lib/marketData.ts +++ b/src/lib/marketData.ts @@ -1,3 +1,21 @@ +/** + * PRICES_AS_OF + * + * The date these hardcoded token prices were last updated (ISO 8601 date string). + * These values are **static estimates** used only for the approximate-USD display + * in SwapCard and the chains list — the real settlement amounts always come from + * the live `useQuote` result, never from these numbers. + * + * ── How to refresh ──────────────────────────────────────────────────────────── + * 1. Look up current prices on CoinGecko/CoinMarketCap for each token below. + * 2. Update the `priceUSD` values in `SRC_TOKENS` and `DST_TOKENS`. + * 3. Update this constant to today's date. + * 4. Open a PR — CI will verify the catalog and type-checks pass. + * + * A proper live-price oracle integration is tracked separately. + */ +export const PRICES_AS_OF = "2026-08-30"; + export const CHAINS = [ { id: "ethereum", name: "Ethereum", short: "ETH", color: "#627EEA" }, { id: "base", name: "Base", short: "BASE", color: "#0052FF" },