"use client"; import { useEffect, useMemo, useState } from "react"; import { useReducedMotion } from "framer-motion"; import { AlertTriangle, Loader2, RefreshCcw } from "lucide-react"; 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 { 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 { useCryptoMarket } from "@/hooks/use-crypto-market"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { type PinnedPair, usePinnedPairs } from "@/hooks/use-pinned-pairs"; import { useMarketRates } from "@/hooks/use-market-rates"; 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 { validateAmount } from "@/lib/validation"; interface ConverterCardProps { forcedFromCode?: string; forcedToCode?: string; forcedAmount?: string; onPairChange?: (fromCode: string, toCode: string) => void; multiConversionCodes?: string[]; } async function copyTextToClipboard(text: string): Promise { if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text); return true; } catch { // Fallback below. } } if (typeof document === "undefined") { return false; } const textArea = document.createElement("textarea"); textArea.value = text; textArea.setAttribute("readonly", ""); textArea.style.position = "fixed"; textArea.style.opacity = "0"; textArea.style.pointerEvents = "none"; textArea.style.left = "-9999px"; document.body.appendChild(textArea); textArea.select(); textArea.setSelectionRange(0, textArea.value.length); try { return document.execCommand("copy"); } finally { document.body.removeChild(textArea); } } export function ConverterCard({ forcedFromCode, forcedToCode, forcedAmount, onPairChange, multiConversionCodes, }: ConverterCardProps) { const shouldReduceMotion = useReducedMotion(); const prefersReducedMotion = shouldReduceMotion ?? false; const { data, error, isLoading, refresh } = useMarketRates(); const [amountInput, setAmountInput] = useState("1"); const [fromCode, setFromCode] = useState(DEFAULT_FROM); const [toCode, setToCode] = useState(DEFAULT_TO); const [isCopied, setIsCopied] = useState(false); const [isShareLinkCopied, setIsShareLinkCopied] = useState(false); const [marketRange, setMarketRange] = useState("24h"); const [swapAnimationStep, setSwapAnimationStep] = useState(0); const [pinStarAnimationStep, setPinStarAnimationStep] = useState(0); const { pinnedPairs, isReady: isPinnedPairsReady, isLimitReached: isPinnedPairsLimitReached, isPinnedPair, removePinnedPair, togglePinnedPair, } = usePinnedPairs(MAX_PINNED_PAIRS); const debouncedAmount = useDebouncedValue(amountInput, 120); const assets = useMemo(() => data?.assets ?? [], [data]); const rateMap = useMemo(() => buildRateMap(assets), [assets]); useEffect(() => { if (!assets.length) { return; } if (!rateMap.has(fromCode)) { setFromCode(rateMap.has(DEFAULT_FROM) ? DEFAULT_FROM : assets[0].code); } if (!rateMap.has(toCode)) { const fallback = rateMap.has(DEFAULT_TO) ? DEFAULT_TO : (assets.find((asset) => asset.code !== fromCode)?.code ?? assets[0].code); setToCode(fallback); } }, [assets, fromCode, toCode, rateMap]); useEffect(() => { if (!assets.length) { return; } if (forcedFromCode && rateMap.has(forcedFromCode)) { setFromCode(forcedFromCode); } if (forcedToCode && rateMap.has(forcedToCode)) { setToCode(forcedToCode); } }, [assets, forcedFromCode, forcedToCode, rateMap]); useEffect(() => { onPairChange?.(fromCode, toCode); }, [fromCode, toCode, onPairChange]); useEffect(() => { if (!forcedAmount) { return; } const parsed = validateAmount(forcedAmount); if (parsed.ok) { setAmountInput(forcedAmount); } }, [forcedAmount]); const inputValidation = validateAmount(amountInput); const debouncedValidation = validateAmount(debouncedAmount); const fromAsset = rateMap.get(fromCode); const toAsset = rateMap.get(toCode); const convertedValue = useMemo(() => { if (!fromAsset || !toAsset || !debouncedValidation.ok) { return null; } return convertAmount(debouncedValidation.value, fromAsset, toAsset); }, [fromAsset, toAsset, debouncedValidation]); const resolvedMultiConversionCodes = useMemo(() => { const configuredCodes = multiConversionCodes && multiConversionCodes.length > 0 ? multiConversionCodes : [...DEFAULT_MULTI_CONVERSION_CODES]; return Array.from( new Set( [toCode, ...configuredCodes] .map((code) => code.trim().toUpperCase()) .filter(Boolean), ), ).slice(0, MAX_MULTI_CONVERSIONS); }, [multiConversionCodes, toCode]); const multiConversions = useMemo(() => { if (!fromAsset || !debouncedValidation.ok) { return []; } return resolvedMultiConversionCodes .map((code) => rateMap.get(code)) .filter((asset): asset is NonNullable => Boolean(asset)) .map((asset) => ({ asset, value: convertAmount(debouncedValidation.value, fromAsset, asset), })); }, [fromAsset, debouncedValidation, resolvedMultiConversionCodes, rateMap]); const currentRate = useMemo(() => { if (!fromAsset || !toAsset) { return null; } return formatRate(fromAsset, toAsset); }, [fromAsset, toAsset]); const inverseRate = useMemo(() => { if (!fromAsset || !toAsset) { return null; } return formatInverseRate(fromAsset, toAsset); }, [fromAsset, toAsset]); const handleSwap = () => { setSwapAnimationStep((current) => current + 1); setFromCode(toCode); setToCode(fromCode); }; const marketAsset = useMemo(() => { if (toAsset?.type === "crypto") { return toAsset; } if (fromAsset?.type === "crypto") { return fromAsset; } return null; }, [fromAsset, toAsset]); const { data: marketData, error: marketError, isLoading: isMarketLoading, refresh: refreshMarket, } = useCryptoMarket(marketAsset?.code ?? null, marketRange); const handleCopyConvertedValue = async () => { if (convertedValue === null || !toAsset) { return; } const text = `${formatAmount(convertedValue, toAsset)} ${toAsset.code}`; try { await navigator.clipboard.writeText(text); setIsCopied(true); window.setTimeout(() => setIsCopied(false), 1400); } catch { setIsCopied(false); } }; const handleCopyShareLink = async () => { if (typeof window === "undefined") { return; } const link = buildConversionShareUrl({ origin: window.location.origin, pathname: window.location.pathname, amount: amountInput, fromCode, toCode, }); const copied = await copyTextToClipboard(link); if (!copied) { setIsShareLinkCopied(false); return; } setIsShareLinkCopied(true); window.setTimeout(() => setIsShareLinkCopied(false), 1400); }; const displayUpdatedAt = useMemo(() => { if (!data) { return null; } const timestamps = [new Date(data.updatedAt).getTime()]; if (marketData?.updatedAt) { timestamps.push(new Date(marketData.updatedAt).getTime()); } const latest = Math.max( ...timestamps.filter((timestamp) => Number.isFinite(timestamp)), ); return Number.isFinite(latest) ? new Date(latest).toISOString() : data.updatedAt; }, [data, marketData?.updatedAt]); const marketRangeChangePct = useMemo(() => { if (!marketData) { return null; } const points = marketData.priceHistory; if (points.length >= 2) { const first = points[0]?.priceUsd; const last = points[points.length - 1]?.priceUsd; if ( typeof first === "number" && typeof last === "number" && Number.isFinite(first) && Number.isFinite(last) && first > 0 ) { return ((last - first) / first) * 100; } } if (marketRange === "24h") { return marketData.change24hPct; } return null; }, [marketData, marketRange]); const amountPrefix = getDisplaySymbol(fromAsset); 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 .map((pair) => { const fromAssetEntry = rateMap.get(pair.fromCode); const toAssetEntry = rateMap.get(pair.toCode); if (!fromAssetEntry || !toAssetEntry || fromAssetEntry.code === toAssetEntry.code) { return null; } return { pair, fromAsset: fromAssetEntry, toAsset: toAssetEntry, }; }) .filter( ( item, ): item is { pair: PinnedPair; fromAsset: (typeof assets)[number]; toAsset: (typeof assets)[number]; } => Boolean(item), ), [pinnedPairs, rateMap], ); if (isLoading && !data) { return ; } if (!data && error) { return void refresh()} />; } if (!data || assets.length === 0) { return ; } const convertedDisplay = convertedValue !== null && toAsset ? `${formatAmount(convertedValue, toAsset)} ${toAsset.code}` : "--"; 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 ( {error ? (
Using last successful data. Latest refresh failed: {error}
) : null} setAmountInput(String(value))} /> { setFromCode(pair.fromCode); setToCode(pair.toCode); }} onRemovePinnedPair={removePinnedPair} /> void handleCopyShareLink()} onCopyConvertedValue={() => void handleCopyConvertedValue()} />
{isLoading ? (
Updating market rates...
) : null}
); }