feat(ui): add dynamic amount currency prefix, normalize fiat symbols, and simplify data source badge
This commit is contained in:
@@ -18,6 +18,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
|
import { getDisplaySymbol } from "@/lib/currency-display";
|
||||||
import { useMarketRates } from "@/hooks/use-market-rates";
|
import { useMarketRates } from "@/hooks/use-market-rates";
|
||||||
import {
|
import {
|
||||||
formatAmount,
|
formatAmount,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
formatTimestamp,
|
formatTimestamp,
|
||||||
} from "@/lib/format";
|
} from "@/lib/format";
|
||||||
import { buildRateMap, convertAmount } from "@/lib/rates";
|
import { buildRateMap, convertAmount } from "@/lib/rates";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { validateAmount } from "@/lib/validation";
|
import { validateAmount } from "@/lib/validation";
|
||||||
|
|
||||||
const DEFAULT_FROM = "USD";
|
const DEFAULT_FROM = "USD";
|
||||||
@@ -210,6 +212,7 @@ export function ConverterCard({
|
|||||||
: "--";
|
: "--";
|
||||||
|
|
||||||
const amountError = inputValidation.ok ? null : inputValidation.error;
|
const amountError = inputValidation.ok ? null : inputValidation.error;
|
||||||
|
const amountPrefix = getDisplaySymbol(fromAsset);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="relative overflow-hidden border-border/70 bg-card/90">
|
<Card className="relative overflow-hidden border-border/70 bg-card/90">
|
||||||
@@ -220,27 +223,49 @@ export function ConverterCard({
|
|||||||
</CardTitle>
|
</CardTitle>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="border-border/70 bg-background/50"
|
className="max-w-full border-border/70 bg-background/50 text-[11px] sm:text-xs"
|
||||||
>
|
>
|
||||||
<span className="text-muted-foreground">Fiat rates by</span>
|
<span className="inline-flex items-center gap-1 whitespace-nowrap sm:hidden">
|
||||||
<a
|
<span className="text-muted-foreground">Data sources:</span>
|
||||||
href="https://frankfurter.dev/"
|
<a
|
||||||
target="_blank"
|
href="https://frankfurter.dev/"
|
||||||
rel="noreferrer"
|
target="_blank"
|
||||||
className="ml-1 text-foreground transition-colors hover:text-cyan-100"
|
rel="noreferrer"
|
||||||
>
|
className="text-foreground transition-colors hover:text-cyan-100"
|
||||||
{data.sources.fiat}
|
>
|
||||||
</a>
|
{data.sources.fiat}
|
||||||
<span className="mx-1 text-muted-foreground">•</span>
|
</a>
|
||||||
<span className="text-muted-foreground">Price data by</span>
|
<span className="text-muted-foreground">•</span>
|
||||||
<a
|
<a
|
||||||
href="https://www.coingecko.com/"
|
href="https://www.coingecko.com/"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="ml-1 text-foreground transition-colors hover:text-cyan-100"
|
className="text-foreground transition-colors hover:text-cyan-100"
|
||||||
>
|
>
|
||||||
{data.sources.crypto}
|
{data.sources.crypto}
|
||||||
</a>
|
</a>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="hidden items-center gap-1 whitespace-nowrap sm:inline-flex">
|
||||||
|
<span className="text-muted-foreground">Data sources:</span>
|
||||||
|
<a
|
||||||
|
href="https://frankfurter.dev/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-foreground transition-colors hover:text-cyan-100"
|
||||||
|
>
|
||||||
|
{data.sources.fiat}
|
||||||
|
</a>
|
||||||
|
<span className="text-muted-foreground">•</span>
|
||||||
|
<a
|
||||||
|
href="https://www.coingecko.com/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-foreground transition-colors hover:text-cyan-100"
|
||||||
|
>
|
||||||
|
{data.sources.crypto}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="pr-1 text-base/7 sm:text-sm">
|
<CardDescription className="pr-1 text-base/7 sm:text-sm">
|
||||||
@@ -265,17 +290,27 @@ export function ConverterCard({
|
|||||||
>
|
>
|
||||||
Amount
|
Amount
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<div className="relative">
|
||||||
id="amount"
|
{amountPrefix ? (
|
||||||
type="text"
|
<span className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-sm text-muted-foreground/80">
|
||||||
inputMode="decimal"
|
{amountPrefix}
|
||||||
value={amountInput}
|
</span>
|
||||||
onChange={(event) => setAmountInput(event.target.value)}
|
) : null}
|
||||||
placeholder="Enter amount"
|
<Input
|
||||||
className="h-14 rounded-xl bg-background/70 px-4 text-lg"
|
id="amount"
|
||||||
aria-invalid={Boolean(amountError)}
|
type="text"
|
||||||
aria-describedby={amountError ? "amount-error" : undefined}
|
inputMode="decimal"
|
||||||
/>
|
value={amountInput}
|
||||||
|
onChange={(event) => setAmountInput(event.target.value)}
|
||||||
|
placeholder="Enter amount"
|
||||||
|
className={cn(
|
||||||
|
"h-14 rounded-xl bg-background/70 px-4 text-lg",
|
||||||
|
amountPrefix ? "pl-10" : "",
|
||||||
|
)}
|
||||||
|
aria-invalid={Boolean(amountError)}
|
||||||
|
aria-describedby={amountError ? "amount-error" : undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{amountError ? (
|
{amountError ? (
|
||||||
<p id="amount-error" className="text-sm text-red-300">
|
<p id="amount-error" className="text-sm text-red-300">
|
||||||
{amountError}
|
{amountError}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useMemo, useState } from "react";
|
|||||||
import { Check, ChevronDown } from "lucide-react";
|
import { Check, ChevronDown } from "lucide-react";
|
||||||
|
|
||||||
import { POPULAR_CODES } from "@/lib/assets";
|
import { POPULAR_CODES } from "@/lib/assets";
|
||||||
|
import { getDisplaySymbol } from "@/lib/currency-display";
|
||||||
import { RateAsset } from "@/lib/rates";
|
import { RateAsset } from "@/lib/rates";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { CurrencyIcon } from "@/components/converter/currency-icon";
|
import { CurrencyIcon } from "@/components/converter/currency-icon";
|
||||||
@@ -34,6 +35,8 @@ function AssetLabel({ asset }: { asset?: RateAsset }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const displaySymbol = getDisplaySymbol(asset);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex min-w-0 items-center gap-2.5 text-left">
|
<span className="flex min-w-0 items-center gap-2.5 text-left">
|
||||||
<CurrencyIcon code={asset.code} type={asset.type} size="md" className="shrink-0" />
|
<CurrencyIcon code={asset.code} type={asset.type} size="md" className="shrink-0" />
|
||||||
@@ -43,7 +46,7 @@ function AssetLabel({ asset }: { asset?: RateAsset }) {
|
|||||||
</span>
|
</span>
|
||||||
<span className="truncate text-xs text-muted-foreground">
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
{asset.type === "fiat" ? "Fiat" : "Crypto"}
|
{asset.type === "fiat" ? "Fiat" : "Crypto"}
|
||||||
{asset.symbol ? ` | ${asset.symbol}` : ""}
|
{displaySymbol ? ` | ${displaySymbol}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -111,7 +114,50 @@ export function CurrencySelect({
|
|||||||
<CommandEmpty>No assets found.</CommandEmpty>
|
<CommandEmpty>No assets found.</CommandEmpty>
|
||||||
{popularAssets.length > 0 ? (
|
{popularAssets.length > 0 ? (
|
||||||
<CommandGroup heading="Popular">
|
<CommandGroup heading="Popular">
|
||||||
{popularAssets.map((asset) => (
|
{popularAssets.map((asset) => {
|
||||||
|
const displaySymbol = getDisplaySymbol(asset);
|
||||||
|
return (
|
||||||
|
<CommandItem
|
||||||
|
key={asset.code}
|
||||||
|
value={`${asset.code} ${asset.name} ${asset.type}`}
|
||||||
|
onSelect={() => {
|
||||||
|
onChange(asset.code);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center gap-2.5">
|
||||||
|
<CurrencyIcon
|
||||||
|
code={asset.code}
|
||||||
|
type={asset.type}
|
||||||
|
size="md"
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
<span className="flex min-w-0 flex-col">
|
||||||
|
<span className="truncate font-medium">
|
||||||
|
{asset.code} - {asset.name}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{asset.type === "fiat" ? "Fiat" : "Crypto"}
|
||||||
|
{displaySymbol ? ` | ${displaySymbol}` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"ml-2 h-4 w-4",
|
||||||
|
value === asset.code ? "opacity-100" : "opacity-0"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</CommandGroup>
|
||||||
|
) : null}
|
||||||
|
{allAssets.length > 0 ? <CommandSeparator /> : null}
|
||||||
|
<CommandGroup heading="All assets">
|
||||||
|
{allAssets.map((asset) => {
|
||||||
|
const displaySymbol = getDisplaySymbol(asset);
|
||||||
|
return (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={asset.code}
|
key={asset.code}
|
||||||
value={`${asset.code} ${asset.name} ${asset.type}`}
|
value={`${asset.code} ${asset.name} ${asset.type}`}
|
||||||
@@ -133,7 +179,7 @@ export function CurrencySelect({
|
|||||||
</span>
|
</span>
|
||||||
<span className="truncate text-xs text-muted-foreground">
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
{asset.type === "fiat" ? "Fiat" : "Crypto"}
|
{asset.type === "fiat" ? "Fiat" : "Crypto"}
|
||||||
{asset.symbol ? ` | ${asset.symbol}` : ""}
|
{displaySymbol ? ` | ${displaySymbol}` : ""}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,45 +190,8 @@ export function CurrencySelect({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
);
|
||||||
</CommandGroup>
|
})}
|
||||||
) : null}
|
|
||||||
{allAssets.length > 0 ? <CommandSeparator /> : null}
|
|
||||||
<CommandGroup heading="All assets">
|
|
||||||
{allAssets.map((asset) => (
|
|
||||||
<CommandItem
|
|
||||||
key={asset.code}
|
|
||||||
value={`${asset.code} ${asset.name} ${asset.type}`}
|
|
||||||
onSelect={() => {
|
|
||||||
onChange(asset.code);
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 flex-1 items-center gap-2.5">
|
|
||||||
<CurrencyIcon
|
|
||||||
code={asset.code}
|
|
||||||
type={asset.type}
|
|
||||||
size="md"
|
|
||||||
className="shrink-0"
|
|
||||||
/>
|
|
||||||
<span className="flex min-w-0 flex-col">
|
|
||||||
<span className="truncate font-medium">
|
|
||||||
{asset.code} - {asset.name}
|
|
||||||
</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">
|
|
||||||
{asset.type === "fiat" ? "Fiat" : "Crypto"}
|
|
||||||
{asset.symbol ? ` | ${asset.symbol}` : ""}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"ml-2 h-4 w-4",
|
|
||||||
value === asset.code ? "opacity-100" : "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
|
|||||||
38
lib/currency-display.ts
Normal file
38
lib/currency-display.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { AssetType } from "@/lib/assets";
|
||||||
|
|
||||||
|
interface DisplayAsset {
|
||||||
|
code: string;
|
||||||
|
type: AssetType;
|
||||||
|
symbol?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FIAT_DISPLAY_SYMBOLS: Record<string, string> = {
|
||||||
|
USD: "$",
|
||||||
|
EUR: "€",
|
||||||
|
GBP: "£",
|
||||||
|
JPY: "¥",
|
||||||
|
PLN: "zł",
|
||||||
|
CHF: "CHF",
|
||||||
|
CAD: "C$",
|
||||||
|
AUD: "A$",
|
||||||
|
NZD: "NZ$",
|
||||||
|
CNY: "¥",
|
||||||
|
INR: "₹",
|
||||||
|
KRW: "₩",
|
||||||
|
TRY: "₺",
|
||||||
|
BRL: "R$",
|
||||||
|
MXN: "MX$",
|
||||||
|
THB: "฿"
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getDisplaySymbol(asset?: DisplayAsset): string | undefined {
|
||||||
|
if (!asset) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asset.type === "fiat") {
|
||||||
|
return FIAT_DISPLAY_SYMBOLS[asset.code] ?? asset.symbol ?? asset.code;
|
||||||
|
}
|
||||||
|
|
||||||
|
return asset.symbol ?? asset.code;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user