From 86fe4b516cafcbcfa0e532e60b55fd5236b86f59 Mon Sep 17 00:00:00 2001 From: zvspany Date: Mon, 30 Mar 2026 17:22:36 +0200 Subject: [PATCH] feat(converter): implement pinned pairs functionality for quick access --- components/converter/converter-card.tsx | 183 ++++++++++++++++++++++ hooks/use-pinned-pairs.ts | 196 ++++++++++++++++++++++++ 2 files changed, 379 insertions(+) create mode 100644 hooks/use-pinned-pairs.ts diff --git a/components/converter/converter-card.tsx b/components/converter/converter-card.tsx index 909ccbc..46e55dd 100644 --- a/components/converter/converter-card.tsx +++ b/components/converter/converter-card.tsx @@ -9,7 +9,10 @@ import { Check, Copy, Loader2, + MoveRight, RefreshCcw, + Star, + X, } from "lucide-react"; import { CurrencyIcon } from "@/components/converter/currency-icon"; @@ -29,6 +32,7 @@ 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, @@ -53,6 +57,7 @@ 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", @@ -150,6 +155,15 @@ export function ConverterCard({ const [isCopied, setIsCopied] = 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); @@ -341,6 +355,45 @@ export function ConverterCard({ }, [marketData, marketRange]); const amountPrefix = getDisplaySymbol(fromAsset); + 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: { fromCode: string; toCode: string }; + fromAsset: (typeof assets)[number]; + toAsset: (typeof assets)[number]; + } => Boolean(item), + ), + [pinnedPairs, rateMap], + ); const amountInputPaddingLeft = useMemo(() => { if (!amountPrefix) { return undefined; @@ -540,6 +593,136 @@ export function ConverterCard({ /> +
+
+

+ 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 ({MAX_PINNED_PAIRS}). Remove one to add a new pair. +

+ ) : null} +
+

diff --git a/hooks/use-pinned-pairs.ts b/hooks/use-pinned-pairs.ts new file mode 100644 index 0000000..ae586ce --- /dev/null +++ b/hooks/use-pinned-pairs.ts @@ -0,0 +1,196 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; + +export interface PinnedPair { + fromCode: string; + toCode: string; +} + +const PINNED_PAIRS_STORAGE_KEY = "nexcurrency:pinned-pairs:v1"; +const CODE_PATTERN = /^[A-Z0-9]{2,12}$/; + +function normalizeCode(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + + const normalized = value.trim().toUpperCase(); + + if (!CODE_PATTERN.test(normalized)) { + return null; + } + + return normalized; +} + +function normalizePair(value: unknown): PinnedPair | null { + if (!value || typeof value !== "object") { + return null; + } + + const record = value as Record; + const fromCode = normalizeCode(record.fromCode); + const toCode = normalizeCode(record.toCode); + + if (!fromCode || !toCode || fromCode === toCode) { + return null; + } + + return { fromCode, toCode }; +} + +function toPairKey(pair: PinnedPair): string { + return `${pair.fromCode}:${pair.toCode}`; +} + +function dedupeAndClampPairs(pairs: PinnedPair[], limit: number): PinnedPair[] { + const keys = new Set(); + const normalized: PinnedPair[] = []; + + for (const pair of pairs) { + const key = toPairKey(pair); + + if (keys.has(key)) { + continue; + } + + keys.add(key); + normalized.push(pair); + + if (normalized.length >= limit) { + break; + } + } + + return normalized; +} + +export function usePinnedPairs(limit = 6) { + const [pinnedPairs, setPinnedPairs] = useState([]); + const [isReady, setIsReady] = useState(false); + const isLimitReached = pinnedPairs.length >= limit; + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + try { + const raw = window.localStorage.getItem(PINNED_PAIRS_STORAGE_KEY); + + if (!raw) { + setPinnedPairs([]); + setIsReady(true); + return; + } + + const parsed = JSON.parse(raw) as unknown; + const parsedPairs = Array.isArray(parsed) + ? parsed.map(normalizePair).filter((pair): pair is PinnedPair => pair !== null) + : []; + + setPinnedPairs(dedupeAndClampPairs(parsedPairs, limit)); + } catch { + setPinnedPairs([]); + } finally { + setIsReady(true); + } + }, [limit]); + + useEffect(() => { + if (!isReady || typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem( + PINNED_PAIRS_STORAGE_KEY, + JSON.stringify(pinnedPairs), + ); + } catch { + // Intentionally ignored: localStorage may be unavailable. + } + }, [isReady, pinnedPairs]); + + const isPinnedPair = useCallback( + (pair: PinnedPair) => pinnedPairs.some((entry) => toPairKey(entry) === toPairKey(pair)), + [pinnedPairs], + ); + + const addPinnedPair = useCallback( + (pair: PinnedPair) => { + const normalized = normalizePair(pair); + + if (!normalized) { + return; + } + + setPinnedPairs((current) => { + const exists = current.some( + (entry) => toPairKey(entry) === toPairKey(normalized), + ); + + if (!exists && current.length >= limit) { + return current; + } + + return dedupeAndClampPairs( + [ + normalized, + ...current.filter((entry) => toPairKey(entry) !== toPairKey(normalized)), + ], + limit, + ); + }); + }, + [limit], + ); + + const removePinnedPair = useCallback((pair: PinnedPair) => { + const normalized = normalizePair(pair); + + if (!normalized) { + return; + } + + setPinnedPairs((current) => + current.filter((entry) => toPairKey(entry) !== toPairKey(normalized)), + ); + }, []); + + const togglePinnedPair = useCallback( + (pair: PinnedPair) => { + if (isPinnedPair(pair)) { + removePinnedPair(pair); + return; + } + + addPinnedPair(pair); + }, + [addPinnedPair, isPinnedPair, removePinnedPair], + ); + + return useMemo( + () => ({ + pinnedPairs, + isReady, + isLimitReached, + isPinnedPair, + addPinnedPair, + removePinnedPair, + togglePinnedPair, + limit, + }), + [ + pinnedPairs, + isReady, + isLimitReached, + isPinnedPair, + addPinnedPair, + removePinnedPair, + togglePinnedPair, + limit, + ], + ); +}