From b10cdf4c9ba1e54c5cf244b25488df6ce22b2b6d Mon Sep 17 00:00:00 2001 From: LordDregg Date: Mon, 30 Mar 2026 00:22:32 +0100 Subject: [PATCH 1/2] ui: replace react-hot-toast with sonner toast notification system --- frontend/package-lock.json | 125 ++++++++++++++---- frontend/package.json | 4 +- .../src/app/(authenticated)/api-keys/page.tsx | 2 +- .../src/app/(authenticated)/settings/page.tsx | 2 +- frontend/src/app/(public)/pay/[id]/page.tsx | 2 +- frontend/src/app/globals.css | 71 ++++++++++ frontend/src/components/CreatePaymentForm.tsx | 2 +- .../src/components/PaymentDetailModal.tsx | 2 +- frontend/src/components/RegistrationForm.tsx | 2 +- frontend/src/components/ToastProvider.tsx | 27 ++-- frontend/src/components/WithdrawalModal.tsx | 2 +- 11 files changed, 186 insertions(+), 55 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 689ef9c..a71a9e6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,9 +18,10 @@ "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-hot-toast": "^2.6.0", "react-loading-skeleton": "^3.5.0", "recharts": "^2.15.4", + "socket.io-client": "^4.8.1", + "sonner": "^2.0.7", "stellar-sdk": "^12.2.0", "zustand": "^5.0.12" }, @@ -3004,6 +3005,12 @@ "webpack": ">=5.0.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@stellar/freighter-api": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@stellar/freighter-api/-/freighter-api-1.7.1.tgz", @@ -5282,6 +5289,28 @@ "dev": true, "license": "MIT" }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", @@ -6507,15 +6536,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/goober": { - "version": "2.1.18", - "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", - "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", - "license": "MIT", - "peerDependencies": { - "csstype": "^3.0.10" - } - }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -8224,6 +8244,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -8555,23 +8576,6 @@ "react": "^18.3.1" } }, - "node_modules/react-hot-toast": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", - "integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==", - "license": "MIT", - "dependencies": { - "csstype": "^3.1.3", - "goober": "^2.1.16" - }, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" - } - }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -9321,6 +9325,34 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.6", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", + "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/sodium-native": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-4.3.3.tgz", @@ -9331,6 +9363,16 @@ "require-addon": "^1.1.0" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -10654,6 +10696,35 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4d8cf04..43d3840 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,10 +20,10 @@ "qrcode.react": "^4.2.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-hot-toast": "^2.6.0", "react-loading-skeleton": "^3.5.0", "recharts": "^2.15.4", "socket.io-client": "^4.8.1", + "sonner": "^2.0.7", "stellar-sdk": "^12.2.0", "zustand": "^5.0.12" }, @@ -39,4 +39,4 @@ "tailwindcss": "^3.4.7", "typescript": "5.9.3" } -} \ No newline at end of file +} diff --git a/frontend/src/app/(authenticated)/api-keys/page.tsx b/frontend/src/app/(authenticated)/api-keys/page.tsx index af5cac7..5172a36 100644 --- a/frontend/src/app/(authenticated)/api-keys/page.tsx +++ b/frontend/src/app/(authenticated)/api-keys/page.tsx @@ -3,7 +3,7 @@ import { useMerchantApiKey } from "@/lib/merchant-store"; import { useState } from "react"; import CopyButton from "@/components/CopyButton"; -import toast from "react-hot-toast"; +import { toast } from "sonner"; export default function apiKeysPage() { const storedApiKey = useMerchantApiKey(); diff --git a/frontend/src/app/(authenticated)/settings/page.tsx b/frontend/src/app/(authenticated)/settings/page.tsx index e1cf5e6..807f1f8 100644 --- a/frontend/src/app/(authenticated)/settings/page.tsx +++ b/frontend/src/app/(authenticated)/settings/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; import CopyButton from "@/components/CopyButton"; -import toast from "react-hot-toast"; +import { toast } from "sonner"; import { useHydrateMerchantStore, useMerchantApiKey, diff --git a/frontend/src/app/(public)/pay/[id]/page.tsx b/frontend/src/app/(public)/pay/[id]/page.tsx index f61bda6..079c1a0 100644 --- a/frontend/src/app/(public)/pay/[id]/page.tsx +++ b/frontend/src/app/(public)/pay/[id]/page.tsx @@ -6,7 +6,7 @@ import { useWallet } from "@/lib/wallet-context"; import { usePayment } from "@/lib/usePayment"; import CopyButton from "@/components/CopyButton"; import WalletSelector from "@/components/WalletSelector"; -import toast from "react-hot-toast"; +import { toast } from "sonner"; import Skeleton, { SkeletonTheme } from "react-loading-skeleton"; import "react-loading-skeleton/dist/skeleton.css"; import { QRCodeSVG } from "qrcode.react"; diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index ac1fa53..51778b4 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -16,3 +16,74 @@ body { padding-bottom: calc(5.5rem + env(safe-area-inset-bottom)); } } + +/* ─── Sonner Toast Theme ─────────────────────────────── */ +[data-sonner-toaster] { + --width: 360px; +} + +[data-sonner-toaster][data-theme="dark"] { + --normal-bg: #0d1c2e; + --normal-border: rgba(255, 255, 255, 0.08); + --normal-text: #f3f5f7; + --success-bg: #0d1c2e; + --success-border: rgba(94, 242, 192, 0.28); + --success-text: #f3f5f7; + --error-bg: #0d1c2e; + --error-border: rgba(248, 113, 113, 0.28); + --error-text: #f3f5f7; + --warning-bg: #0d1c2e; + --warning-border: rgba(251, 191, 36, 0.28); + --warning-text: #f3f5f7; + --info-bg: #0d1c2e; + --info-border: rgba(96, 165, 250, 0.28); + --info-text: #f3f5f7; +} + +[data-sonner-toast] { + border-radius: 10px !important; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.04) !important; + backdrop-filter: blur(20px); +} + +[data-sonner-toast] [data-title] { + font-weight: 500; + letter-spacing: -0.01em; +} + +[data-sonner-toast] [data-description] { + color: rgba(243, 245, 247, 0.65) !important; +} + +/* Success icon color */ +[data-sonner-toast][data-type="success"] [data-icon] svg { + color: #5ef2c0; +} + +/* Error icon color */ +[data-sonner-toast][data-type="error"] [data-icon] svg { + color: #f87171; +} + +/* Warning icon color */ +[data-sonner-toast][data-type="warning"] [data-icon] svg { + color: #fbbf24; +} + +/* Info icon color */ +[data-sonner-toast][data-type="info"] [data-icon] svg { + color: #60a5fa; +} + +/* Close button */ +[data-sonner-toast] [data-close-button] { + background: rgba(255, 255, 255, 0.06) !important; + border-color: rgba(255, 255, 255, 0.1) !important; + color: rgba(243, 245, 247, 0.5) !important; + transition: background 0.15s, color 0.15s; +} + +[data-sonner-toast] [data-close-button]:hover { + background: rgba(255, 255, 255, 0.12) !important; + color: #f3f5f7 !important; +} diff --git a/frontend/src/components/CreatePaymentForm.tsx b/frontend/src/components/CreatePaymentForm.tsx index 931c3e1..205031d 100644 --- a/frontend/src/components/CreatePaymentForm.tsx +++ b/frontend/src/components/CreatePaymentForm.tsx @@ -2,7 +2,7 @@ import { useState, type FormEvent } from "react"; import CopyButton from "./CopyButton"; -import toast from "react-hot-toast"; +import { toast } from "sonner"; import Link from "next/link"; import { useHydrateMerchantStore, diff --git a/frontend/src/components/PaymentDetailModal.tsx b/frontend/src/components/PaymentDetailModal.tsx index 127b06a..776cd29 100644 --- a/frontend/src/components/PaymentDetailModal.tsx +++ b/frontend/src/components/PaymentDetailModal.tsx @@ -5,7 +5,7 @@ import { useWallet } from "@/lib/wallet-context"; import { usePayment } from "@/lib/usePayment"; import WalletSelector from "@/components/WalletSelector"; import CopyButton from "@/components/CopyButton"; -import toast from "react-hot-toast"; +import { toast } from "sonner"; import { QRCodeSVG } from "qrcode.react"; const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000"; diff --git a/frontend/src/components/RegistrationForm.tsx b/frontend/src/components/RegistrationForm.tsx index 923b494..ae2ce1c 100644 --- a/frontend/src/components/RegistrationForm.tsx +++ b/frontend/src/components/RegistrationForm.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { registerMerchant, type Merchant } from "../lib/auth"; import CopyButton from "./CopyButton"; -import toast from "react-hot-toast"; +import { toast } from "sonner"; import { useSetMerchantApiKey, useSetMerchantMetadata, diff --git a/frontend/src/components/ToastProvider.tsx b/frontend/src/components/ToastProvider.tsx index 563157b..381eefa 100644 --- a/frontend/src/components/ToastProvider.tsx +++ b/frontend/src/components/ToastProvider.tsx @@ -1,31 +1,20 @@ "use client"; -import { Toaster } from "react-hot-toast"; +import { Toaster } from "sonner"; export default function ToastProvider() { return ( diff --git a/frontend/src/components/WithdrawalModal.tsx b/frontend/src/components/WithdrawalModal.tsx index 358da08..789cdaa 100644 --- a/frontend/src/components/WithdrawalModal.tsx +++ b/frontend/src/components/WithdrawalModal.tsx @@ -4,7 +4,7 @@ import React, { useState, useEffect } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { getAnchorServices, authenticateWithAnchor, initiateWithdrawal } from "@/lib/stellar"; import { signWithFreighter, getFreighterPublicKey } from "@/lib/freighter"; -import toast from "react-hot-toast"; +import { toast } from "sonner"; interface WithdrawalModalProps { isOpen: boolean; From 1326acd9f9f36a8d9cbe77cec540f1830056f119 Mon Sep 17 00:00:00 2001 From: LordDregg Date: Mon, 30 Mar 2026 06:10:02 +0100 Subject: [PATCH 2/2] fix: #389 [FE] UX: Add 'Asset Conversion Tool' Modal within Dashboard --- frontend/src/components/AssetConverter.tsx | 488 +++++++++++++++++++++ frontend/src/components/CommandPalette.tsx | 40 +- 2 files changed, 525 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/AssetConverter.tsx diff --git a/frontend/src/components/AssetConverter.tsx b/frontend/src/components/AssetConverter.tsx new file mode 100644 index 0000000..ed0edb2 --- /dev/null +++ b/frontend/src/components/AssetConverter.tsx @@ -0,0 +1,488 @@ +"use client"; + +import React, { useState, useRef, useEffect, useCallback } from "react"; +import * as StellarSdk from "stellar-sdk"; +import { useWallet } from "@/lib/wallet-context"; +import { resolveAsset } from "@/lib/stellar"; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface PathRecord { + destination_amount: string; + source_amount: string; + path: Array<{ asset_type: string; asset_code?: string; asset_issuer?: string }>; +} + +interface QuoteResult { + sourceAsset: string; + sourceAmount: string; + destAsset: string; + destAmount: string; + rate: string; + path: string[]; + queriedAt: number; +} + +/* ------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------ */ + +const HORIZON_URL = + process.env.NEXT_PUBLIC_HORIZON_URL ?? "https://horizon-testnet.stellar.org"; + +const SLIPPAGE = 0.01; + +/* ------------------------------------------------------------------ */ +/* Icons */ +/* ------------------------------------------------------------------ */ + +function SwapIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function ArrowLeftIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +function RefreshIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +function SpinnerIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function formatAssetLabel(code: string, issuer: string | null): string { + if (!issuer || code.toUpperCase() === "XLM") return code.toUpperCase(); + return `${code.toUpperCase()}:${issuer.slice(0, 4)}…${issuer.slice(-4)}`; +} + +function formatPathHop(asset: { asset_type: string; asset_code?: string; asset_issuer?: string }): string { + if (asset.asset_type === "native") return "XLM"; + return asset.asset_code?.toUpperCase() ?? "?"; +} + +function isValidAssetCode(code: string): boolean { + return /^[a-zA-Z0-9]{1,12}$/.test(code); +} + +function isValidStellarAddress(addr: string): boolean { + return /^G[A-Z2-7]{55}$/.test(addr); +} + +/* ------------------------------------------------------------------ */ +/* Component */ +/* ------------------------------------------------------------------ */ + +interface AssetConverterProps { + onBack: () => void; +} + +export default function AssetConverter({ onBack }: AssetConverterProps) { + const { activeProvider } = useWallet(); + const firstInputRef = useRef(null); + + // From fields + const [fromCode, setFromCode] = useState("XLM"); + const [fromIssuer, setFromIssuer] = useState(""); + const [amount, setAmount] = useState(""); + + // To fields + const [toCode, setToCode] = useState("USDC"); + const [toIssuer, setToIssuer] = useState( + "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5", + ); + + // State + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + // Focus first input on mount + useEffect(() => { + requestAnimationFrame(() => firstInputRef.current?.focus()); + }, []); + + /* ---------- staleness timer ---------- */ + const [secondsAgo, setSecondsAgo] = useState(0); + useEffect(() => { + if (!result) return; + setSecondsAgo(0); + const id = setInterval(() => { + setSecondsAgo(Math.floor((Date.now() - result.queriedAt) / 1000)); + }, 1000); + return () => clearInterval(id); + }, [result]); + + /* ---------- validation ---------- */ + function validate(): string | null { + if (!activeProvider) return "Connect a wallet to query paths"; + if (!fromCode.trim()) return "Enter source asset code"; + if (!isValidAssetCode(fromCode.trim())) return "Invalid source asset code (1-12 alphanumeric)"; + if (fromCode.toUpperCase() !== "XLM" && !fromIssuer.trim()) return "Issuer address required for non-native source asset"; + if (fromCode.toUpperCase() !== "XLM" && fromIssuer.trim() && !isValidStellarAddress(fromIssuer.trim())) return "Invalid source issuer address"; + if (!toCode.trim()) return "Enter destination asset code"; + if (!isValidAssetCode(toCode.trim())) return "Invalid destination asset code (1-12 alphanumeric)"; + if (toCode.toUpperCase() !== "XLM" && !toIssuer.trim()) return "Issuer address required for non-native destination asset"; + if (toCode.toUpperCase() !== "XLM" && toIssuer.trim() && !isValidStellarAddress(toIssuer.trim())) return "Invalid destination issuer address"; + if (!amount.trim() || isNaN(Number(amount)) || Number(amount) <= 0) return "Enter a valid positive amount"; + if (fromCode.toUpperCase() === toCode.toUpperCase() && (fromIssuer || "") === (toIssuer || "")) return "Source and destination assets must be different"; + return null; + } + + /* ---------- query Horizon ---------- */ + const convert = useCallback(async () => { + const validationError = validate(); + if (validationError) { + setError(validationError); + return; + } + + setLoading(true); + setError(null); + setResult(null); + + try { + const server = new StellarSdk.Horizon.Server(HORIZON_URL); + + const sourceAsset = resolveAsset( + fromCode.trim().toUpperCase(), + fromCode.toUpperCase() === "XLM" ? null : fromIssuer.trim(), + ); + const destAsset = resolveAsset( + toCode.trim().toUpperCase(), + toCode.toUpperCase() === "XLM" ? null : toIssuer.trim(), + ); + + const response = await server + .strictSendPaths(sourceAsset, amount.trim(), [destAsset]) + .call(); + + const records = response.records as PathRecord[]; + + if (!records || records.length === 0) { + setError("No conversion path available for this asset pair"); + return; + } + + // Pick the best path (highest destination amount) + const best = records.reduce((a, b) => + Number(b.destination_amount) > Number(a.destination_amount) ? b : a, + ); + + const srcAmt = Number(best.source_amount); + const dstAmt = Number(best.destination_amount); + const rate = srcAmt > 0 ? (dstAmt / srcAmt).toFixed(7) : "0"; + + const pathLabels = [ + formatAssetLabel(fromCode, fromCode.toUpperCase() === "XLM" ? null : fromIssuer), + ...best.path.map(formatPathHop), + formatAssetLabel(toCode, toCode.toUpperCase() === "XLM" ? null : toIssuer), + ]; + + setResult({ + sourceAsset: formatAssetLabel(fromCode, fromCode.toUpperCase() === "XLM" ? null : fromIssuer), + sourceAmount: best.source_amount, + destAsset: formatAssetLabel(toCode, toCode.toUpperCase() === "XLM" ? null : toIssuer), + destAmount: best.destination_amount, + rate, + path: pathLabels, + queriedAt: Date.now(), + }); + } catch (err) { + if (err instanceof Error && err.message.includes("404")) { + setError("No conversion path available for this asset pair"); + } else { + setError( + err instanceof Error + ? err.message + : "Horizon unavailable. Try again.", + ); + } + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeProvider, fromCode, fromIssuer, toCode, toIssuer, amount]); + + /* ---------- swap ---------- */ + function handleSwap() { + setFromCode(toCode); + setFromIssuer(toIssuer); + setToCode(fromCode); + setToIssuer(fromIssuer); + setResult(null); + setError(null); + } + + /* ---------- keyboard ---------- */ + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && !loading) { + e.preventDefault(); + convert(); + } + } + + const isNativeFrom = fromCode.toUpperCase() === "XLM"; + const isNativeTo = toCode.toUpperCase() === "XLM"; + + return ( +
+ {/* ── Header ── */} +
+ +
+ + + + + Asset Converter +
+ + ESC + +
+ + {/* ── Body ── */} +
+ + {/* FROM */} +
+ + From + +
+ { + setFromCode(e.target.value.toUpperCase().slice(0, 12)); + setResult(null); + }} + placeholder="XLM" + className="w-28 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-slate-600 outline-none transition-colors focus:border-mint focus:ring-1 focus:ring-mint" + aria-label="Source asset code" + /> + {!isNativeFrom && ( + { + setFromIssuer(e.target.value); + setResult(null); + }} + placeholder="Issuer G…" + className="flex-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2 font-mono text-xs text-white placeholder-slate-600 outline-none transition-colors focus:border-mint focus:ring-1 focus:ring-mint" + aria-label="Source asset issuer" + /> + )} +
+ { + const v = e.target.value; + if (v === "" || /^\d*\.?\d*$/.test(v)) { + setAmount(v); + setResult(null); + } + }} + placeholder="Amount" + className="rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-slate-600 outline-none transition-colors focus:border-mint focus:ring-1 focus:ring-mint" + aria-label="Amount to convert" + /> +
+ + {/* SWAP */} +
+ +
+ + {/* TO */} +
+ + To + +
+ { + setToCode(e.target.value.toUpperCase().slice(0, 12)); + setResult(null); + }} + placeholder="USDC" + className="w-28 rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm text-white placeholder-slate-600 outline-none transition-colors focus:border-mint focus:ring-1 focus:ring-mint" + aria-label="Destination asset code" + /> + {!isNativeTo && ( + { + setToIssuer(e.target.value); + setResult(null); + }} + placeholder="Issuer G…" + className="flex-1 rounded-lg border border-white/10 bg-white/5 px-3 py-2 font-mono text-xs text-white placeholder-slate-600 outline-none transition-colors focus:border-mint focus:ring-1 focus:ring-mint" + aria-label="Destination asset issuer" + /> + )} +
+
+ + {/* CONVERT BUTTON */} + + + {/* ERROR */} + {error && ( +
+ {error} +
+ )} + + {/* RESULT */} + {result && ( +
+ {/* Rate headline */} +
+ + Rate + + + 1 {result.sourceAsset} ≈ {result.rate} {result.destAsset} + +
+ + {/* You send */} +
+ You send + + {result.sourceAmount} {result.sourceAsset} + +
+ + {/* You receive */} +
+ You receive + + {result.destAmount} {result.destAsset} + +
+ + {/* Slippage */} +
+ Slippage buffer + + ≤ {(Number(result.sourceAmount) * (1 + SLIPPAGE)).toFixed(7)} {result.sourceAsset} ({(SLIPPAGE * 100).toFixed(0)}%) + +
+ + {/* Path */} + {result.path.length > 2 && ( +
+ Path + + {result.path.join(" → ")} + +
+ )} + + {/* Staleness + refresh */} +
+ + Quoted {secondsAgo}s ago + + +
+
+ )} +
+ + {/* ── Footer ── */} +
+ + + ↵ + + convert + + + + esc + + back + + {!activeProvider && ( + + Wallet not connected + + )} +
+
+ ); +} diff --git a/frontend/src/components/CommandPalette.tsx b/frontend/src/components/CommandPalette.tsx index 423b190..76b06de 100644 --- a/frontend/src/components/CommandPalette.tsx +++ b/frontend/src/components/CommandPalette.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; +import AssetConverter from "@/components/AssetConverter"; /* ------------------------------------------------------------------ */ /* Command definitions */ @@ -11,7 +12,8 @@ type Command = { id: string; label: string; description: string; - href: string; + href?: string; + action?: string; icon: React.ReactNode; keywords: string[]; }; @@ -137,6 +139,19 @@ const commands: Command[] = [ icon: RegisterIcon, keywords: ["register", "merchant", "signup", "account", "new"], }, + { + id: "asset-converter", + label: "Asset Converter", + description: "Look up real-time Stellar conversion rates", + action: "converter", + icon: ( + + + + + ), + keywords: ["convert", "converter", "rate", "exchange", "swap", "xlm", "usdc", "path", "calculator", "asset"], + }, ]; /* ------------------------------------------------------------------ */ @@ -147,6 +162,7 @@ export default function CommandPalette() { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); const [activeIndex, setActiveIndex] = useState(0); + const [view, setView] = useState<"commands" | "converter">("commands"); const inputRef = useRef(null); const listRef = useRef(null); const router = useRouter(); @@ -182,6 +198,7 @@ export default function CommandPalette() { if (open) { setQuery(""); setActiveIndex(0); + setView("commands"); // Small delay so the DOM renders first requestAnimationFrame(() => inputRef.current?.focus()); } @@ -202,8 +219,12 @@ export default function CommandPalette() { /* ---------- select a command ---------- */ const select = useCallback( (cmd: Command) => { + if (cmd.action === "converter") { + setView("converter"); + return; + } setOpen(false); - router.push(cmd.href); + if (cmd.href) router.push(cmd.href); }, [router], ); @@ -212,10 +233,17 @@ export default function CommandPalette() { function handlePaletteKeydown(e: React.KeyboardEvent) { if (e.key === "Escape") { e.preventDefault(); - setOpen(false); + if (view === "converter") { + setView("commands"); + } else { + setOpen(false); + } return; } + // Block arrow/enter handling when converter is active + if (view === "converter") return; + if (e.key === "ArrowDown") { e.preventDefault(); setActiveIndex((i) => (i + 1) % filtered.length); @@ -251,6 +279,10 @@ export default function CommandPalette() { onClick={(e) => e.stopPropagation()} onKeyDown={handlePaletteKeydown} > + {view === "converter" ? ( + setView("commands")} /> + ) : ( + <> {/* search input */}
+ + )} );