From 24f21d25bc5b253d2f601dac0848e811e41486ee Mon Sep 17 00:00:00 2001 From: zvspany Date: Mon, 30 Mar 2026 19:26:18 +0200 Subject: [PATCH] refactor(converter): split converter card into modular section components --- .../converter/converter-card-amount-input.tsx | 82 ++ .../converter-card-converted-value.tsx | 142 +++ .../converter/converter-card-header.tsx | 71 ++ .../converter/converter-card-market-data.tsx | 150 +++ .../converter-card-multi-conversion.tsx | 107 +++ .../converter-card-pair-selection.tsx | 76 ++ .../converter/converter-card-pinned-pairs.tsx | 175 ++++ .../converter-card-rates-summary.tsx | 45 + .../converter/converter-card-states.tsx | 74 ++ .../converter/converter-card.constants.ts | 22 + components/converter/converter-card.tsx | 886 +++--------------- 11 files changed, 1075 insertions(+), 755 deletions(-) create mode 100644 components/converter/converter-card-amount-input.tsx create mode 100644 components/converter/converter-card-converted-value.tsx create mode 100644 components/converter/converter-card-header.tsx create mode 100644 components/converter/converter-card-market-data.tsx create mode 100644 components/converter/converter-card-multi-conversion.tsx create mode 100644 components/converter/converter-card-pair-selection.tsx create mode 100644 components/converter/converter-card-pinned-pairs.tsx create mode 100644 components/converter/converter-card-rates-summary.tsx create mode 100644 components/converter/converter-card-states.tsx create mode 100644 components/converter/converter-card.constants.ts 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 ( + +
+ + Currency Converter + + + + Data sources: + + {fiatSource} + + + + {cryptoSource} + + + + + Data sources: + + {fiatSource} + + + + {cryptoSource} + + + +
+ + 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 ( - -
- - Currency Converter - - - - Data sources: - - {data.sources.fiat} - - - - {data.sources.crypto} - - + - - Data sources: - - {data.sources.fiat} - - - - {data.sources.crypto} - - - -
- - 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} +