Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions src/components/expenses/AssetSelector.tsx
Original file line number Diff line number Diff line change
@@ -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 `<select>` of the configured settlement assets (XLM native,
* the stable asset, and any custom trustline tokens) plus a live fiat
* equivalent preview. It is fully controlled — the parent owns `value` and
* `onChange` — so it composes with React Hook Form, local `useState`, or a
* draft store. Precision/rounding for the Stellar grid is delegated to the
* existing `src/lib/money` / `src/lib/currency` helpers so the dozens of
* decimal places (7 = 1 stroop) are never truncated here.
*/
export function AssetSelector({
value,
onChange,
onBlur,
id,
name: nameOf,
ariaInvalid,
ariaDescribedby,
cryptoAmount,
fiatCurrency = "USD",
}: AssetSelectorProps) {
const selected =
SETTLEMENT_ASSETS.find((a) => a.code === value) ?? SETTLEMENT_ASSETS[0];

const equivalent = useLiveEquivalent(cryptoAmount, selected.code, fiatCurrency);

return (
<div className="space-y-1.5">
<Select
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
aria-invalid={ariaInvalid}
aria-describedby={ariaDescribedby}
>
{SETTLEMENT_ASSETS.map((a) => (
<option key={a.code} value={a.code}>
{a.code}
{nameOf
? ` (${nameOf(a.code)})`
: a.code === "XLM"
? " (native)"
: a.code === STABLE_ASSET.code
? " (stable)"
: ""}
</option>
))}
</Select>

<div className="flex items-center justify-between gap-2 pt-0.5">
<AssetBadge code={selected.code} className="shrink-0" />
<span
className="truncate text-xs font-medium text-ink/60"
aria-live="polite"
data-testid="asset-equivalent"
>
{equivalent}
</span>
</div>
</div>
);
}

/** 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}`;
}
32 changes: 32 additions & 0 deletions src/components/expenses/__tests__/asset-selector.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
3 changes: 2 additions & 1 deletion src/components/expenses/add-expense-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading