537 lines
20 KiB
TypeScript
537 lines
20 KiB
TypeScript
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
|
import { Autocomplete, TextField, CircularProgress, Box, SxProps, Theme, IconButton, Typography, Divider } from '@mui/material';
|
|
import { Star, StarBorder } from '@mui/icons-material';
|
|
import upbitApiService, { UpbitMarket, UpbitTicker } from '../services/upbitApi';
|
|
import { toggleFavorite, getAllFavorites } from '../services/cryptoFavoriteApi';
|
|
|
|
interface CryptoNameComboBoxProps {
|
|
value: string;
|
|
onChange: (symbol: string, cryptoInfo?: UpbitMarket) => void;
|
|
disabled?: boolean;
|
|
label?: string;
|
|
placeholder?: string;
|
|
helperText?: string;
|
|
size?: 'small' | 'medium';
|
|
sx?: SxProps<Theme>;
|
|
}
|
|
|
|
type SortKey = 'favorite' | 'korean_name' | 'trade_price' | 'signed_change_rate' | 'acc_trade_price_24h';
|
|
type SortOrder = 'asc' | 'desc';
|
|
|
|
const HEADER_ROW_HEIGHT = 30;
|
|
|
|
const formatPrice = (price?: number): string => {
|
|
if (price == null) return '-';
|
|
if (price >= 1000) return price.toLocaleString();
|
|
if (price >= 1) return price.toLocaleString(undefined, { maximumFractionDigits: 4 });
|
|
return price.toLocaleString(undefined, { maximumFractionDigits: 8 });
|
|
};
|
|
|
|
const formatChangeRate = (rate?: number): string => {
|
|
if (rate == null) return '-';
|
|
const percent = rate * 100;
|
|
const sign = percent > 0 ? '+' : '';
|
|
return `${sign}${percent.toFixed(2)}%`;
|
|
};
|
|
|
|
const formatTradeValue = (value?: number): string => {
|
|
if (value == null) return '-';
|
|
if (value >= 1_0000_0000_0000) return `${(value / 1_0000_0000_0000).toFixed(2)}조`;
|
|
if (value >= 1_0000_0000) return `${(value / 1_0000_0000).toFixed(1)}억`;
|
|
if (value >= 1_0000) return `${(value / 1_0000).toFixed(1)}만`;
|
|
return value.toLocaleString();
|
|
};
|
|
|
|
const compareNumber = (a?: number, b?: number): number => {
|
|
const av = a ?? Number.NEGATIVE_INFINITY;
|
|
const bv = b ?? Number.NEGATIVE_INFINITY;
|
|
return av - bv;
|
|
};
|
|
|
|
const compareString = (a?: string, b?: string): number => (a ?? '').localeCompare(b ?? '', 'ko');
|
|
|
|
|
|
const CryptoNameComboBox: React.FC<CryptoNameComboBoxProps> = ({
|
|
value,
|
|
onChange,
|
|
disabled = false,
|
|
label = '암호화폐명',
|
|
placeholder = '암호화폐명을 입력하거나 선택하세요',
|
|
helperText,
|
|
size = 'medium',
|
|
sx,
|
|
}) => {
|
|
const [cryptos, setCryptos] = useState<UpbitMarket[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [inputValue, setInputValue] = useState('');
|
|
const [selectedCrypto, setSelectedCrypto] = useState<UpbitMarket | null>(null);
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [isUserTyping, setIsUserTyping] = useState(false);
|
|
const [favorites, setFavorites] = useState<Record<string, boolean>>({});
|
|
const [tickerMap, setTickerMap] = useState<Record<string, UpbitTicker>>({});
|
|
const [sortKey, setSortKey] = useState<SortKey>('favorite');
|
|
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
|
|
const [dropdownWidth, setDropdownWidth] = useState<number>(520);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const comboRootRef = useRef<HTMLDivElement>(null);
|
|
|
|
const fetchTickers = useCallback(async (markets: string[]) => {
|
|
if (markets.length === 0) return;
|
|
const chunkSize = 80;
|
|
const chunks: string[][] = [];
|
|
for (let i = 0; i < markets.length; i += chunkSize) {
|
|
chunks.push(markets.slice(i, i + chunkSize));
|
|
}
|
|
|
|
try {
|
|
const results = await Promise.all(chunks.map((chunk) => upbitApiService.getCurrentPrice(chunk)));
|
|
const next: Record<string, UpbitTicker> = {};
|
|
results.flat().forEach((ticker) => {
|
|
if (ticker?.market) next[ticker.market] = ticker;
|
|
});
|
|
setTickerMap(next);
|
|
} catch (error) {
|
|
console.error('티커 로드 실패:', error);
|
|
}
|
|
}, []);
|
|
|
|
// 컴포넌트 마운트 시 암호화폐 목록 로드
|
|
useEffect(() => {
|
|
const loadAllCryptos = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const activeCryptos = await upbitApiService.getActiveCryptos();
|
|
const convertedCryptos = activeCryptos.map(crypto => ({
|
|
market: crypto.symbol,
|
|
korean_name: crypto.koreanName,
|
|
english_name: crypto.englishName
|
|
}));
|
|
setCryptos(convertedCryptos);
|
|
await fetchTickers(convertedCryptos.map((c) => c.market));
|
|
|
|
// 즐겨찾기 목록 한 번에 로드
|
|
try {
|
|
const favList = await getAllFavorites();
|
|
const favMap: Record<string, boolean> = {};
|
|
favList.forEach(fav => {
|
|
favMap[fav.symbol] = true;
|
|
});
|
|
setFavorites(favMap);
|
|
} catch (error) {
|
|
console.error('즐겨찾기 목록 로드 실패:', error);
|
|
setFavorites({});
|
|
}
|
|
} catch (error) {
|
|
console.error('암호화폐 목록 로드 실패:', error);
|
|
const defaultCryptos = [
|
|
{ market: 'KRW-BTC', korean_name: '비트코인', english_name: 'Bitcoin' },
|
|
{ market: 'KRW-ETH', korean_name: '이더리움', english_name: 'Ethereum' },
|
|
{ market: 'KRW-XRP', korean_name: '리플', english_name: 'Ripple' },
|
|
{ market: 'KRW-ADA', korean_name: '에이다', english_name: 'Cardano' },
|
|
{ market: 'KRW-SOL', korean_name: '솔라나', english_name: 'Solana' },
|
|
];
|
|
setCryptos(defaultCryptos);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadAllCryptos();
|
|
}, [fetchTickers]);
|
|
|
|
useEffect(() => {
|
|
if (!isOpen || cryptos.length === 0) return;
|
|
void fetchTickers(cryptos.map((c) => c.market));
|
|
}, [isOpen, cryptos, fetchTickers]);
|
|
|
|
// 부모에서 종목이 바뀌면(알림·FAB 등) 검색 중 플래그 해제 → 아래 동기화 effect가 한글명 반영
|
|
useEffect(() => {
|
|
setIsUserTyping(false);
|
|
}, [value]);
|
|
|
|
// 외부 value 변경 시 선택 상태 동기화
|
|
useEffect(() => {
|
|
// 사용자가 타이핑 중이거나 드롭다운이 열려있을 때는 동기화하지 않음
|
|
if (isUserTyping || isOpen) {
|
|
return;
|
|
}
|
|
|
|
if (value && cryptos.length > 0) {
|
|
const crypto = cryptos.find(c => c.market === value);
|
|
if (crypto && crypto !== selectedCrypto) {
|
|
setSelectedCrypto(crypto);
|
|
setInputValue(crypto.korean_name);
|
|
}
|
|
} else if (!value) {
|
|
setSelectedCrypto(null);
|
|
setInputValue('');
|
|
}
|
|
}, [value, cryptos, selectedCrypto, isUserTyping, isOpen]);
|
|
|
|
// 필터링된 종목 목록
|
|
// - 드롭다운이 열릴 때 (isOpen=true) 사용자가 타이핑 중일 때만 필터링
|
|
// - 목록을 펼칠 때는 모든 종목 표시
|
|
// - 즐겨찾기를 먼저 표시하도록 정렬
|
|
const getFilteredOptions = useCallback(() => {
|
|
let filteredList: UpbitMarket[];
|
|
|
|
// 사용자가 타이핑 중일 때만 필터링
|
|
if (isUserTyping && inputValue) {
|
|
const lowerCaseInput = inputValue.toLowerCase();
|
|
filteredList = cryptos.filter(crypto => {
|
|
if (!crypto || !crypto.korean_name || !crypto.english_name || !crypto.market) return false;
|
|
return crypto.korean_name.toLowerCase().includes(lowerCaseInput) ||
|
|
crypto.english_name.toLowerCase().includes(lowerCaseInput) ||
|
|
crypto.market.toLowerCase().includes(lowerCaseInput);
|
|
});
|
|
} else {
|
|
// 목록을 펼칠 때는 모든 종목 표시
|
|
filteredList = cryptos;
|
|
}
|
|
|
|
return [...filteredList].sort((a, b) => {
|
|
const tA = tickerMap[a.market];
|
|
const tB = tickerMap[b.market];
|
|
let cmp = 0;
|
|
|
|
if (sortKey === 'favorite') {
|
|
cmp = Number(!!favorites[a.market]) - Number(!!favorites[b.market]);
|
|
} else if (sortKey === 'korean_name') {
|
|
cmp = compareString(a.korean_name, b.korean_name);
|
|
} else if (sortKey === 'trade_price') {
|
|
cmp = compareNumber(tA?.trade_price, tB?.trade_price);
|
|
} else if (sortKey === 'signed_change_rate') {
|
|
cmp = compareNumber(tA?.signed_change_rate, tB?.signed_change_rate);
|
|
} else if (sortKey === 'acc_trade_price_24h') {
|
|
cmp = compareNumber(tA?.acc_trade_price_24h, tB?.acc_trade_price_24h);
|
|
}
|
|
|
|
if (cmp === 0) {
|
|
cmp = compareString(a.korean_name, b.korean_name);
|
|
}
|
|
return sortOrder === 'asc' ? cmp : -cmp;
|
|
});
|
|
}, [cryptos, inputValue, isUserTyping, favorites, tickerMap, sortKey, sortOrder]);
|
|
|
|
const handleSort = useCallback((key: SortKey) => {
|
|
if (sortKey === key) {
|
|
setSortOrder((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
|
return;
|
|
}
|
|
setSortKey(key);
|
|
setSortOrder(key === 'korean_name' ? 'asc' : 'desc');
|
|
}, [sortKey]);
|
|
|
|
// 드롭다운 열림/닫힘 핸들러
|
|
const handleOpen = () => {
|
|
const inputWidth = comboRootRef.current?.clientWidth ?? 0;
|
|
// 입력란 좌측 기준으로 붙이고, 너무 좁지 않게 최소 폭 보장
|
|
setDropdownWidth(Math.max(520, inputWidth));
|
|
setIsOpen(true);
|
|
setIsUserTyping(false); // 목록을 펼칠 때는 필터링 비활성화
|
|
};
|
|
|
|
const handleClose = () => {
|
|
setIsOpen(false);
|
|
setIsUserTyping(false);
|
|
// 닫힐 때 입력값이 비어있거나 매칭되는 종목이 없으면 선택된 종목으로 복원
|
|
if (selectedCrypto && !inputValue) {
|
|
setInputValue(selectedCrypto.korean_name);
|
|
} else if (!selectedCrypto && !inputValue) {
|
|
// 선택된 종목도 없고 입력값도 없으면 그대로 유지
|
|
setInputValue('');
|
|
}
|
|
};
|
|
|
|
// 종목 선택 핸들러 — 목록에서 고를 때만 부모 onChange(차트 갱신). Clear는 검색칸만 비움.
|
|
const handleChange = (event: React.SyntheticEvent, newValue: UpbitMarket | null) => {
|
|
if (newValue) {
|
|
setSelectedCrypto(newValue);
|
|
setIsUserTyping(false);
|
|
setInputValue(newValue.korean_name);
|
|
onChange(newValue.market, newValue);
|
|
return;
|
|
}
|
|
// Clear(X): 실제 선택·부모 symbol·차트는 유지, 입력만 비워 다음 종목 검색 가능
|
|
const keep =
|
|
(value && cryptos.length > 0 ? cryptos.find((c) => c.market === value) : null) ?? selectedCrypto;
|
|
if (keep) {
|
|
setSelectedCrypto(keep);
|
|
setInputValue('');
|
|
setIsUserTyping(true);
|
|
} else {
|
|
setSelectedCrypto(null);
|
|
setInputValue('');
|
|
setIsUserTyping(false);
|
|
}
|
|
};
|
|
|
|
// 입력 변경 핸들러
|
|
const handleInputChange = (event: React.SyntheticEvent, newInputValue: string, reason: string) => {
|
|
setInputValue(newInputValue);
|
|
|
|
// 사용자가 키보드로 직접 입력하거나 clear할 때 필터링 활성화
|
|
if (reason === 'input' || reason === 'clear') {
|
|
setIsUserTyping(true);
|
|
} else if (reason === 'reset') {
|
|
// 선택이 완료되었을 때만 타이핑 상태 해제
|
|
setIsUserTyping(false);
|
|
}
|
|
};
|
|
|
|
// 키 입력 핸들러 - 엔터키 처리
|
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
|
if (event.key === 'Enter' && isUserTyping) {
|
|
const filteredOptions = getFilteredOptions();
|
|
// 필터링된 결과가 하나이면 해당 종목 선택
|
|
if (filteredOptions.length === 1) {
|
|
const crypto = filteredOptions[0];
|
|
setSelectedCrypto(crypto);
|
|
setInputValue(crypto.korean_name);
|
|
setIsUserTyping(false);
|
|
setIsOpen(false);
|
|
onChange(crypto.market, crypto);
|
|
} else if (filteredOptions.length > 1) {
|
|
// 필터링된 결과 중 정확히 일치하는 종목이 있으면 선택
|
|
const exactMatch = filteredOptions.find(c =>
|
|
c.korean_name.toLowerCase() === inputValue.toLowerCase() ||
|
|
c.english_name.toLowerCase() === inputValue.toLowerCase() ||
|
|
c.market.toLowerCase() === inputValue.toLowerCase()
|
|
);
|
|
if (exactMatch) {
|
|
setSelectedCrypto(exactMatch);
|
|
setInputValue(exactMatch.korean_name);
|
|
setIsUserTyping(false);
|
|
setIsOpen(false);
|
|
onChange(exactMatch.market, exactMatch);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
// 즐겨찾기 토글 핸들러
|
|
const handleToggleFavorite = async (e: React.MouseEvent, option: UpbitMarket) => {
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
try {
|
|
const result = await toggleFavorite({
|
|
symbol: option.market,
|
|
koreanName: option.korean_name,
|
|
englishName: option.english_name,
|
|
});
|
|
setFavorites(prev => ({ ...prev, [option.market]: result }));
|
|
} catch (error) {
|
|
console.error('즐겨찾기 토글 실패:', error);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Box ref={comboRootRef}>
|
|
<Autocomplete
|
|
options={getFilteredOptions()}
|
|
getOptionLabel={(option) => option.korean_name}
|
|
isOptionEqualToValue={(option, val) => option.market === val.market}
|
|
loading={loading}
|
|
disabled={disabled}
|
|
value={selectedCrypto}
|
|
open={isOpen}
|
|
onOpen={handleOpen}
|
|
onClose={handleClose}
|
|
onChange={handleChange}
|
|
inputValue={inputValue}
|
|
onInputChange={handleInputChange}
|
|
filterOptions={(options) => options} // 커스텀 필터링 사용
|
|
size={size}
|
|
freeSolo={false} // 목록에서만 선택 가능
|
|
clearOnBlur={false} // 포커스를 잃어도 입력값 유지
|
|
selectOnFocus={false} // 포커스 시 자동 선택 안함
|
|
handleHomeEndKeys={false}
|
|
disableClearable={false} // X 버튼 표시
|
|
sx={sx}
|
|
renderInput={(params) => (
|
|
<TextField
|
|
{...params}
|
|
inputRef={inputRef}
|
|
label={label || undefined}
|
|
placeholder={placeholder}
|
|
helperText={helperText || undefined}
|
|
fullWidth
|
|
size={size}
|
|
onKeyDown={handleKeyDown}
|
|
InputProps={{
|
|
...params.InputProps,
|
|
endAdornment: (
|
|
<React.Fragment>
|
|
{loading ? <CircularProgress color="inherit" size={20} /> : null}
|
|
{params.InputProps.endAdornment}
|
|
</React.Fragment>
|
|
),
|
|
}}
|
|
/>
|
|
)}
|
|
renderOption={(props, option, state) => {
|
|
const isFav = favorites[option.market] || false;
|
|
const ticker = tickerMap[option.market];
|
|
const options = getFilteredOptions();
|
|
const currentIndex = state.index;
|
|
const isFirstRow = currentIndex === 0;
|
|
|
|
// 현재 항목이 즐겨찾기이고, 다음 항목이 일반 항목인지 확인
|
|
const nextOption = options[currentIndex + 1];
|
|
const isLastFavorite = isFav && nextOption && !favorites[nextOption.market];
|
|
|
|
return (
|
|
<React.Fragment key={option.market}>
|
|
{isFirstRow && (
|
|
<Box
|
|
sx={{
|
|
position: 'sticky',
|
|
top: 0,
|
|
zIndex: 1,
|
|
display: 'grid',
|
|
gridTemplateColumns: '28px minmax(120px, 1.2fr) minmax(92px, 0.9fr) minmax(82px, 0.8fr) minmax(82px, 0.8fr)',
|
|
alignItems: 'center',
|
|
px: 1.5,
|
|
height: HEADER_ROW_HEIGHT,
|
|
bgcolor: 'background.paper',
|
|
borderBottom: '1px solid',
|
|
borderColor: 'divider',
|
|
}}
|
|
>
|
|
<Typography
|
|
variant="caption"
|
|
sx={{ textAlign: 'center', cursor: 'pointer', userSelect: 'none' }}
|
|
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('favorite'); }}
|
|
>
|
|
즐겨
|
|
</Typography>
|
|
<Typography
|
|
variant="caption"
|
|
sx={{ cursor: 'pointer', userSelect: 'none' }}
|
|
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('korean_name'); }}
|
|
>
|
|
한글명 {sortKey === 'korean_name' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
|
</Typography>
|
|
<Typography
|
|
variant="caption"
|
|
sx={{ textAlign: 'right', cursor: 'pointer', userSelect: 'none' }}
|
|
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('trade_price'); }}
|
|
>
|
|
현재가 {sortKey === 'trade_price' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
|
</Typography>
|
|
<Typography
|
|
variant="caption"
|
|
sx={{ textAlign: 'right', cursor: 'pointer', userSelect: 'none' }}
|
|
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('signed_change_rate'); }}
|
|
>
|
|
전일대비 {sortKey === 'signed_change_rate' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
|
</Typography>
|
|
<Typography
|
|
variant="caption"
|
|
sx={{ textAlign: 'right', cursor: 'pointer', userSelect: 'none' }}
|
|
onMouseDown={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
|
onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleSort('acc_trade_price_24h'); }}
|
|
>
|
|
거래대금 {sortKey === 'acc_trade_price_24h' ? (sortOrder === 'asc' ? '▲' : '▼') : ''}
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
<Box
|
|
component="li"
|
|
{...props}
|
|
sx={{
|
|
display: 'grid !important',
|
|
gridTemplateColumns: '28px minmax(120px, 1.2fr) minmax(92px, 0.9fr) minmax(82px, 0.8fr) minmax(82px, 0.8fr) !important',
|
|
alignItems: 'center !important',
|
|
gap: 1,
|
|
padding: '8px 12px !important',
|
|
}}
|
|
>
|
|
<IconButton
|
|
size="small"
|
|
onClick={(e) => handleToggleFavorite(e, option)}
|
|
sx={{
|
|
color: isFav ? '#fbbf24' : 'action.disabled',
|
|
padding: '2px',
|
|
}}
|
|
>
|
|
{isFav ? <Star fontSize="small" /> : <StarBorder fontSize="small" />}
|
|
</IconButton>
|
|
|
|
{/* 한글명 */}
|
|
<Box sx={{ minWidth: 0, display: 'flex', flexDirection: 'column' }}>
|
|
<Typography sx={{ fontWeight: 'bold', fontSize: '14px' }}>
|
|
{option.korean_name}
|
|
</Typography>
|
|
<Typography sx={{ fontSize: '12px', color: 'text.secondary' }}>
|
|
{option.market.replace('KRW-', '')}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* 현재가 */}
|
|
<Typography sx={{ textAlign: 'right', fontWeight: 600, fontSize: '13px' }}>
|
|
{formatPrice(ticker?.trade_price)}
|
|
</Typography>
|
|
|
|
{/* 전일대비 */}
|
|
<Typography
|
|
sx={{
|
|
textAlign: 'right',
|
|
fontWeight: 600,
|
|
fontSize: '13px',
|
|
color:
|
|
(ticker?.signed_change_rate ?? 0) > 0
|
|
? 'error.main'
|
|
: (ticker?.signed_change_rate ?? 0) < 0
|
|
? 'info.main'
|
|
: 'text.secondary',
|
|
}}
|
|
>
|
|
{formatChangeRate(ticker?.signed_change_rate)}
|
|
</Typography>
|
|
|
|
{/* 거래대금 */}
|
|
<Typography sx={{ textAlign: 'right', fontSize: '12px', color: 'text.secondary' }}>
|
|
{formatTradeValue(ticker?.acc_trade_price_24h)}
|
|
</Typography>
|
|
</Box>
|
|
|
|
{/* 즐겨찾기와 일반 종목 사이 divider */}
|
|
{isLastFavorite && (
|
|
<Divider sx={{ my: 0.5 }} />
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
}}
|
|
ListboxProps={{
|
|
sx: {
|
|
width: '100%',
|
|
maxHeight: '70vh',
|
|
pt: 0,
|
|
},
|
|
}}
|
|
componentsProps={{
|
|
popper: {
|
|
style: {
|
|
width: `${dropdownWidth}px`,
|
|
},
|
|
placement: 'bottom-start',
|
|
},
|
|
paper: {
|
|
sx: {
|
|
maxHeight: '72vh',
|
|
},
|
|
},
|
|
}}
|
|
/>
|
|
</Box>
|
|
);
|
|
|
|
};
|
|
|
|
export default CryptoNameComboBox;
|