diff --git a/src/App.jsx b/src/App.jsx index be19e71..394410b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -9,6 +9,7 @@ import LoginPage from './pages/LoginPage'; import RegisterPage from './pages/RegisterPage'; import HomePage from './pages/HomePage'; import AdminPage from './pages/AdminPage'; +import WalletPage from './pages/WalletPage'; import SSAFYCallbackPage from './pages/SSAFYCallbackPage'; function AuthRedirect() { @@ -26,6 +27,12 @@ function AuthRedirect() { return null; } +function PrivateRoute({ children }) { + const { user } = useAuth(); + if (!user) return ; + return children; +} + function AdminRoute({ children }) { const { user } = useAuth(); if (!user) return ; @@ -43,6 +50,14 @@ function AppRoutes() { : } /> } /> } /> + + + + } + /> { + setLoading(true); + getAssets() + .then((ids) => { + if (!ids || ids.length === 0) { + setAssets([]); + return; + } + return Promise.all( + ids.map((id) => + getAsset(id) + .then((data) => ({ ...data, _loaded: true })) + .catch(() => ({ id, _loaded: false })) + ) + ).then(setAssets); + }) + .catch(() => { + toast.error('자산 목록을 불러오지 못했습니다.'); + }) + .finally(() => setLoading(false)); + }, [toast]); + + useEffect(() => { load(); }, [load]); + + const toggleExpand = (id) => { + setExpanded((prev) => (prev === id ? null : id)); + setListingAsset(null); + setListingPrice(''); + }; + + const handleList = async (assetId) => { + const price = Number(listingPrice); + if (!price || price <= 0) return; + setSubmitting(true); + try { + await listOnMarket(assetId, price); + toast.success('마켓에 등록되었습니다.'); + setAssets((prev) => + prev.map((a) => (a.id === assetId ? { ...a, listed: true } : a)) + ); + setListingAsset(null); + setListingPrice(''); + } catch (err) { + toast.error(err.message || '마켓 등록에 실패했습니다.'); + } finally { + setSubmitting(false); + } + }; + + if (loading) return
불러오는 중...
; + if (assets.length === 0) return
보유 자산이 없습니다
; + + return ( +
+ {assets.map((asset) => ( +
+
toggleExpand(asset.id)}> +
+ {asset._loaded ? ( + <> + {asset.item_name || asset.template_name || '자산'} + {asset.template_name && asset.item_name !== asset.template_name && ( + {asset.template_name} + )} + #{asset.id} + + ) : ( + 로드 실패 (#{asset.id}) + )} +
+ {asset._loaded && ( + + {asset.tradable ? '거래 가능' : '거래 불가'} + + )} +
+ + {expanded === asset.id && asset._loaded && ( +
+ {/* 속성 */} + {asset.properties && Object.keys(asset.properties).length > 0 && ( +
+ {Object.entries(asset.properties).map(([k, v]) => ( + + {k} + {String(v)} + + ))} +
+ )} + + {/* 마켓 등록 상태 */} +
+ {asset.listed ? ( + 마켓 등록됨 + ) : ( + 미등록 + )} +
+ + {/* 마켓 등록 버튼 */} + {!asset.listed && asset.tradable && ( +
+ {listingAsset === asset.id ? ( +
+ setListingPrice(e.target.value)} + min="1" + style={{ width: 140 }} + /> + + +
+ ) : ( + + )} +
+ )} +
+ )} +
+ ))} +
+ ); +} diff --git a/src/components/wallet/InventoryTab.jsx b/src/components/wallet/InventoryTab.jsx new file mode 100644 index 0000000..c49d407 --- /dev/null +++ b/src/components/wallet/InventoryTab.jsx @@ -0,0 +1,39 @@ +import { useState, useEffect } from 'react'; +import { getInventory } from '../../api/chain'; +import { useToast } from '../toast/useToast'; + +export default function InventoryTab() { + const toast = useToast(); + const [slots, setSlots] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + getInventory() + .then((data) => { if (!cancelled) setSlots(data); }) + .catch(() => { if (!cancelled) toast.error('인벤토리를 불러오지 못했습니다.'); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + if (loading) return
불러오는 중...
; + if (!slots || slots.length === 0) return
인벤토리가 비어있습니다
; + + return ( +
+ {slots.map((slot, i) => { + const isEmpty = !slot.item_name && !slot.item_id; + return ( +
+ + {slot.slot_name} + + + {isEmpty ? '비어있음' : `${slot.item_name} #${slot.item_id}`} + +
+ ); + })} +
+ ); +} diff --git a/src/components/wallet/MarketTab.jsx b/src/components/wallet/MarketTab.jsx new file mode 100644 index 0000000..b277701 --- /dev/null +++ b/src/components/wallet/MarketTab.jsx @@ -0,0 +1,131 @@ +import { useState, useEffect, useCallback } from 'react'; +import { getMarketListings, getWallet, buyFromMarket, cancelListing } from '../../api/chain'; +import { useToast } from '../toast/useToast'; +import { useConfirm } from '../confirm/useConfirm'; + +function truncateAddr(addr) { + if (!addr || addr.length <= 12) return addr || ''; + return `${addr.slice(0, 6)}...${addr.slice(-4)}`; +} + +export default function MarketTab() { + const toast = useToast(); + const confirm = useConfirm(); + const [listings, setListings] = useState([]); + const [myAddress, setMyAddress] = useState(''); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState('all'); + const [processing, setProcessing] = useState(null); + + const load = useCallback(() => { + setLoading(true); + Promise.all([getMarketListings(), getWallet()]) + .then(([l, w]) => { + setListings(l || []); + setMyAddress(w?.address || ''); + }) + .catch(() => { + toast.error('마켓 정보를 불러오지 못했습니다.'); + }) + .finally(() => setLoading(false)); + }, [toast]); + + useEffect(() => { load(); }, [load]); + + const handleBuy = async (listing) => { + const ok = await confirm( + `${listing.item_name || '아이템'}을(를) ${Number(listing.price).toLocaleString()} TOL에 구매하시겠습니까?` + ); + if (!ok) return; + setProcessing(listing.listing_id); + try { + await buyFromMarket(listing.listing_id); + toast.success('구매가 완료되었습니다.'); + load(); + } catch (err) { + toast.error(err.message || '구매에 실패했습니다.'); + } finally { + setProcessing(null); + } + }; + + const handleCancel = async (listing) => { + const ok = await confirm('리스팅을 취소하시겠습니까?'); + if (!ok) return; + setProcessing(listing.listing_id); + try { + await cancelListing(listing.listing_id); + toast.success('리스팅이 취소되었습니다.'); + load(); + } catch (err) { + toast.error(err.message || '취소에 실패했습니다.'); + } finally { + setProcessing(null); + } + }; + + if (loading) return
불러오는 중...
; + + const filtered = filter === 'mine' + ? listings.filter((l) => l.seller === myAddress) + : listings; + + return ( +
+
+ + +
+ + {filtered.length === 0 ? ( +
+ {filter === 'mine' ? '내 리스팅이 없습니다' : '등록된 리스팅이 없습니다'} +
+ ) : ( + filtered.map((listing) => { + const isMine = listing.seller === myAddress; + return ( +
+
+ {listing.item_name || '아이템'} + + {truncateAddr(listing.seller)} + +
+
+ {Number(listing.price).toLocaleString()} TOL + {isMine ? ( + + ) : ( + + )} +
+
+ ); + }) + )} +
+ ); +} diff --git a/src/components/wallet/WalletSummary.jsx b/src/components/wallet/WalletSummary.jsx new file mode 100644 index 0000000..3175aae --- /dev/null +++ b/src/components/wallet/WalletSummary.jsx @@ -0,0 +1,45 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { getBalance, getAssets, getInventory } from '../../api/chain'; +import '../../pages/WalletPage.css'; + +export default function WalletSummary() { + const navigate = useNavigate(); + const [balance, setBalance] = useState(null); + const [assetCount, setAssetCount] = useState(null); + const [equippedCount, setEquippedCount] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + Promise.all([ + getBalance().catch(() => null), + getAssets().catch(() => null), + getInventory().catch(() => null), + ]).then(([b, assets, inv]) => { + setBalance(b?.balance != null ? b.balance : null); + setAssetCount(assets != null ? assets.length : null); + setEquippedCount( + inv != null ? inv.filter((s) => s.item_name || s.item_id).length : null + ); + }).finally(() => setLoading(false)); + }, []); + + if (loading) return null; + + return ( +
navigate('/wallet')}> +

내 지갑

+

+ {balance != null ? `${Number(balance).toLocaleString()} TOL` : '-- TOL'} +

+
+ + 보유 자산 {assetCount != null ? assetCount : '--'} + + + 장착 아이템 {equippedCount != null ? equippedCount : '--'} + +
+
+ ); +} diff --git a/src/components/wallet/WalletTab.jsx b/src/components/wallet/WalletTab.jsx new file mode 100644 index 0000000..bf20a8d --- /dev/null +++ b/src/components/wallet/WalletTab.jsx @@ -0,0 +1,146 @@ +import { useState, useEffect, useCallback } from 'react'; +import { getBalance, getWallet, exportWalletKey } from '../../api/chain'; +import { useToast } from '../toast/useToast'; + +function truncate(str, startLen = 4, endLen = 4) { + if (!str || str.length <= startLen + endLen + 3) return str || ''; + return `${str.slice(0, startLen)}...${str.slice(-endLen)}`; +} + +export default function WalletTab() { + const toast = useToast(); + const [balance, setBalance] = useState(null); + const [wallet, setWallet] = useState(null); + const [loading, setLoading] = useState(true); + + const [password, setPassword] = useState(''); + const [privateKey, setPrivateKey] = useState(''); + const [exportError, setExportError] = useState(''); + const [exporting, setExporting] = useState(false); + + const load = useCallback(() => { + setLoading(true); + Promise.all([getBalance(), getWallet()]) + .then(([b, w]) => { + setBalance(b); + setWallet(w); + }) + .catch(() => { + toast.error('지갑 정보를 불러오지 못했습니다.'); + }) + .finally(() => setLoading(false)); + }, [toast]); + + useEffect(() => { load(); }, [load]); + + // 탭 이탈 시 개인키 초기화 + useEffect(() => { + return () => { + setPrivateKey(''); + setPassword(''); + setExportError(''); + }; + }, []); + + const copyToClipboard = async (text, label) => { + try { + await navigator.clipboard.writeText(text); + toast.success(`${label} 복사됨`); + } catch { + toast.error('복사에 실패했습니다.'); + } + }; + + const handleExport = async (e) => { + e.preventDefault(); + if (!password) return; + setExportError(''); + setExporting(true); + try { + const data = await exportWalletKey(password); + setPrivateKey(data.private_key); + } catch (err) { + if (err.status === 401) { + setExportError(err.message); + } else { + toast.error(err.message || '키 내보내기에 실패했습니다.'); + } + } finally { + setExporting(false); + } + }; + + if (loading) return
불러오는 중...
; + + return ( + <> + {/* 잔액 카드 */} +
+

TOL 잔액

+

+ {balance?.balance != null ? Number(balance.balance).toLocaleString() : '--'} +

+
+ + {/* 지갑 정보 */} + {wallet && ( +
+

지갑 정보

+ +
+
공개키
+
+ {truncate(wallet.public_key, 4, 4)} + +
+
+ +
+
주소
+
+ {truncate(wallet.address, 8, 8)} + +
+
+
+ )} + + {/* 키 내보내기 */} +
+

개인키 내보내기

+ + {!privateKey ? ( +
+ setPassword(e.target.value)} + style={{ flex: 1 }} + /> + +
+ ) : ( +
+
+ {privateKey} + +
+

개인키를 안전하게 보관하세요

+
+ )} + + {exportError &&

{exportError}

} +
+ + ); +} diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 878e438..63047f4 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -1,6 +1,7 @@ import { Link } from 'react-router-dom'; import { useAuth } from '../context/useAuth'; import DownloadSection from '../components/DownloadSection'; +import WalletSummary from '../components/wallet/WalletSummary'; import AnnouncementBoard from '../components/AnnouncementBoard'; import './HomePage.css'; @@ -15,6 +16,7 @@ export default function HomePage() { {user ? ( <> {user.username} + 지갑 {user.role === 'admin' && ( 관리자 )} @@ -34,6 +36,7 @@ export default function HomePage() {
+ {user && }
diff --git a/src/pages/WalletPage.css b/src/pages/WalletPage.css new file mode 100644 index 0000000..1132910 --- /dev/null +++ b/src/pages/WalletPage.css @@ -0,0 +1,470 @@ +.wallet-page { + min-height: 100vh; + background-color: #2E2C2F; +} + +.wallet-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 32px; + border-bottom: 1px solid rgba(186, 205, 176, 0.1); +} + +.wallet-header-left { + display: flex; + align-items: center; + gap: 20px; +} + +.wallet-home-link { + font-size: 0.85rem; + color: rgba(186, 205, 176, 0.6); + text-decoration: none; + transition: color 0.2s; +} + +.wallet-home-link:hover { + color: #BACDB0; +} + +.wallet-title { + font-size: 1.2rem; + font-weight: 700; + color: #BACDB0; + margin: 0; +} + +.wallet-header-right { + display: flex; + align-items: center; + gap: 16px; +} + +.wallet-username { + font-size: 0.9rem; + color: rgba(255, 255, 255, 0.6); +} + +.btn-wallet-logout { + padding: 8px 16px; + background: transparent; + color: rgba(186, 205, 176, 0.7); + border: 1px solid rgba(186, 205, 176, 0.25); + border-radius: 6px; + font-size: 0.8rem; + cursor: pointer; + transition: background 0.2s; +} + +.btn-wallet-logout:hover { + background: rgba(186, 205, 176, 0.08); +} + +.wallet-tabs { + display: flex; + gap: 4px; + padding: 16px 32px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.wallet-tab { + padding: 10px 24px; + background: transparent; + color: rgba(255, 255, 255, 0.45); + border: none; + border-bottom: 2px solid transparent; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: color 0.2s, border-color 0.2s; + margin-bottom: -1px; +} + +.wallet-tab:hover { + color: rgba(255, 255, 255, 0.75); +} + +.wallet-tab.active { + color: #BACDB0; + border-bottom-color: #BACDB0; +} + +.wallet-main { + max-width: 900px; + margin: 0 auto; + padding: 32px 24px 80px; +} + +/* === Tab content shared styles === */ + +.wallet-card { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(186, 205, 176, 0.12); + border-radius: 8px; + padding: 24px; + margin-bottom: 20px; +} + +.wallet-card-title { + font-size: 0.75rem; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.4); + letter-spacing: 0.08em; + margin: 0 0 12px; +} + +.wallet-balance { + font-size: 2rem; + font-weight: 700; + color: #BACDB0; + margin: 0; +} + +.wallet-mono { + font-family: monospace; + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.7); + word-break: break-all; +} + +.wallet-label { + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.4); + margin-bottom: 4px; +} + +.wallet-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.btn-copy { + padding: 4px 12px; + background: transparent; + color: rgba(186, 205, 176, 0.6); + border: 1px solid rgba(186, 205, 176, 0.2); + border-radius: 4px; + font-size: 0.75rem; + cursor: pointer; + transition: background 0.2s; + white-space: nowrap; +} + +.btn-copy:hover { + background: rgba(186, 205, 176, 0.08); +} + +.btn-primary { + padding: 8px 20px; + background: #BACDB0; + color: #2E2C2F; + border: none; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; +} + +.btn-primary:hover { + opacity: 0.9; +} + +.btn-primary:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.btn-danger-outline { + padding: 8px 20px; + background: transparent; + color: #e57373; + border: 1px solid rgba(229, 115, 115, 0.4); + border-radius: 6px; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.btn-danger-outline:hover { + background: rgba(229, 115, 115, 0.08); +} + +.wallet-input { + padding: 8px 12px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(186, 205, 176, 0.2); + border-radius: 6px; + color: #fff; + font-size: 0.85rem; + outline: none; + transition: border-color 0.2s; +} + +.wallet-input:focus { + border-color: rgba(186, 205, 176, 0.5); +} + +.wallet-input::placeholder { + color: rgba(255, 255, 255, 0.25); +} + +.wallet-spinner { + display: flex; + align-items: center; + justify-content: center; + padding: 60px 0; + color: rgba(255, 255, 255, 0.4); + font-size: 0.9rem; +} + +.wallet-empty { + text-align: center; + padding: 40px 0; + color: rgba(255, 255, 255, 0.3); + font-size: 0.9rem; +} + +.wallet-error-text { + color: #e57373; + font-size: 0.85rem; + margin-top: 8px; +} + +.wallet-warning { + color: #ffb74d; + font-size: 0.8rem; + margin-top: 8px; +} + +.wallet-tag { + display: inline-block; + padding: 2px 8px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; +} + +.wallet-tag-ok { + background: rgba(186, 205, 176, 0.15); + color: #BACDB0; +} + +.wallet-tag-no { + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.4); +} + +.wallet-tag-listed { + background: rgba(78, 168, 222, 0.15); + color: #4ea8de; +} + +/* Asset list */ +.asset-item { + border: 1px solid rgba(186, 205, 176, 0.1); + border-radius: 8px; + margin-bottom: 8px; + overflow: hidden; +} + +.asset-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + cursor: pointer; + transition: background 0.2s; +} + +.asset-header:hover { + background: rgba(255, 255, 255, 0.02); +} + +.asset-detail { + padding: 0 16px 16px; + border-top: 1px solid rgba(255, 255, 255, 0.04); +} + +.asset-props { + display: grid; + grid-template-columns: auto 1fr; + gap: 6px 16px; + font-size: 0.85rem; + margin: 12px 0; +} + +.asset-prop-key { + color: rgba(255, 255, 255, 0.4); +} + +.asset-prop-val { + color: rgba(255, 255, 255, 0.8); +} + +/* Inventory slots */ +.inv-slot { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border: 1px solid rgba(186, 205, 176, 0.1); + border-radius: 8px; + margin-bottom: 8px; +} + +.inv-slot-empty { + border-style: dashed; + color: rgba(255, 255, 255, 0.25); +} + +/* Market listings */ +.market-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border: 1px solid rgba(186, 205, 176, 0.1); + border-radius: 8px; + margin-bottom: 8px; +} + +.market-price { + font-weight: 600; + color: #BACDB0; +} + +.market-seller { + font-family: monospace; + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.4); +} + +.market-filter { + display: flex; + gap: 8px; + margin-bottom: 20px; +} + +.market-filter-btn { + padding: 6px 16px; + background: transparent; + color: rgba(255, 255, 255, 0.5); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + font-size: 0.8rem; + cursor: pointer; + transition: all 0.2s; +} + +.market-filter-btn.active { + color: #BACDB0; + border-color: rgba(186, 205, 176, 0.4); + background: rgba(186, 205, 176, 0.08); +} + +/* Wallet summary card (HomePage) */ +.wallet-summary { + background: rgba(186, 205, 176, 0.06); + border: 1px solid rgba(186, 205, 176, 0.15); + border-radius: 10px; + padding: 20px 24px; + margin-bottom: 32px; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; +} + +.wallet-summary:hover { + border-color: rgba(186, 205, 176, 0.3); + background: rgba(186, 205, 176, 0.09); +} + +.wallet-summary-title { + font-size: 0.75rem; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.4); + letter-spacing: 0.08em; + margin: 0 0 12px; +} + +.wallet-summary-balance { + font-size: 1.8rem; + font-weight: 700; + color: #BACDB0; + margin: 0; +} + +.wallet-summary-stats { + display: flex; + gap: 24px; + margin-top: 12px; +} + +.wallet-summary-stat { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.5); +} + +.wallet-summary-stat strong { + color: rgba(255, 255, 255, 0.8); +} + +/* Mobile responsive */ +@media (max-width: 768px) { + .wallet-header { + flex-direction: column; + gap: 12px; + padding: 12px 16px; + } + + .wallet-header-left { + width: 100%; + justify-content: center; + } + + .wallet-header-right { + width: 100%; + justify-content: center; + } + + .wallet-tabs { + padding: 12px 16px 0; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + + .wallet-tabs::-webkit-scrollbar { + display: none; + } + + .wallet-tab { + white-space: nowrap; + flex-shrink: 0; + min-height: 44px; + } + + .wallet-main { + padding: 20px 12px 60px; + } + + .wallet-row { + flex-direction: column; + align-items: stretch; + } + + .wallet-summary-stats { + flex-direction: column; + gap: 8px; + } + + .market-item { + flex-direction: column; + align-items: stretch; + gap: 8px; + } +} diff --git a/src/pages/WalletPage.jsx b/src/pages/WalletPage.jsx new file mode 100644 index 0000000..747b888 --- /dev/null +++ b/src/pages/WalletPage.jsx @@ -0,0 +1,58 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useAuth } from '../context/useAuth'; +import WalletTab from '../components/wallet/WalletTab'; +import AssetsTab from '../components/wallet/AssetsTab'; +import InventoryTab from '../components/wallet/InventoryTab'; +import MarketTab from '../components/wallet/MarketTab'; +import './WalletPage.css'; + +const TABS = [ + { key: 'wallet', label: '지갑' }, + { key: 'assets', label: '자산' }, + { key: 'inventory', label: '인벤토리' }, + { key: 'market', label: '마켓' }, +]; + +export default function WalletPage() { + const { user, logout } = useAuth(); + const [tab, setTab] = useState('wallet'); + + return ( +
+
+
+ ← 메인으로 +

지갑

+
+
+ {user?.username} + +
+
+ +
+ {TABS.map((t) => ( + + ))} +
+ +
+ {tab === 'wallet' && } + {tab === 'assets' && } + {tab === 'inventory' && } + {tab === 'market' && } +
+
+ ); +}