diff --git a/components/converter/converter-card-amount-input.tsx b/components/converter/converter-card-amount-input.tsx
new file mode 100644
index 0000000..735e8b9
--- /dev/null
+++ b/components/converter/converter-card-amount-input.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+interface ConverterCardAmountInputProps {
+ amountInput: string;
+ amountPrefix?: string;
+ amountInputPaddingLeft?: string;
+ amountError: string | null;
+ quickAmounts: readonly number[];
+ activeQuickAmount: number | null;
+ onAmountChange: (nextValue: string) => void;
+ onQuickAmountSelect: (value: number) => void;
+}
+
+export function ConverterCardAmountInput({
+ amountInput,
+ amountPrefix,
+ amountInputPaddingLeft,
+ amountError,
+ quickAmounts,
+ activeQuickAmount,
+ onAmountChange,
+ onQuickAmountSelect,
+}: ConverterCardAmountInputProps) {
+ return (
+
+
+
+ {amountPrefix ? (
+
+ {amountPrefix}
+
+ ) : null}
+ onAmountChange(event.target.value)}
+ placeholder="Enter amount"
+ className="h-14 rounded-xl bg-background/70 px-4 text-lg"
+ style={amountInputPaddingLeft ? { paddingLeft: amountInputPaddingLeft } : undefined}
+ aria-invalid={Boolean(amountError)}
+ aria-describedby={amountError ? "amount-error" : undefined}
+ />
+
+
+ {quickAmounts.map((quickAmount) => (
+
+ ))}
+
+ {amountError ? (
+
+ {amountError}
+
+ ) : null}
+
+ );
+}
diff --git a/components/converter/converter-card-converted-value.tsx b/components/converter/converter-card-converted-value.tsx
new file mode 100644
index 0000000..b7db769
--- /dev/null
+++ b/components/converter/converter-card-converted-value.tsx
@@ -0,0 +1,142 @@
+"use client";
+
+import { AnimatePresence, motion } from "framer-motion";
+import { Check, Copy, Link2 } from "lucide-react";
+
+import { CurrencyIcon } from "@/components/converter/currency-icon";
+import { Button } from "@/components/ui/button";
+import type { RateAsset } from "@/lib/rates";
+
+interface ConverterCardConvertedValueProps {
+ shouldReduceMotion: boolean;
+ fromAsset?: RateAsset;
+ toAsset?: RateAsset;
+ pairTransitionKey: string;
+ convertedDisplay: string;
+ forAmountDisplay: string;
+ isShareLinkCopied: boolean;
+ isCopied: boolean;
+ canCopyConvertedValue: boolean;
+ onCopyShareLink: () => void;
+ onCopyConvertedValue: () => void;
+}
+
+export function ConverterCardConvertedValue({
+ shouldReduceMotion,
+ fromAsset,
+ toAsset,
+ pairTransitionKey,
+ convertedDisplay,
+ forAmountDisplay,
+ isShareLinkCopied,
+ isCopied,
+ canCopyConvertedValue,
+ onCopyShareLink,
+ onCopyConvertedValue,
+}: ConverterCardConvertedValueProps) {
+ return (
+
+
+
+ Converted value
+
+
+
+
+
+
+ {fromAsset && toAsset ? (
+
+
+
+
+ {fromAsset.code}
+
+ to
+
+
+ {toAsset.code}
+
+
+
+ ) : null}
+
+
+
+ {convertedDisplay}
+
+
+
+ {fromAsset ? (
+
+ for {forAmountDisplay} {fromAsset.code}
+
+ ) : null}
+
+ );
+}
diff --git a/components/converter/converter-card-header.tsx b/components/converter/converter-card-header.tsx
new file mode 100644
index 0000000..4d35b2c
--- /dev/null
+++ b/components/converter/converter-card-header.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { Badge } from "@/components/ui/badge";
+import { CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+
+export function ConverterCardHeader({
+ fiatSource,
+ cryptoSource,
+}: {
+ fiatSource: string;
+ cryptoSource: string;
+}) {
+ return (
+
+
+
+ Convert fiat currencies and cryptocurrencies using live exchange rates.
+
+
+ );
+}
diff --git a/components/converter/converter-card-market-data.tsx b/components/converter/converter-card-market-data.tsx
new file mode 100644
index 0000000..f3af674
--- /dev/null
+++ b/components/converter/converter-card-market-data.tsx
@@ -0,0 +1,150 @@
+"use client";
+
+import { BarChart3 } from "lucide-react";
+
+import { CurrencyIcon } from "@/components/converter/currency-icon";
+import { PriceSparkline } from "@/components/converter/price-sparkline";
+import { Button } from "@/components/ui/button";
+import { type CryptoMarketResponse, MARKET_CHART_RANGES, type MarketChartRange } from "@/lib/market";
+import { formatSignedPercent, formatTimestamp, formatUsdCompact, formatUsdPrice } from "@/lib/format";
+import type { RateAsset } from "@/lib/rates";
+import { cn } from "@/lib/utils";
+
+interface ConverterCardMarketDataProps {
+ marketAsset: RateAsset | null;
+ marketData: CryptoMarketResponse | null;
+ marketError: string | null;
+ isMarketLoading: boolean;
+ marketRange: MarketChartRange;
+ marketRangeLabels: Record;
+ marketRangeChangePct: number | null;
+ onMarketRangeChange: (range: MarketChartRange) => void;
+}
+
+export function ConverterCardMarketData({
+ marketAsset,
+ marketData,
+ marketError,
+ isMarketLoading,
+ marketRange,
+ marketRangeLabels,
+ marketRangeChangePct,
+ onMarketRangeChange,
+}: ConverterCardMarketDataProps) {
+ if (!marketAsset) {
+ return null;
+ }
+
+ return (
+
+
+
+
+ Market data
+
+
+
+ {marketAsset.code}
+
+
+
+
+ {MARKET_CHART_RANGES.map((range) => {
+ const isActive = marketRange === range;
+
+ return (
+
+ );
+ })}
+
+
+ {marketError && !marketData ? (
+
+ Unable to load market data right now.
+
+ ) : null}
+
+
+
+
+
+
+ Price
+
+
+ {marketData ? formatUsdPrice(marketData.priceUsd) : "-"}
+
+
+
+
+ {marketRangeLabels[marketRange]}
+
+
0
+ ? "text-emerald-300"
+ : marketRangeChangePct < 0
+ ? "text-red-300"
+ : "text-foreground"
+ : "text-foreground",
+ )}
+ >
+ {marketData ? formatSignedPercent(marketRangeChangePct) : "-"}
+
+
+
+
+ Market cap
+
+
+ {marketData ? formatUsdCompact(marketData.marketCapUsd) : "-"}
+
+
+
+
+ Volume (24h)
+
+
+ {marketData ? formatUsdCompact(marketData.volume24hUsd) : "-"}
+
+
+
+
+
+
+ {marketData
+ ? `Updated ${formatTimestamp(marketData.updatedAt)}`
+ : isMarketLoading
+ ? "Updating market data..."
+ : ""}
+
+ {marketData ? Source: {marketData.source} : null}
+
+
+ );
+}
diff --git a/components/converter/converter-card-multi-conversion.tsx b/components/converter/converter-card-multi-conversion.tsx
new file mode 100644
index 0000000..7eb171c
--- /dev/null
+++ b/components/converter/converter-card-multi-conversion.tsx
@@ -0,0 +1,107 @@
+"use client";
+
+import { AnimatePresence, motion } from "framer-motion";
+import { Loader2 } from "lucide-react";
+
+import { CurrencyIcon } from "@/components/converter/currency-icon";
+import { formatAmount } from "@/lib/format";
+import type { RateAsset } from "@/lib/rates";
+
+type AmountValidationResult =
+ | { ok: true; value: number }
+ | { ok: false; error: string };
+
+interface MultiConversionItem {
+ asset: RateAsset;
+ value: number;
+}
+
+interface ConverterCardMultiConversionProps {
+ shouldReduceMotion: boolean;
+ debouncedValidation: AmountValidationResult;
+ fromAsset?: RateAsset;
+ multiConversions: MultiConversionItem[];
+ isRatesLoading: boolean;
+}
+
+export function ConverterCardMultiConversion({
+ shouldReduceMotion,
+ debouncedValidation,
+ fromAsset,
+ multiConversions,
+ isRatesLoading,
+}: ConverterCardMultiConversionProps) {
+ return (
+
+
+
+ Multi conversion
+
+ {fromAsset && debouncedValidation.ok ? (
+
+ for {formatAmount(debouncedValidation.value, fromAsset)} {fromAsset.code}
+
+ ) : null}
+
+
+ {!debouncedValidation.ok ? (
+
{debouncedValidation.error}
+ ) : null}
+
+ {debouncedValidation.ok && multiConversions.length === 0 ? (
+
+ No additional assets available for multi conversion.
+
+ ) : null}
+
+ {debouncedValidation.ok && multiConversions.length > 0 ? (
+
+ {multiConversions.map(({ asset, value }) => (
+
+
+
+ {asset.code}
+
+
+
+
+ {formatAmount(value, asset)} {asset.code}
+
+
+
+
+ ))}
+
+ ) : null}
+
+ {isRatesLoading ? (
+
+
+ Updating multi conversion...
+
+ ) : null}
+
+ );
+}
diff --git a/components/converter/converter-card-pair-selection.tsx b/components/converter/converter-card-pair-selection.tsx
new file mode 100644
index 0000000..22a1d1a
--- /dev/null
+++ b/components/converter/converter-card-pair-selection.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { ArrowUpDown } from "lucide-react";
+
+import { CurrencySelect } from "@/components/converter/currency-select";
+import { Button } from "@/components/ui/button";
+import type { RateAsset } from "@/lib/rates";
+
+interface ConverterCardPairSelectionProps {
+ assets: RateAsset[];
+ fromCode: string;
+ toCode: string;
+ shouldReduceMotion: boolean;
+ swapAnimationStep: number;
+ onFromChange: (code: string) => void;
+ onToChange: (code: string) => void;
+ onSwap: () => void;
+}
+
+export function ConverterCardPairSelection({
+ assets,
+ fromCode,
+ toCode,
+ shouldReduceMotion,
+ swapAnimationStep,
+ onFromChange,
+ onToChange,
+ onSwap,
+}: ConverterCardPairSelectionProps) {
+ return (
+
+ );
+}
diff --git a/components/converter/converter-card-pinned-pairs.tsx b/components/converter/converter-card-pinned-pairs.tsx
new file mode 100644
index 0000000..5cdbe7f
--- /dev/null
+++ b/components/converter/converter-card-pinned-pairs.tsx
@@ -0,0 +1,175 @@
+"use client";
+
+import { motion } from "framer-motion";
+import { MoveRight, Star, X } from "lucide-react";
+
+import { CurrencyIcon } from "@/components/converter/currency-icon";
+import { Button } from "@/components/ui/button";
+import { type PinnedPair } from "@/hooks/use-pinned-pairs";
+import type { RateAsset } from "@/lib/rates";
+import { cn } from "@/lib/utils";
+
+interface PinnedPairItem {
+ pair: PinnedPair;
+ fromAsset: RateAsset;
+ toAsset: RateAsset;
+}
+
+interface ConverterCardPinnedPairsProps {
+ shouldReduceMotion: boolean;
+ isCurrentPairPinned: boolean;
+ hasReachedPinnedPairsLimit: boolean;
+ pinStarAnimationStep: number;
+ pinnedPairItems: PinnedPairItem[];
+ fromCode: string;
+ toCode: string;
+ isPinnedPairsReady: boolean;
+ maxPinnedPairs: number;
+ onTogglePinnedCurrentPair: () => void;
+ onSelectPinnedPair: (pair: PinnedPair) => void;
+ onRemovePinnedPair: (pair: PinnedPair) => void;
+}
+
+export function ConverterCardPinnedPairs({
+ shouldReduceMotion,
+ isCurrentPairPinned,
+ hasReachedPinnedPairsLimit,
+ pinStarAnimationStep,
+ pinnedPairItems,
+ fromCode,
+ toCode,
+ isPinnedPairsReady,
+ maxPinnedPairs,
+ onTogglePinnedCurrentPair,
+ onSelectPinnedPair,
+ onRemovePinnedPair,
+}: ConverterCardPinnedPairsProps) {
+ return (
+
+
+
+ Pinned pairs
+
+
+
+
+ {pinnedPairItems.length > 0 ? (
+
+ {pinnedPairItems.map(({ pair, fromAsset, toAsset }) => {
+ const isActive = pair.fromCode === fromCode && pair.toCode === toCode;
+
+ return (
+
+
+
+
+ );
+ })}
+
+ ) : null}
+
+ {isPinnedPairsReady && pinnedPairItems.length === 0 ? (
+
+ Pin your most-used pairs for one-click access.
+
+ ) : null}
+
+ {hasReachedPinnedPairsLimit ? (
+
+ Pinned pairs limit reached ({maxPinnedPairs}). Remove one to add a new
+ pair.
+
+ ) : null}
+
+ );
+}
diff --git a/components/converter/converter-card-rates-summary.tsx b/components/converter/converter-card-rates-summary.tsx
new file mode 100644
index 0000000..f1fe45f
--- /dev/null
+++ b/components/converter/converter-card-rates-summary.tsx
@@ -0,0 +1,45 @@
+"use client";
+
+import { formatTimestamp } from "@/lib/format";
+import type { RateAsset } from "@/lib/rates";
+
+interface ConverterCardRatesSummaryProps {
+ fromAsset?: RateAsset;
+ toAsset?: RateAsset;
+ currentRate: string | null;
+ inverseRate: string | null;
+ updatedAt: string;
+}
+
+export function ConverterCardRatesSummary({
+ fromAsset,
+ toAsset,
+ currentRate,
+ inverseRate,
+ updatedAt,
+}: ConverterCardRatesSummaryProps) {
+ return (
+
+
+
Current rate
+
+ {fromAsset && toAsset && currentRate
+ ? `1 ${fromAsset.code} = ${currentRate} ${toAsset.code}`
+ : "-"}
+
+
+
+
Inverse rate
+
+ {fromAsset && toAsset && inverseRate
+ ? `1 ${toAsset.code} = ${inverseRate} ${fromAsset.code}`
+ : "-"}
+
+
+
+
Last updated
+
{formatTimestamp(updatedAt)}
+
+
+ );
+}
diff --git a/components/converter/converter-card-states.tsx b/components/converter/converter-card-states.tsx
new file mode 100644
index 0000000..25d0970
--- /dev/null
+++ b/components/converter/converter-card-states.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import { AlertTriangle, RefreshCcw } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export function ConverterSkeleton() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function ConverterErrorState({
+ message,
+ onRetry,
+}: {
+ message: string;
+ onRetry: () => void;
+}) {
+ return (
+
+
+
+
+
+
+ Unable to load market rates
+
+
{message}
+
+
+
+
+
+ );
+}
+
+export function ConverterEmptyState() {
+ return (
+
+
+
+ No assets are currently available. Please try again shortly.
+
+
+
+ );
+}
diff --git a/components/converter/converter-card.constants.ts b/components/converter/converter-card.constants.ts
new file mode 100644
index 0000000..c4d3686
--- /dev/null
+++ b/components/converter/converter-card.constants.ts
@@ -0,0 +1,22 @@
+import type { MarketChartRange } from "@/lib/market";
+
+export const DEFAULT_FROM = "USD";
+export const DEFAULT_TO = "BTC";
+export const QUICK_AMOUNTS = [10, 50, 100, 500, 1000] as const;
+export const DEFAULT_MULTI_CONVERSION_CODES = [
+ "USD",
+ "EUR",
+ "BTC",
+ "ETH",
+ "SOL",
+] as const;
+export const MAX_MULTI_CONVERSIONS = 4;
+export const MAX_PINNED_PAIRS = 6;
+
+export const MARKET_RANGE_LABELS: Record = {
+ "24h": "24h",
+ "7d": "7d",
+ "30d": "30d",
+ "1y": "1y",
+ all: "all",
+};
diff --git a/components/converter/converter-card.tsx b/components/converter/converter-card.tsx
index ea0639c..23f0321 100644
--- a/components/converter/converter-card.tsx
+++ b/components/converter/converter-card.tsx
@@ -1,73 +1,45 @@
"use client";
import { useEffect, useMemo, useState } from "react";
-import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
-import {
- AlertTriangle,
- ArrowUpDown,
- BarChart3,
- Check,
- Copy,
- Link2,
- Loader2,
- MoveRight,
- RefreshCcw,
- Star,
- X,
-} from "lucide-react";
+import { useReducedMotion } from "framer-motion";
+import { AlertTriangle, Loader2, RefreshCcw } from "lucide-react";
-import { CurrencyIcon } from "@/components/converter/currency-icon";
-import { PriceSparkline } from "@/components/converter/price-sparkline";
-import { CurrencySelect } from "@/components/converter/currency-select";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
+import { ConverterCardAmountInput } from "@/components/converter/converter-card-amount-input";
+import { ConverterCardConvertedValue } from "@/components/converter/converter-card-converted-value";
+import { ConverterCardHeader } from "@/components/converter/converter-card-header";
import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
+ DEFAULT_FROM,
+ DEFAULT_MULTI_CONVERSION_CODES,
+ DEFAULT_TO,
+ MARKET_RANGE_LABELS,
+ MAX_MULTI_CONVERSIONS,
+ MAX_PINNED_PAIRS,
+ QUICK_AMOUNTS,
+} from "@/components/converter/converter-card.constants";
+import { ConverterCardMarketData } from "@/components/converter/converter-card-market-data";
+import { ConverterCardMultiConversion } from "@/components/converter/converter-card-multi-conversion";
+import { ConverterCardPairSelection } from "@/components/converter/converter-card-pair-selection";
+import { ConverterCardPinnedPairs } from "@/components/converter/converter-card-pinned-pairs";
+import { ConverterCardRatesSummary } from "@/components/converter/converter-card-rates-summary";
+import {
+ ConverterEmptyState,
+ ConverterErrorState,
+ ConverterSkeleton,
+} from "@/components/converter/converter-card-states";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
-import { Skeleton } from "@/components/ui/skeleton";
import { useCryptoMarket } from "@/hooks/use-crypto-market";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
-import { usePinnedPairs } from "@/hooks/use-pinned-pairs";
-import { getDisplaySymbol } from "@/lib/currency-display";
-import {
- MARKET_CHART_RANGES,
- type MarketChartRange,
-} from "@/lib/market";
+import { type PinnedPair, usePinnedPairs } from "@/hooks/use-pinned-pairs";
import { useMarketRates } from "@/hooks/use-market-rates";
-import {
- formatAmount,
- formatInverseRate,
- formatRate,
- formatSignedPercent,
- formatTimestamp,
- formatUsdCompact,
- formatUsdPrice,
-} from "@/lib/format";
+import { getDisplaySymbol } from "@/lib/currency-display";
+import { formatAmount, formatInverseRate, formatRate } from "@/lib/format";
+import { type MarketChartRange } from "@/lib/market";
import { buildRateMap, convertAmount } from "@/lib/rates";
import { buildConversionShareUrl } from "@/lib/share-link";
-import { cn } from "@/lib/utils";
import { validateAmount } from "@/lib/validation";
-const DEFAULT_FROM = "USD";
-const DEFAULT_TO = "BTC";
-const QUICK_AMOUNTS = [10, 50, 100, 500, 1000] as const;
-const DEFAULT_MULTI_CONVERSION_CODES = ["USD", "EUR", "BTC", "ETH", "SOL"] as const;
-const MAX_MULTI_CONVERSIONS = 4;
-const MAX_PINNED_PAIRS = 6;
-const MARKET_RANGE_LABELS: Record = {
- "24h": "24h",
- "7d": "7d",
- "30d": "30d",
- "1y": "1y",
- all: "all",
-};
-
interface ConverterCardProps {
forcedFromCode?: string;
forcedToCode?: string;
@@ -108,73 +80,6 @@ async function copyTextToClipboard(text: string): Promise {
}
}
-function ConverterSkeleton() {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-function ErrorState({
- message,
- onRetry,
-}: {
- message: string;
- onRetry: () => void;
-}) {
- return (
-
-
-
-
-
-
- Unable to load market rates
-
-
{message}
-
-
-
-
-
- );
-}
-
-function EmptyState() {
- return (
-
-
-
- No assets are currently available. Please try again shortly.
-
-
-
- );
-}
-
export function ConverterCard({
forcedFromCode,
forcedToCode,
@@ -183,6 +88,7 @@ export function ConverterCard({
multiConversionCodes,
}: ConverterCardProps) {
const shouldReduceMotion = useReducedMotion();
+ const prefersReducedMotion = shouldReduceMotion ?? false;
const { data, error, isLoading, refresh } = useMarketRates();
const [amountInput, setAmountInput] = useState("1");
@@ -193,6 +99,7 @@ export function ConverterCard({
const [marketRange, setMarketRange] = useState("24h");
const [swapAnimationStep, setSwapAnimationStep] = useState(0);
const [pinStarAnimationStep, setPinStarAnimationStep] = useState(0);
+
const {
pinnedPairs,
isReady: isPinnedPairsReady,
@@ -219,8 +126,7 @@ export function ConverterCard({
if (!rateMap.has(toCode)) {
const fallback = rateMap.has(DEFAULT_TO)
? DEFAULT_TO
- : (assets.find((asset) => asset.code !== fromCode)?.code ??
- assets[0].code);
+ : (assets.find((asset) => asset.code !== fromCode)?.code ?? assets[0].code);
setToCode(fallback);
}
@@ -428,17 +334,25 @@ export function ConverterCard({
}, [marketData, marketRange]);
const amountPrefix = getDisplaySymbol(fromAsset);
- const currentPair = useMemo(
- () => ({ fromCode, toCode }),
- [fromCode, toCode],
- );
+ const amountInputPaddingLeft = useMemo(() => {
+ if (!amountPrefix) {
+ return undefined;
+ }
+
+ const prefixWidthCh = Math.max(amountPrefix.length, 1);
+ return `calc(1rem + ${prefixWidthCh}ch + 0.85rem)`;
+ }, [amountPrefix]);
+
+ const currentPair = useMemo(() => ({ fromCode, toCode }), [fromCode, toCode]);
const isCurrentPairPinned = isPinnedPair(currentPair);
const hasReachedPinnedPairsLimit =
isPinnedPairsLimitReached && !isCurrentPairPinned;
+
const handleTogglePinnedCurrentPair = () => {
setPinStarAnimationStep((current) => current + 1);
togglePinnedPair(currentPair);
};
+
const pinnedPairItems = useMemo(
() =>
pinnedPairs
@@ -460,32 +374,24 @@ export function ConverterCard({
(
item,
): item is {
- pair: { fromCode: string; toCode: string };
+ pair: PinnedPair;
fromAsset: (typeof assets)[number];
toAsset: (typeof assets)[number];
} => Boolean(item),
),
[pinnedPairs, rateMap],
);
- const amountInputPaddingLeft = useMemo(() => {
- if (!amountPrefix) {
- return undefined;
- }
-
- const prefixWidthCh = Math.max(amountPrefix.length, 1);
- return `calc(1rem + ${prefixWidthCh}ch + 0.85rem)`;
- }, [amountPrefix]);
if (isLoading && !data) {
return ;
}
if (!data && error) {
- return void refresh()} />;
+ return void refresh()} />;
}
if (!data || assets.length === 0) {
- return ;
+ return ;
}
const convertedDisplay =
@@ -494,641 +400,111 @@ export function ConverterCard({
: "--";
const amountError = inputValidation.ok ? null : inputValidation.error;
+ const activeQuickAmount = inputValidation.ok ? inputValidation.value : null;
+ const forAmountDisplay =
+ fromAsset && inputValidation.ok
+ ? formatAmount(inputValidation.value, fromAsset)
+ : "-";
const pairTransitionKey =
fromAsset && toAsset ? `${fromAsset.code}-${toAsset.code}` : "empty";
return (
-
-
-
- Convert fiat currencies and cryptocurrencies using live exchange
- rates.
-
-
{error ? (
-
- Using last successful data. Latest refresh failed: {error}
-
+
Using last successful data. Latest refresh failed: {error}
) : null}
-
-
-
- {amountPrefix ? (
-
- {amountPrefix}
-
- ) : null}
- setAmountInput(event.target.value)}
- placeholder="Enter amount"
- className="h-14 rounded-xl bg-background/70 px-4 text-lg"
- style={amountInputPaddingLeft ? { paddingLeft: amountInputPaddingLeft } : undefined}
- aria-invalid={Boolean(amountError)}
- aria-describedby={amountError ? "amount-error" : undefined}
- />
-
-
- {QUICK_AMOUNTS.map((quickAmount) => {
- const isActive =
- inputValidation.ok && inputValidation.value === quickAmount;
+ setAmountInput(String(value))}
+ />
- return (
-
- );
- })}
-
- {amountError ? (
-
- {amountError}
-
- ) : null}
-
+
-
+ {
+ setFromCode(pair.fromCode);
+ setToCode(pair.toCode);
+ }}
+ onRemovePinnedPair={removePinnedPair}
+ />
-
-
-
- Pinned pairs
-
-
-
+
void handleCopyShareLink()}
+ onCopyConvertedValue={() => void handleCopyConvertedValue()}
+ />
- {pinnedPairItems.length > 0 ? (
-
- {pinnedPairItems.map(({ pair, fromAsset, toAsset }) => {
- const isActive =
- pair.fromCode === fromCode && pair.toCode === toCode;
-
- return (
-
-
-
-
- );
- })}
-
- ) : null}
-
- {isPinnedPairsReady && pinnedPairItems.length === 0 ? (
-
- Pin your most-used pairs for one-click access.
-
- ) : null}
-
- {hasReachedPinnedPairsLimit ? (
-
- Pinned pairs limit reached ({MAX_PINNED_PAIRS}). Remove one to add a new pair.
-
- ) : null}
-
-
-
-
-
- Converted value
-
-
-
-
-
-
- {fromAsset && toAsset ? (
-
-
-
-
- {fromAsset.code}
-
- to
-
-
- {toAsset.code}
-
-
-
- ) : null}
-
-
-
- {convertedDisplay}
-
-
-
- {fromAsset ? (
-
- for{" "}
- {inputValidation.ok
- ? formatAmount(inputValidation.value, fromAsset)
- : "-"}{" "}
- {fromAsset.code}
-
- ) : null}
-
-
-
-
-
- Multi conversion
-
- {fromAsset && debouncedValidation.ok ? (
-
- for {formatAmount(debouncedValidation.value, fromAsset)} {fromAsset.code}
-
- ) : null}
-
-
- {!debouncedValidation.ok ? (
-
{debouncedValidation.error}
- ) : null}
-
- {debouncedValidation.ok && multiConversions.length === 0 ? (
-
- No additional assets available for multi conversion.
-
- ) : null}
-
- {debouncedValidation.ok && multiConversions.length > 0 ? (
-
- {multiConversions.map(({ asset, value }) => (
-
-
-
- {asset.code}
-
-
-
-
- {formatAmount(value, asset)} {asset.code}
-
-
-
-
- ))}
-
- ) : null}
-
- {isLoading ? (
-
-
- Updating multi conversion...
-
- ) : null}
-
+
-
-
-
Current rate
-
- {fromAsset && toAsset && currentRate
- ? `1 ${fromAsset.code} = ${currentRate} ${toAsset.code}`
- : "-"}
-
-
-
-
Inverse rate
-
- {fromAsset && toAsset && inverseRate
- ? `1 ${toAsset.code} = ${inverseRate} ${fromAsset.code}`
- : "-"}
-
-
-
-
Last updated
-
- {formatTimestamp(displayUpdatedAt ?? data.updatedAt)}
-
-
-
+
- {marketAsset ? (
-
-
-
-
- Market data
-
-
-
- {marketAsset.code}
-
-
-
-
- {MARKET_CHART_RANGES.map((range) => {
- const isActive = marketRange === range;
-
- return (
-
- );
- })}
-
-
- {marketError && !marketData ? (
-
- Unable to load market data right now.
-
- ) : null}
-
-
-
-
-
-
- Price
-
-
- {marketData ? formatUsdPrice(marketData.priceUsd) : "-"}
-
-
-
-
- {MARKET_RANGE_LABELS[marketRange]}
-
-
0
- ? "text-emerald-300"
- : marketRangeChangePct < 0
- ? "text-red-300"
- : "text-foreground"
- : "text-foreground",
- )}
- >
- {marketData ? formatSignedPercent(marketRangeChangePct) : "-"}
-
-
-
-
- Market cap
-
-
- {marketData ? formatUsdCompact(marketData.marketCapUsd) : "-"}
-
-
-
-
- Volume (24h)
-
-
- {marketData ? formatUsdCompact(marketData.volume24hUsd) : "-"}
-
-
-
-
-
-
- {marketData
- ? `Updated ${formatTimestamp(marketData.updatedAt)}`
- : isMarketLoading
- ? "Updating market data..."
- : ""}
-
- {marketData ? Source: {marketData.source} : null}
-
-
- ) : null}
+