diff --git a/src/components/expenses/AssetSelector.tsx b/src/components/expenses/AssetSelector.tsx new file mode 100644 index 0000000..403d5e0 --- /dev/null +++ b/src/components/expenses/AssetSelector.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { Select } from "@/components/ui/input"; +import { AssetBadge } from "@/components/asset-badge"; +import { SETTLEMENT_ASSETS, STABLE_ASSET } from "@/lib/constants"; +import { convertToFiat, useCurrencyRates } from "@/hooks/useCurrencyRates"; +import type { SupportedFiatCurrency } from "@/lib/currency"; + +export interface AssetSelectorProps { + /** Currently selected asset code (one of the configured settlement assets). */ + value: string; + onChange: (assetCode: string) => void; + onBlur?: () => void; + id?: string; + /** Transition labels for the available options (e.g. "native", "stable"). */ + name?: (code: string) => string; + ariaInvalid?: boolean; + ariaDescribedby?: string; + /** + * When provided, the selector renders a live fiat-equivalent of this crypto + * amount (in `fiatCurrency`) that updates as the user types/edits the + * amount — the "real-time conversion" half of issue #401. + */ + cryptoAmount?: string; + fiatCurrency?: SupportedFiatCurrency; +} + +/** + * Controlled multi-asset selector (issue #401). + * + * A neobrutalist ` onChange(e.target.value)} + onBlur={onBlur} + aria-invalid={ariaInvalid} + aria-describedby={ariaDescribedby} + > + {SETTLEMENT_ASSETS.map((a) => ( + + ))} + + +
+ + + {equivalent} + +
+ + ); +} + +/** Fiat-equivalent of `cryptoAmount` in the selected asset, or a prompt. */ +function useLiveEquivalent( + cryptoAmount: string | undefined, + assetCode: string, + fiatCurrency: SupportedFiatCurrency +): string { + const { rates } = useCurrencyRates(fiatCurrency); + + if (!cryptoAmount) return "Enter an amount for a fiat preview."; + + const numeric = Number(cryptoAmount); + if (!Number.isFinite(numeric) || numeric < 0) + return "Fiat preview unavailable."; + + // Only the price-tracked assets (XLM / the stable asset) get a live quote. + const fiat = convertToFiat(cryptoAmount, assetCode, rates); + if (fiat === null) return "Fiat preview unavailable for this asset."; + + return `≈ ${fiat} ${fiatCurrency}`; +} \ No newline at end of file diff --git a/src/components/expenses/__tests__/asset-selector.test.ts b/src/components/expenses/__tests__/asset-selector.test.ts new file mode 100644 index 0000000..2da0004 --- /dev/null +++ b/src/components/expenses/__tests__/asset-selector.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { convertToFiat } from "@/hooks/useCurrencyRates"; +import type { CryptoToFiatRates } from "@/hooks/useCurrencyRates"; + +const rates: CryptoToFiatRates = { xlm: 0.12, usdc: 1.0, live: true }; + +describe("convertToFiat (AssetSelector conversion)", () => { + it("converts XLM and USDC to a two-decimal fiat string", () => { + expect(convertToFiat("100", "XLM", rates)).toBe("12.00"); + expect(convertToFiat("100", "USDC", rates)).toBe("100.00"); + }); + + it("accepts numeric input and trims to two decimals", () => { + expect(convertToFiat("10.555", "XLM", rates)).toBe("1.27"); + expect(convertToFiat(120.5, "USDC", rates)).toBe("120.50"); + }); + + it("returns null for untracked (custom trustline) assets", () => { + expect(convertToFiat("50", "RANDOM", rates)).toBeNull(); + }); + + it("rejects invalid, missing or negative amounts", () => { + expect(convertToFiat("", "XLM", rates)).toBeNull(); + expect(convertToFiat("abc", "XLM", rates)).toBeNull(); + expect(convertToFiat("-5", "XLM", rates)).toBeNull(); + }); + + it("returns null when the rate is zero/unavailable", () => { + const dead: CryptoToFiatRates = { xlm: 0, usdc: 0, live: false }; + expect(convertToFiat("10", "XLM", dead)).toBeNull(); + }); +}); \ No newline at end of file diff --git a/src/components/expenses/add-expense-dialog.tsx b/src/components/expenses/add-expense-dialog.tsx index e6536ab..b1f8554 100644 --- a/src/components/expenses/add-expense-dialog.tsx +++ b/src/components/expenses/add-expense-dialog.tsx @@ -12,7 +12,8 @@ import { cn } from "@/lib/utils"; import { useCreateExpense } from "@/lib/queries"; import { api } from "@/lib/api"; import { handleApiError } from "@/lib/errorHandler"; -import { SETTLEMENT_ASSETS, STABLE_ASSET } from "@/lib/constants"; +import { SETTLEMENT_ASSETS } from "@/lib/constants"; +import { AssetSelector } from "@/components/expenses/AssetSelector"; import type { GroupMember, SplitType, ExpenseShareInput } from "@/lib/types"; import { AMOUNT_DECIMAL_PLACES,