Files
nexcurrency/components/converter/converter-card.tsx

535 lines
16 KiB
TypeScript

"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<boolean> {
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<MarketChartRange>("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<typeof asset> => 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 <ConverterSkeleton />;
}
if (!data && error) {
return <ConverterErrorState message={error} onRetry={() => void refresh()} />;
}
if (!data || assets.length === 0) {
return <ConverterEmptyState />;
}
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 (
<Card className="relative overflow-hidden border-border/70 bg-card/90">
<ConverterCardHeader
fiatSource={data.sources.fiat}
cryptoSource={data.sources.crypto}
/>
<CardContent className="relative z-10 space-y-5 pt-4 sm:pt-5">
{error ? (
<div className="flex items-center gap-2 rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-200">
<AlertTriangle className="h-4 w-4" />
<span>Using last successful data. Latest refresh failed: {error}</span>
</div>
) : null}
<ConverterCardAmountInput
amountInput={amountInput}
amountPrefix={amountPrefix}
amountInputPaddingLeft={amountInputPaddingLeft}
amountError={amountError}
quickAmounts={QUICK_AMOUNTS}
activeQuickAmount={activeQuickAmount}
onAmountChange={setAmountInput}
onQuickAmountSelect={(value) => setAmountInput(String(value))}
/>
<ConverterCardPairSelection
assets={assets}
fromCode={fromCode}
toCode={toCode}
shouldReduceMotion={prefersReducedMotion}
swapAnimationStep={swapAnimationStep}
onFromChange={setFromCode}
onToChange={setToCode}
onSwap={handleSwap}
/>
<ConverterCardPinnedPairs
shouldReduceMotion={prefersReducedMotion}
isCurrentPairPinned={isCurrentPairPinned}
hasReachedPinnedPairsLimit={hasReachedPinnedPairsLimit}
pinStarAnimationStep={pinStarAnimationStep}
pinnedPairItems={pinnedPairItems}
fromCode={fromCode}
toCode={toCode}
isPinnedPairsReady={isPinnedPairsReady}
maxPinnedPairs={MAX_PINNED_PAIRS}
onTogglePinnedCurrentPair={handleTogglePinnedCurrentPair}
onSelectPinnedPair={(pair) => {
setFromCode(pair.fromCode);
setToCode(pair.toCode);
}}
onRemovePinnedPair={removePinnedPair}
/>
<ConverterCardConvertedValue
shouldReduceMotion={prefersReducedMotion}
fromAsset={fromAsset}
toAsset={toAsset}
pairTransitionKey={pairTransitionKey}
convertedDisplay={convertedDisplay}
forAmountDisplay={forAmountDisplay}
isShareLinkCopied={isShareLinkCopied}
isCopied={isCopied}
canCopyConvertedValue={convertedValue !== null && Boolean(toAsset)}
onCopyShareLink={() => void handleCopyShareLink()}
onCopyConvertedValue={() => void handleCopyConvertedValue()}
/>
<ConverterCardMultiConversion
shouldReduceMotion={prefersReducedMotion}
debouncedValidation={debouncedValidation}
fromAsset={fromAsset}
multiConversions={multiConversions}
isRatesLoading={isLoading}
/>
<Separator className="bg-border/70" />
<ConverterCardRatesSummary
fromAsset={fromAsset}
toAsset={toAsset}
currentRate={currentRate}
inverseRate={inverseRate}
updatedAt={displayUpdatedAt ?? data.updatedAt}
/>
<ConverterCardMarketData
marketAsset={marketAsset}
marketData={marketData}
marketError={marketError}
isMarketLoading={isMarketLoading}
marketRange={marketRange}
marketRangeLabels={MARKET_RANGE_LABELS}
marketRangeChangePct={marketRangeChangePct}
onMarketRangeChange={setMarketRange}
/>
<div className="flex items-center justify-end">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
void refresh();
void refreshMarket();
}}
className="text-muted-foreground"
>
<RefreshCcw className="mr-2 h-4 w-4" />
Refresh rates
</Button>
</div>
{isLoading ? (
<div className="flex items-center justify-end gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Updating market rates...
</div>
) : null}
</CardContent>
</Card>
);
}