615 lines
30 KiB
HTML
615 lines
30 KiB
HTML
|
<!DOCTYPE html>
|
||
|
<html lang="ko">
|
||
|
<head>
|
||
|
<meta charset="UTF-8">
|
||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
|
<title>AI 투자 시스템</title>
|
||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||
|
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||
|
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||
|
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||
|
</head>
|
||
|
<body>
|
||
|
<div id="root"></div>
|
||
|
|
||
|
<script type="text/babel">
|
||
|
const { useState, useEffect } = React;
|
||
|
|
||
|
// 설정
|
||
|
const COIN_LIST = [
|
||
|
{ code: 'KRW-BTC', name: '비트코인', symbol: 'BTC' },
|
||
|
{ code: 'KRW-ETH', name: '이더리움', symbol: 'ETH' },
|
||
|
{ code: 'KRW-SOL', name: '솔라나', symbol: 'SOL' },
|
||
|
{ code: 'KRW-XRP', name: '리플', symbol: 'XRP' },
|
||
|
{ code: 'KRW-ADA', name: '에이다', symbol: 'ADA' }
|
||
|
];
|
||
|
|
||
|
// API 함수들
|
||
|
const getUpbitCurrentPrice = async (markets) => {
|
||
|
try {
|
||
|
const response = await fetch(`https://api.upbit.com/v1/ticker?markets=${markets.join(',')}`);
|
||
|
const data = await response.json();
|
||
|
return data.map(item => ({
|
||
|
market: item.market,
|
||
|
trade_price: item.trade_price,
|
||
|
change: item.change,
|
||
|
change_price: item.change_price,
|
||
|
change_rate: item.change_rate
|
||
|
}));
|
||
|
} catch (error) {
|
||
|
console.error('업비트 API 호출 실패:', error);
|
||
|
return [];
|
||
|
}
|
||
|
};
|
||
|
|
||
|
// 더미 캔들 데이터 생성 함수
|
||
|
const generateDummyCandles = (count, basePrice) => {
|
||
|
const candles = [];
|
||
|
let currentPrice = basePrice;
|
||
|
const now = new Date();
|
||
|
|
||
|
for (let i = count - 1; i >= 0; i--) {
|
||
|
const date = new Date(now.getTime() - i * 60 * 1000);
|
||
|
const variation = (Math.random() - 0.5) * 0.02;
|
||
|
const open = currentPrice;
|
||
|
const change = basePrice * variation;
|
||
|
const high = open + Math.abs(change) * Math.random();
|
||
|
const low = open - Math.abs(change) * Math.random();
|
||
|
const close = open + change;
|
||
|
|
||
|
candles.push({
|
||
|
target_dt: date.toISOString(),
|
||
|
open,
|
||
|
high: Math.max(open, high, close),
|
||
|
low: Math.min(open, low, close),
|
||
|
close,
|
||
|
volume: Math.random() * 1000
|
||
|
});
|
||
|
|
||
|
currentPrice = close;
|
||
|
}
|
||
|
|
||
|
return candles;
|
||
|
};
|
||
|
|
||
|
// 차트 컴포넌트
|
||
|
const CoinChart = ({ coinCode, coinName, currentPrice }) => {
|
||
|
const [chartData, setChartData] = useState([]);
|
||
|
const [selectedInterval, setSelectedInterval] = useState('1d');
|
||
|
|
||
|
useEffect(() => {
|
||
|
// 더미 캔들 데이터 생성
|
||
|
const basePrices = {
|
||
|
'KRW-BTC': 90000000,
|
||
|
'KRW-ETH': 4000000,
|
||
|
'KRW-SOL': 300000,
|
||
|
'KRW-XRP': 3000,
|
||
|
'KRW-ADA': 1200
|
||
|
};
|
||
|
|
||
|
const basePrice = basePrices[coinCode] || 100000;
|
||
|
const data = [
|
||
|
{
|
||
|
interval: '1d',
|
||
|
description: '일봉',
|
||
|
data: generateDummyCandles(30, basePrice)
|
||
|
},
|
||
|
{
|
||
|
interval: '1h',
|
||
|
description: '시간봉',
|
||
|
data: generateDummyCandles(24, basePrice)
|
||
|
},
|
||
|
{
|
||
|
interval: '1m',
|
||
|
description: '분봉',
|
||
|
data: generateDummyCandles(60, basePrice)
|
||
|
}
|
||
|
];
|
||
|
|
||
|
setChartData(data);
|
||
|
}, [coinCode]);
|
||
|
|
||
|
const selectedData = chartData.find(dataset => dataset.interval === selectedInterval);
|
||
|
const formatPrice = (price) => new Intl.NumberFormat('ko-KR').format(price);
|
||
|
|
||
|
return (
|
||
|
<div className="bg-white rounded-lg shadow p-6">
|
||
|
<div className="flex justify-between items-center mb-4">
|
||
|
<div>
|
||
|
<h3 className="text-lg font-semibold text-gray-900">{coinName}</h3>
|
||
|
{currentPrice && (
|
||
|
<p className="text-sm text-gray-600">
|
||
|
현재가: <span className="font-medium text-blue-600">{formatPrice(currentPrice)}원</span>
|
||
|
</p>
|
||
|
)}
|
||
|
</div>
|
||
|
|
||
|
<div className="flex space-x-2">
|
||
|
{chartData.map((dataset) => (
|
||
|
<button
|
||
|
key={dataset.interval}
|
||
|
onClick={() => setSelectedInterval(dataset.interval)}
|
||
|
className={`px-3 py-1 text-xs rounded ${
|
||
|
selectedInterval === dataset.interval
|
||
|
? 'bg-blue-500 text-white'
|
||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||
|
}`}
|
||
|
>
|
||
|
{dataset.description}
|
||
|
</button>
|
||
|
))}
|
||
|
</div>
|
||
|
</div>
|
||
|
|
||
|
{selectedData && (
|
||
|
<div className="h-64 relative">
|
||
|
<div className="flex items-end justify-between h-full border-b border-gray-200 px-2">
|
||
|
{selectedData.data.slice(-20).map((candle, index) => {
|
||
|
const maxPrice = Math.max(...selectedData.data.map(c => c.high));
|
||
|
const minPrice = Math.min(...selectedData.data.map(c => c.low));
|
||
|
const priceRange = maxPrice - minPrice;
|
||
|
|
||
|
const height = ((candle.high - candle.low) / priceRange) * 200;
|
||
|
const bottomOffset = ((candle.low - minPrice) / priceRange) * 200;
|
||
|
const isGreen = candle.close >= candle.open;
|
||
|
|
||
|
return (
|
||
|
<div key={index} className="flex flex-col items-center group relative" style={{ width: '4%' }}>
|
||
|
<div
|
||
|
className={`w-1 ${isGreen ? 'bg-red-500' : 'bg-blue-500'}`}
|
||
|
style={{
|
||
|
height: `${height}px`,
|
||
|
marginBottom: `${bottomOffset}px`
|
||
|
}}
|
||
|
/>
|
||
|
|
||
|
<div className="absolute bottom-full mb-2 hidden group-hover:block bg-gray-800 text-white text-xs rounded px-2 py-1 whitespace-nowrap z-10">
|
||
|
<div>시가: {formatPrice(candle.open)}</div>
|
||
|
<div>고가: {formatPrice(candle.high)}</div>
|
||
|
<div>저가: {formatPrice(candle.low)}</div>
|
||
|
<div>종가: {formatPrice(candle.close)}</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
);
|
||
|
})}
|
||
|
</div>
|
||
|
</div>
|
||
|
)}
|
||
|
</div>
|
||
|
);
|
||
|
};
|
||
|
|
||
|
// 인증 모달 컴포넌트
|
||
|
const AuthModal = ({ isOpen, onClose, onLogin }) => {
|
||
|
const [isLoginMode, setIsLoginMode] = useState(true);
|
||
|
const [email, setEmail] = useState('');
|
||
|
const [nickname, setNickname] = useState('');
|
||
|
|
||
|
if (!isOpen) return null;
|
||
|
|
||
|
const handleSubmit = (e) => {
|
||
|
e.preventDefault();
|
||
|
|
||
|
// 간단한 로컬스토리지 기반 인증
|
||
|
const users = JSON.parse(localStorage.getItem('users') || '[]');
|
||
|
|
||
|
if (isLoginMode) {
|
||
|
const user = users.find(u => u.email === email);
|
||
|
if (user) {
|
||
|
onLogin(user);
|
||
|
onClose();
|
||
|
} else {
|
||
|
alert('등록되지 않은 이메일입니다.');
|
||
|
}
|
||
|
} else {
|
||
|
if (users.find(u => u.email === email)) {
|
||
|
alert('이미 등록된 이메일입니다.');
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const newUser = {
|
||
|
user_seq: Date.now(),
|
||
|
email,
|
||
|
nickname: nickname || '사용자',
|
||
|
cash_balance: 10000000,
|
||
|
invested_amount: 0,
|
||
|
selected_invest_code: 'KRW-BTC',
|
||
|
invest_run_flag: false
|
||
|
};
|
||
|
|
||
|
users.push(newUser);
|
||
|
localStorage.setItem('users', JSON.stringify(users));
|
||
|
onLogin(newUser);
|
||
|
onClose();
|
||
|
}
|
||
|
};
|
||
|
|
||
|
return (
|
||
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
|
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||
|
<div className="flex justify-between items-center mb-4">
|
||
|
<h2 className="text-xl font-semibold">
|
||
|
{isLoginMode ? '로그인' : '회원가입'}
|
||
|
</h2>
|
||
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">✕</button>
|
||
|
</div>
|
||
|
|
||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||
|
<div>
|
||
|
<label className="block text-sm font-medium text-gray-700 mb-1">이메일</label>
|
||
|
<input
|
||
|
type="email"
|
||
|
value={email}
|
||
|
onChange={(e) => setEmail(e.target.value)}
|
||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
|
placeholder="이메일을 입력하세요"
|
||
|
required
|
||
|
/>
|
||
|
</div>
|
||
|
|
||
|
{!isLoginMode && (
|
||
|
<div>
|
||
|
<label className="block text-sm font-medium text-gray-700 mb-1">닉네임</label>
|
||
|
<input
|
||
|
type="text"
|
||
|
value={nickname}
|
||
|
onChange={(e) => setNickname(e.target.value)}
|
||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
|
placeholder="닉네임을 입력하세요"
|
||
|
/>
|
||
|
</div>
|
||
|
)}
|
||
|
|
||
|
<button
|
||
|
type="submit"
|
||
|
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700"
|
||
|
>
|
||
|
{isLoginMode ? '로그인' : '회원가입'}
|
||
|
</button>
|
||
|
</form>
|
||
|
|
||
|
<div className="mt-4 text-center">
|
||
|
<button
|
||
|
onClick={() => setIsLoginMode(!isLoginMode)}
|
||
|
className="text-blue-600 hover:text-blue-800 text-sm"
|
||
|
>
|
||
|
{isLoginMode ? '계정이 없으신가요? 회원가입' : '이미 계정이 있으신가요? 로그인'}
|
||
|
</button>
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
);
|
||
|
};
|
||
|
|
||
|
// 사용자 패널 컴포넌트
|
||
|
const UserPanel = ({ user, onUserUpdate, onLogout }) => {
|
||
|
const [isEditing, setIsEditing] = useState(false);
|
||
|
const [editData, setEditData] = useState({
|
||
|
cash_balance: user.cash_balance,
|
||
|
selected_invest_code: user.selected_invest_code,
|
||
|
invest_run_flag: user.invest_run_flag
|
||
|
});
|
||
|
|
||
|
const formatPrice = (price) => new Intl.NumberFormat('ko-KR').format(price);
|
||
|
const selectedCoin = COIN_LIST.find(coin => coin.code === user.selected_invest_code);
|
||
|
|
||
|
const handleSave = () => {
|
||
|
// 투자중일 때 현금 변경 방지
|
||
|
if (user.invest_run_flag && editData.cash_balance !== user.cash_balance) {
|
||
|
alert('투자 진행 중에는 현금을 변경할 수 없습니다.');
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// 투자상품 변경 시 확인
|
||
|
if (editData.selected_invest_code !== user.selected_invest_code && user.invested_amount > 0) {
|
||
|
if (!confirm('투자상품을 변경하면 현재 보유중인 모든 투자상품이 자동으로 매도됩니다. 계속하시겠습니까?')) {
|
||
|
return;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// 투자 진행 플래그를 false로 변경 시 확인
|
||
|
if (user.invest_run_flag && !editData.invest_run_flag && user.invested_amount > 0) {
|
||
|
if (!confirm('투자를 중지하면 현재 보유중인 모든 투자상품이 자동으로 매도됩니다. 계속하시겠습니까?')) {
|
||
|
return;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const updatedUser = { ...user, ...editData };
|
||
|
|
||
|
// 로컬스토리지 업데이트
|
||
|
const users = JSON.parse(localStorage.getItem('users') || '[]');
|
||
|
const userIndex = users.findIndex(u => u.user_seq === user.user_seq);
|
||
|
if (userIndex !== -1) {
|
||
|
users[userIndex] = updatedUser;
|
||
|
localStorage.setItem('users', JSON.stringify(users));
|
||
|
}
|
||
|
|
||
|
onUserUpdate(updatedUser);
|
||
|
setIsEditing(false);
|
||
|
};
|
||
|
|
||
|
return (
|
||
|
<div className="bg-white rounded-lg shadow p-6">
|
||
|
<div className="flex justify-between items-center mb-4">
|
||
|
<h3 className="text-lg font-semibold text-gray-900">내 투자 정보</h3>
|
||
|
<button onClick={onLogout} className="text-sm text-gray-600 hover:text-gray-800">
|
||
|
로그아웃
|
||
|
</button>
|
||
|
</div>
|
||
|
|
||
|
<div className="space-y-4">
|
||
|
<div>
|
||
|
<p className="text-sm text-gray-600">사용자</p>
|
||
|
<p className="font-medium">{user.nickname} ({user.email})</p>
|
||
|
</div>
|
||
|
|
||
|
<div>
|
||
|
<p className="text-sm text-gray-600">보유 현금</p>
|
||
|
{isEditing ? (
|
||
|
<input
|
||
|
type="number"
|
||
|
value={editData.cash_balance}
|
||
|
onChange={(e) => setEditData({...editData, cash_balance: parseFloat(e.target.value) || 0})}
|
||
|
disabled={user.invest_run_flag}
|
||
|
className="w-full px-3 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
|
||
|
/>
|
||
|
) : (
|
||
|
<p className="font-medium text-blue-600">{formatPrice(user.cash_balance)}원</p>
|
||
|
)}
|
||
|
</div>
|
||
|
|
||
|
<div>
|
||
|
<p className="text-sm text-gray-600">투자 금액</p>
|
||
|
<p className="font-medium text-green-600">{formatPrice(user.invested_amount)}원</p>
|
||
|
</div>
|
||
|
|
||
|
<div>
|
||
|
<p className="text-sm text-gray-600">선택 투자상품</p>
|
||
|
{isEditing ? (
|
||
|
<select
|
||
|
value={editData.selected_invest_code}
|
||
|
onChange={(e) => setEditData({...editData, selected_invest_code: e.target.value})}
|
||
|
className="w-full px-3 py-1 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
|
>
|
||
|
{COIN_LIST.map((coin) => (
|
||
|
<option key={coin.code} value={coin.code}>
|
||
|
{coin.name} ({coin.symbol})
|
||
|
</option>
|
||
|
))}
|
||
|
</select>
|
||
|
) : (
|
||
|
<p className="font-medium">{selectedCoin?.name} ({selectedCoin?.symbol})</p>
|
||
|
)}
|
||
|
</div>
|
||
|
|
||
|
<div>
|
||
|
<p className="text-sm text-gray-600">투자 진행 상태</p>
|
||
|
{isEditing ? (
|
||
|
<label className="flex items-center space-x-2">
|
||
|
<input
|
||
|
type="checkbox"
|
||
|
checked={editData.invest_run_flag}
|
||
|
onChange={(e) => setEditData({...editData, invest_run_flag: e.target.checked})}
|
||
|
className="rounded"
|
||
|
/>
|
||
|
<span className="text-sm">투자 진행</span>
|
||
|
</label>
|
||
|
) : (
|
||
|
<p className={`font-medium ${user.invest_run_flag ? 'text-green-600' : 'text-red-600'}`}>
|
||
|
{user.invest_run_flag ? '투자 진행 중' : '투자 중지'}
|
||
|
</p>
|
||
|
)}
|
||
|
</div>
|
||
|
|
||
|
<div className="flex space-x-2">
|
||
|
{isEditing ? (
|
||
|
<>
|
||
|
<button
|
||
|
onClick={handleSave}
|
||
|
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded hover:bg-blue-700"
|
||
|
>
|
||
|
저장
|
||
|
</button>
|
||
|
<button
|
||
|
onClick={() => {
|
||
|
setEditData({
|
||
|
cash_balance: user.cash_balance,
|
||
|
selected_invest_code: user.selected_invest_code,
|
||
|
invest_run_flag: user.invest_run_flag
|
||
|
});
|
||
|
setIsEditing(false);
|
||
|
}}
|
||
|
className="flex-1 bg-gray-300 text-gray-700 py-2 px-4 rounded hover:bg-gray-400"
|
||
|
>
|
||
|
취소
|
||
|
</button>
|
||
|
</>
|
||
|
) : (
|
||
|
<button
|
||
|
onClick={() => setIsEditing(true)}
|
||
|
className="w-full bg-gray-600 text-white py-2 px-4 rounded hover:bg-gray-700"
|
||
|
>
|
||
|
정보 수정
|
||
|
</button>
|
||
|
)}
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
);
|
||
|
};
|
||
|
|
||
|
// 메인 앱 컴포넌트
|
||
|
const App = () => {
|
||
|
const [user, setUser] = useState(null);
|
||
|
const [showAuthModal, setShowAuthModal] = useState(false);
|
||
|
const [coinPrices, setCoinPrices] = useState({});
|
||
|
const [selectedCoins, setSelectedCoins] = useState(['KRW-BTC', 'KRW-ETH', 'KRW-SOL', 'KRW-XRP', 'KRW-ADA']);
|
||
|
|
||
|
// 로그인 상태 복원
|
||
|
useEffect(() => {
|
||
|
const savedUser = localStorage.getItem('currentUser');
|
||
|
if (savedUser) {
|
||
|
setUser(JSON.parse(savedUser));
|
||
|
}
|
||
|
}, []);
|
||
|
|
||
|
// 현재가 업데이트
|
||
|
useEffect(() => {
|
||
|
const updatePrices = async () => {
|
||
|
try {
|
||
|
const prices = await getUpbitCurrentPrice(selectedCoins);
|
||
|
const priceMap = {};
|
||
|
prices.forEach(price => {
|
||
|
priceMap[price.market] = price.trade_price;
|
||
|
});
|
||
|
setCoinPrices(priceMap);
|
||
|
} catch (error) {
|
||
|
console.error('가격 업데이트 실패:', error);
|
||
|
}
|
||
|
};
|
||
|
|
||
|
updatePrices();
|
||
|
const interval = setInterval(updatePrices, 5000);
|
||
|
return () => clearInterval(interval);
|
||
|
}, [selectedCoins]);
|
||
|
|
||
|
const handleLogin = (loggedInUser) => {
|
||
|
setUser(loggedInUser);
|
||
|
localStorage.setItem('currentUser', JSON.stringify(loggedInUser));
|
||
|
setShowAuthModal(false);
|
||
|
};
|
||
|
|
||
|
const handleLogout = () => {
|
||
|
setUser(null);
|
||
|
localStorage.removeItem('currentUser');
|
||
|
};
|
||
|
|
||
|
const handleUserUpdate = (updatedUser) => {
|
||
|
setUser(updatedUser);
|
||
|
localStorage.setItem('currentUser', JSON.stringify(updatedUser));
|
||
|
};
|
||
|
|
||
|
const formatPrice = (price) => new Intl.NumberFormat('ko-KR').format(price);
|
||
|
const getCoinName = (code) => COIN_LIST.find(coin => coin.code === code)?.name || code;
|
||
|
|
||
|
return (
|
||
|
<div className="min-h-screen bg-gray-100">
|
||
|
<header className="bg-white shadow-sm border-b">
|
||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||
|
<div className="flex justify-between items-center h-16">
|
||
|
<div className="flex items-center">
|
||
|
<h1 className="text-xl font-bold text-gray-900">AI 투자 시스템</h1>
|
||
|
</div>
|
||
|
|
||
|
<div className="flex items-center space-x-4">
|
||
|
<div className="hidden md:flex items-center space-x-4 text-sm">
|
||
|
{selectedCoins.slice(0, 3).map(coinCode => (
|
||
|
<div key={coinCode} className="text-center">
|
||
|
<div className="text-gray-600">{getCoinName(coinCode)}</div>
|
||
|
<div className="font-medium text-blue-600">
|
||
|
{coinPrices[coinCode] ? formatPrice(coinPrices[coinCode]) : '로딩중...'}
|
||
|
</div>
|
||
|
</div>
|
||
|
))}
|
||
|
</div>
|
||
|
|
||
|
{user ? (
|
||
|
<div className="text-sm text-gray-700">
|
||
|
안녕하세요, <span className="font-medium">{user.nickname}</span>님
|
||
|
</div>
|
||
|
) : (
|
||
|
<div className="flex space-x-2">
|
||
|
<button
|
||
|
onClick={() => setShowAuthModal(true)}
|
||
|
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
|
||
|
>
|
||
|
로그인
|
||
|
</button>
|
||
|
<button
|
||
|
onClick={() => setShowAuthModal(true)}
|
||
|
className="bg-gray-600 text-white px-4 py-2 rounded hover:bg-gray-700"
|
||
|
>
|
||
|
회원가입
|
||
|
</button>
|
||
|
</div>
|
||
|
)}
|
||
|
</div>
|
||
|
</div>
|
||
|
</div>
|
||
|
</header>
|
||
|
|
||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||
|
<div className="flex flex-col lg:flex-row gap-8">
|
||
|
<div className="flex-1">
|
||
|
<h2 className="text-2xl font-bold text-gray-900 mb-6">코인 차트</h2>
|
||
|
|
||
|
<div className="mb-6">
|
||
|
<div className="flex flex-wrap gap-2">
|
||
|
{COIN_LIST.map((coin) => (
|
||
|
<button
|
||
|
key={coin.code}
|
||
|
onClick={() => {
|
||
|
if (selectedCoins.includes(coin.code)) {
|
||
|
setSelectedCoins(selectedCoins.filter(c => c !== coin.code));
|
||
|
} else if (selectedCoins.length < 5) {
|
||
|
setSelectedCoins([...selectedCoins, coin.code]);
|
||
|
}
|
||
|
}}
|
||
|
className={`px-3 py-2 rounded text-sm ${
|
||
|
selectedCoins.includes(coin.code)
|
||
|
? 'bg-blue-500 text-white'
|
||
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
||
|
}`}
|
||
|
disabled={!selectedCoins.includes(coin.code) && selectedCoins.length >= 5}
|
||
|
>
|
||
|
{coin.name} ({coin.symbol})
|
||
|
{coinPrices[coin.code] && (
|
||
|
<div className="text-xs mt-1">
|
||
|
{formatPrice(coinPrices[coin.code])}원
|
||
|
</div>
|
||
|
)}
|
||
|
</button>
|
||
|
))}
|
||
|
</div>
|
||
|
<p className="text-sm text-gray-600 mt-2">
|
||
|
최대 5개의 코인을 선택할 수 있습니다. (현재 {selectedCoins.length}/5)
|
||
|
</p>
|
||
|
</div>
|
||
|
|
||
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||
|
{selectedCoins.map((coinCode) => (
|
||
|
<CoinChart
|
||
|
key={coinCode}
|
||
|
coinCode={coinCode}
|
||
|
coinName={getCoinName(coinCode)}
|
||
|
currentPrice={coinPrices[coinCode]}
|
||
|
/>
|
||
|
))}
|
||
|
</div>
|
||
|
</div>
|
||
|
|
||
|
{user && (
|
||
|
<div className="lg:w-80">
|
||
|
<UserPanel
|
||
|
user={user}
|
||
|
onUserUpdate={handleUserUpdate}
|
||
|
onLogout={handleLogout}
|
||
|
/>
|
||
|
</div>
|
||
|
)}
|
||
|
</div>
|
||
|
</main>
|
||
|
|
||
|
<AuthModal
|
||
|
isOpen={showAuthModal}
|
||
|
onClose={() => setShowAuthModal(false)}
|
||
|
onLogin={handleLogin}
|
||
|
/>
|
||
|
</div>
|
||
|
);
|
||
|
};
|
||
|
|
||
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||
|
</script>
|
||
|
</body>
|
||
|
</html>
|