diff --git a/src/app/solve/SolvePageClient.tsx b/src/app/solve/SolvePageClient.tsx index 890745c..b76cf81 100644 --- a/src/app/solve/SolvePageClient.tsx +++ b/src/app/solve/SolvePageClient.tsx @@ -9,7 +9,8 @@ import { useAcceptIntent } from "@/hooks/useAcceptIntent"; import { useSolverRegistration } from "@/hooks/useSolverRegistration"; import { timeRemaining } from "@/lib/time"; import { isValidStellarPublicKey } from "@/lib/stellarAddress"; -import { getMessage } from "@/i18n/messages"; +import { useTranslation } from "@/lib/i18n/I18nProvider"; +import type { MessageKey } from "@/lib/i18n"; import { formatCurrency } from "@/lib/format"; import Link from "next/link"; @@ -21,14 +22,15 @@ const usdCompact = (value: number) => const MIN_BOND_USD = 50; -const REGISTRATION_LABEL: Record = { - connecting: getMessage("solve.register.states.connecting"), - building: getMessage("solve.register.states.building"), - "awaiting-signature": getMessage("solve.register.states.awaitingSignature"), - submitting: getMessage("solve.register.states.submitting"), +const REGISTRATION_LABEL_KEY: Record = { + connecting: "solve.register.states.connecting", + building: "solve.register.states.building", + "awaiting-signature": "solve.register.states.awaitingSignature", + submitting: "solve.register.states.submitting", }; export default function SolvePageClient() { + const { t } = useTranslation(); const [tab, setTab] = useState<"leaderboard" | "intents" | "register">("leaderboard"); const { solvers, isLoading: solversLoading, error: solversError } = useSolvers(); const { intents: openIntents, isLoading: intentsLoading, error: intentsError } = useOpenIntents(); @@ -37,15 +39,15 @@ export default function SolvePageClient() { const [address, setAddress] = useState(""); const [bond, setBond] = useState(""); const registration = useSolverRegistration(); - const isRegistering = registration.status in REGISTRATION_LABEL; + const isRegistering = registration.status in REGISTRATION_LABEL_KEY; const addressError = address && !isValidStellarPublicKey(address) - ? getMessage("solve.register.validation.invalidAddress") + ? t("solve.register.validation.invalidAddress") : null; const bondError = bond && (isNaN(parseFloat(bond)) || parseFloat(bond) < MIN_BOND_USD) - ? getMessage("solve.register.validation.minimumBond", { minBond: MIN_BOND_USD }) + ? t("solve.register.validation.minimumBond", { minBond: MIN_BOND_USD }) : null; const canRegister = Boolean(address) && Boolean(bond) && !addressError && !bondError && !isRegistering; @@ -63,7 +65,7 @@ export default function SolvePageClient() { return (
-
@@ -238,11 +240,11 @@ export default function SolvePageClient() { >
- {getMessage("solve.intents.title")} + {t("solve.intents.title")} - {getMessage("solve.intents.available", { count: openIntents.length })} + {t("solve.intents.available", { count: openIntents.length })}
@@ -258,11 +260,11 @@ export default function SolvePageClient() { ) : intentsError ? (
- {getMessage("solve.intents.error")} + {t("solve.intents.error")}
) : openIntents.length === 0 ? (
- {getMessage("solve.intents.empty")} + {t("solve.intents.empty")}
) : (
@@ -273,13 +275,13 @@ export default function SolvePageClient() { >
- {getMessage("solve.intents.id", { id: intent.id })} + {t("solve.intents.id", { id: intent.id })}
{intent.srcAmount} {intent.srcToken} on {intent.srcChain}
- {getMessage("solve.intents.details", { + {t("solve.intents.details", { minOut: intent.minOut, dstToken: intent.dstToken, timeRemaining: timeRemaining(intent.deadline), @@ -296,8 +298,8 @@ export default function SolvePageClient() { w-full sm:w-auto disabled:opacity-60 disabled:cursor-wait" > {acceptingId === intent.id - ? getMessage("solve.intents.accepting") - : getMessage("solve.intents.accept")} + ? t("solve.intents.accepting") + : t("solve.intents.accept")}
))} @@ -317,21 +319,21 @@ export default function SolvePageClient() {

- {getMessage("solve.register.title")} + {t("solve.register.title")}

-

{getMessage("solve.register.description")}

+

{t("solve.register.description")}

setAddress(e.target.value.trim())} - placeholder={getMessage("solve.register.addressPlaceholder")} + placeholder={t("solve.register.addressPlaceholder")} aria-invalid={Boolean(addressError)} aria-describedby={addressError ? "solver-address-error" : undefined} className="w-full bg-vx-surface border border-vx-border rounded-lg px-3 py-2.5 @@ -347,14 +349,14 @@ export default function SolvePageClient() {
setBond(e.target.value)} - placeholder={getMessage("solve.register.bondPlaceholder")} + placeholder={t("solve.register.bondPlaceholder")} aria-invalid={Boolean(bondError)} aria-describedby={bondError ? "solver-bond-error" : undefined} className="w-full bg-vx-surface border border-vx-border rounded-lg px-3 py-2.5 @@ -369,9 +371,9 @@ export default function SolvePageClient() {
-
{getMessage("solve.register.info.minimumBond")}
-
{getMessage("solve.register.info.slash")}
-
{getMessage("solve.register.info.withdraw")}
+
{t("solve.register.info.minimumBond")}
+
{t("solve.register.info.slash")}
+
{t("solve.register.info.withdraw")}
{registration.status === "error" && ( @@ -388,10 +390,10 @@ export default function SolvePageClient() { className="btn-swap" > {isRegistering - ? REGISTRATION_LABEL[registration.status] + ? t(REGISTRATION_LABEL_KEY[registration.status]!) : registration.status === "success" - ? getMessage("solve.register.button.registered") - : getMessage("solve.register.button.connect")} + ? t("solve.register.button.registered") + : t("solve.register.button.connect")}
diff --git a/src/components/Combobox.tsx b/src/components/Combobox.tsx new file mode 100644 index 0000000..08e07ce --- /dev/null +++ b/src/components/Combobox.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { useEffect, useId, useRef, useState } from "react"; + +export type ComboboxItem = { key: string; label: string }; + +type ComboboxProps = { + items: T[]; + value: T; + onChange: (item: T) => void; + ariaLabel: string; + triggerAriaLabel?: string; + placeholder?: string; + noMatchesLabel: string; + renderTrigger: (item: T, opts: { open: boolean }) => React.ReactNode; + renderOption: (item: T, opts: { active: boolean; selected: boolean }) => React.ReactNode; + triggerClassName?: string; + panelClassName?: string; +}; + +/** Reusable ARIA combobox/listbox: type-to-filter, roving focus, Escape/Home/End support. */ +export function Combobox({ + items, + value, + onChange, + ariaLabel, + triggerAriaLabel, + placeholder, + noMatchesLabel, + renderTrigger, + renderOption, + triggerClassName, + panelClassName, +}: ComboboxProps) { + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const [activeIndex, setActiveIndex] = useState(0); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const inputRef = useRef(null); + const listboxId = useId(); + + const filtered = query + ? items.filter((item) => item.label.toLowerCase().includes(query.toLowerCase())) + : items; + + const close = () => { + setOpen(false); + setQuery(""); + triggerRef.current?.focus(); + }; + + useEffect(() => { + if (open) { + setActiveIndex(0); + inputRef.current?.focus(); + } + }, [open]); + + useEffect(() => { + if (!open) return; + const onPointerDown = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) close(); + }; + document.addEventListener("mousedown", onPointerDown); + return () => document.removeEventListener("mousedown", onPointerDown); + }, [open]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + close(); + } else if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((i) => Math.min(i + 1, filtered.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === "Home") { + e.preventDefault(); + setActiveIndex(0); + } else if (e.key === "End") { + e.preventDefault(); + setActiveIndex(filtered.length - 1); + } else if (e.key === "Enter") { + e.preventDefault(); + const item = filtered[activeIndex]; + if (item) { + onChange(item); + close(); + } + } + }; + + return ( +
+ + {open && ( +
+ setQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + className="w-full mb-1.5 px-2.5 py-1.5 text-sm bg-vx-surface border border-vx-border rounded-md text-vx-text placeholder-vx-dim/60 focus:outline-none focus:border-vx-sage/50" + /> +
    + {filtered.length === 0 && ( +
  • {noMatchesLabel}
  • + )} + {filtered.map((item, i) => ( +
  • setActiveIndex(i)} + onClick={() => { + onChange(item); + close(); + }} + className={`cursor-pointer rounded-lg ${i === activeIndex ? "bg-vx-lav/15" : ""}`} + > + {renderOption(item, { active: i === activeIndex, selected: item.key === value.key })} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index 84b02b5..52561a0 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -1,14 +1,12 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useRef, useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { VortexLogo } from "./VortexLogo"; import { ConnectWalletButton } from "./ConnectWalletButton"; import { SettingsPanel } from "./SettingsPanel"; -import { getMessage } from "@/lib/i18n-legacy"; -import { useLocale, useSetLocale } from "@/lib/i18n/I18nProvider"; -import { LOCALES, type Locale } from "@/lib/i18n"; +import { useTranslation } from "@/lib/i18n/I18nProvider"; import { useWalletStore } from "@/store/wallet"; type NavProps = { variant: "home" } | { variant: "breadcrumb"; label: string }; @@ -23,6 +21,14 @@ export function Nav(props: NavProps) { const [mobileOpen, setMobileOpen] = useState(false); const isConnected = useWalletStore((s) => s.isConnected); const pathname = usePathname(); + const { t } = useTranslation(); + const toggleRef = useRef(null); + const panelRef = useRef(null); + + const closeMobileMenu = () => { + setMobileOpen(false); + toggleRef.current?.focus(); + }; return (