From 6d88c81e99bec6badf758fd53330220a2283e65d Mon Sep 17 00:00:00 2001 From: jamilahmadzai Date: Fri, 3 Jul 2026 17:59:52 +0200 Subject: [PATCH] feat: format quote result values --- README.md | 8 ++++ src/app/quote/Client.tsx | 44 ++++++++++++++++++++-- src/app/quote/page.test.tsx | 75 +++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 220a252..ab44b91 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,14 @@ The frontend communicates with the StableRoute API backend. - **`/api/v1/events`**: Retrieves system event audit logs (`GET`). - **`/api/v1/webhooks`**: Creates (`POST`), lists (`GET`), and revokes (`DELETE` at `/api/v1/webhooks/:id`) webhook subscriptions. +### Quote Amount Display + +The quote API accepts and returns amounts in base units. The `/quote` page keeps +the raw API values intact for requests and operator inspection, while rendering +safe integer quote amounts through the shared `formatStroops` helper and numeric +rates through `formatNumber`. If a backend value cannot be parsed safely, the UI +falls back to the raw string instead of rounding or coercing it. + ### Asset Codes Stellar asset codes entered through the new-pair form are trimmed, validated as diff --git a/src/app/quote/Client.tsx b/src/app/quote/Client.tsx index 4546213..1216729 100644 --- a/src/app/quote/Client.tsx +++ b/src/app/quote/Client.tsx @@ -2,6 +2,8 @@ import { useState } from "react"; import type { ApiError } from "@/lib/apiClient"; +import { formatNumber, formatStroops } from "@/lib/format"; +import { assetsDiffer, isValidAmount } from "@/lib/quote"; type Quote = { source_asset: string; @@ -23,6 +25,24 @@ function normalizeAssetCode(value: string): string | null { return ASSET_CODE_PATTERN.test(trimmed) ? trimmed : null; } +function parseSafeInteger(value: string): number | null { + if (!/^[0-9]+$/.test(value)) { + return null; + } + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} + +function formatQuoteAmount(amount: string): string { + const parsed = parseSafeInteger(amount); + return parsed === null ? amount : formatStroops(parsed); +} + +function formatQuoteRate(rate: string): string { + const parsed = Number(rate); + return Number.isFinite(parsed) ? formatNumber(parsed) : rate; +} + export default function QuoteClient() { const [sourceAsset, setSourceAsset] = useState(""); const [destAsset, setDestAsset] = useState(""); @@ -38,11 +58,19 @@ export default function QuoteClient() { setRequestId(null); setQuote(null); - if (!assetsDiffer(sourceAsset, destAsset)) { + const normalizedSourceAsset = normalizeAssetCode(sourceAsset); + const normalizedDestAsset = normalizeAssetCode(destAsset); + const normalizedAmount = amount.trim(); + + if (!normalizedSourceAsset || !normalizedDestAsset) { + setError("Asset codes must be 1-12 letters or numbers."); + return; + } + if (!assetsDiffer(normalizedSourceAsset, normalizedDestAsset)) { setError("Source and destination assets must differ."); return; } - if (!isValidAmount(amount)) { + if (!isValidAmount(normalizedAmount)) { setError("Amount must be a positive integer (base units)."); return; } @@ -133,8 +161,16 @@ export default function QuoteClient() { className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm dark:border-emerald-900 dark:bg-emerald-950" >

Route: {quote.route.join(" → ")}

-

Amount: {quote.amount}

-

Estimated rate: {quote.estimated_rate}

+

+ Amount:{" "} + {formatQuoteAmount(quote.amount)} +

+

+ Estimated rate:{" "} + + {formatQuoteRate(quote.estimated_rate)} + +

)} {error && ( diff --git a/src/app/quote/page.test.tsx b/src/app/quote/page.test.tsx index 34680ab..0e84b88 100644 --- a/src/app/quote/page.test.tsx +++ b/src/app/quote/page.test.tsx @@ -53,6 +53,81 @@ describe("QuotePage", () => { ); }); + it("formats quote amount and estimated rate while preserving raw values", async () => { + const mockFetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ + source_asset: "USDC", + dest_asset: "EURC", + amount: "100000000", + estimated_rate: "1234.5", + route: ["USDC", "EURC"], + }), + } as unknown as Response); + globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch; + + render(); + fireEvent.change(screen.getByLabelText(/Source asset/i), { + target: { value: "USDC" }, + }); + fireEvent.change(screen.getByLabelText(/Destination asset/i), { + target: { value: "EURC" }, + }); + fireEvent.change(screen.getByLabelText(/Amount/i), { + target: { value: "100000000" }, + }); + fireEvent.click(screen.getByRole("button", { name: /Get quote/i })); + + await waitFor(() => { + expect(screen.getByRole("status")).toHaveTextContent("Amount: 10.00 XLM"); + }); + expect(screen.getByRole("status")).toHaveTextContent( + "Estimated rate: 1,234.5", + ); + expect(screen.getByTitle("100000000")).toHaveTextContent("10.00 XLM"); + expect(screen.getByTitle("1234.5")).toHaveTextContent("1,234.5"); + }); + + it("falls back to raw quote values when formatting is unsafe", async () => { + const unsafeAmount = "9007199254740993"; + const mockFetch = jest.fn().mockResolvedValueOnce({ + ok: true, + json: async () => ({ + source_asset: "USDC", + dest_asset: "EURC", + amount: unsafeAmount, + estimated_rate: "rate unavailable", + route: ["USDC", "EURC"], + }), + } as unknown as Response); + globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch; + + render(); + fireEvent.change(screen.getByLabelText(/Source asset/i), { + target: { value: "USDC" }, + }); + fireEvent.change(screen.getByLabelText(/Destination asset/i), { + target: { value: "EURC" }, + }); + fireEvent.change(screen.getByLabelText(/Amount/i), { + target: { value: "100000000" }, + }); + fireEvent.click(screen.getByRole("button", { name: /Get quote/i })); + + await waitFor(() => { + expect(screen.getByRole("status")).toHaveTextContent( + `Amount: ${unsafeAmount}`, + ); + }); + expect(screen.getByRole("status")).toHaveTextContent( + "Estimated rate: rate unavailable", + ); + expect(screen.getByTitle(unsafeAmount)).toHaveTextContent(unsafeAmount); + expect(screen.getByTitle("rate unavailable")).toHaveTextContent( + "rate unavailable", + ); + }); + it("blocks submission when source == destination", async () => { const mockFetch = jest.fn(); globalThis.fetch = mockFetch as unknown as typeof globalThis.fetch;