fix: 지갑 UI 필드명/응답 구조 불일치 수정
서버 실제 응답과 프론트엔드 필드명이 맞지 않아 공개키 미표시, 인벤토리/마켓 TypeError 발생하던 버그 수정. - WalletTab: public_key→pubKeyHex, private_key→privateKey - InventoryTab: 배열→객체(slots) 구조 대응 - AssetsTab: 페이지네이션 응답, tradeable, active_listing_id, template_id - MarketTab: ID 목록→개별 getListing 호출, listing_id→id, asset_id - WalletSummary: 페이지네이션 + 인벤토리 객체 구조 대응 - chain.js: getListing() 함수 추가 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -51,6 +51,10 @@ export async function getMarketListings() {
|
|||||||
return apiFetch('/api/chain/market');
|
return apiFetch('/api/chain/market');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getListing(id) {
|
||||||
|
return apiFetch(`/api/chain/market/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
function idempotentPost(path, body) {
|
function idempotentPost(path, body) {
|
||||||
return apiFetch(path, {
|
return apiFetch(path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ export default function AssetsTab() {
|
|||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
getAssets()
|
getAssets()
|
||||||
.then((ids) => {
|
.then((result) => {
|
||||||
if (!ids || ids.length === 0) {
|
const ids = result?.ids || result;
|
||||||
|
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||||
setAssets([]);
|
setAssets([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -49,7 +50,7 @@ export default function AssetsTab() {
|
|||||||
await listOnMarket(assetId, price);
|
await listOnMarket(assetId, price);
|
||||||
toast.success('마켓에 등록되었습니다.');
|
toast.success('마켓에 등록되었습니다.');
|
||||||
setAssets((prev) =>
|
setAssets((prev) =>
|
||||||
prev.map((a) => (a.id === assetId ? { ...a, listed: true } : a))
|
prev.map((a) => (a.id === assetId ? { ...a, active_listing_id: 'pending' } : a))
|
||||||
);
|
);
|
||||||
setListingAsset(null);
|
setListingAsset(null);
|
||||||
setListingPrice('');
|
setListingPrice('');
|
||||||
@@ -65,94 +66,94 @@ export default function AssetsTab() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{assets.map((asset) => (
|
{assets.map((asset) => {
|
||||||
<div key={asset.id} className="asset-item">
|
const isListed = !!asset.active_listing_id;
|
||||||
<div className="asset-header" onClick={() => toggleExpand(asset.id)}>
|
return (
|
||||||
<div>
|
<div key={asset.id} className="asset-item">
|
||||||
{asset._loaded ? (
|
<div className="asset-header" onClick={() => toggleExpand(asset.id)}>
|
||||||
<>
|
<div>
|
||||||
<strong style={{ color: '#fff' }}>{asset.item_name || asset.template_name || '자산'}</strong>
|
{asset._loaded ? (
|
||||||
{asset.template_name && asset.item_name !== asset.template_name && (
|
<>
|
||||||
<span style={{ marginLeft: 8, fontSize: '0.8rem', opacity: 0.5 }}>{asset.template_name}</span>
|
<strong style={{ color: '#fff' }}>{asset.template_id || '자산'}</strong>
|
||||||
)}
|
<span style={{ marginLeft: 12, fontSize: '0.8rem', opacity: 0.4 }}>#{asset.id}</span>
|
||||||
<span style={{ marginLeft: 12, fontSize: '0.8rem', opacity: 0.4 }}>#{asset.id}</span>
|
</>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span style={{ color: '#e57373' }}>로드 실패 (#{asset.id})</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{asset._loaded && (
|
|
||||||
<span className={`wallet-tag ${asset.tradable ? 'wallet-tag-ok' : 'wallet-tag-no'}`}>
|
|
||||||
{asset.tradable ? '거래 가능' : '거래 불가'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded === asset.id && asset._loaded && (
|
|
||||||
<div className="asset-detail">
|
|
||||||
{/* 속성 */}
|
|
||||||
{asset.properties && Object.keys(asset.properties).length > 0 && (
|
|
||||||
<div className="asset-props">
|
|
||||||
{Object.entries(asset.properties).map(([k, v]) => (
|
|
||||||
<Fragment key={k}>
|
|
||||||
<span className="asset-prop-key">{k}</span>
|
|
||||||
<span className="asset-prop-val">{String(v)}</span>
|
|
||||||
</Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 마켓 등록 상태 */}
|
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
{asset.listed ? (
|
|
||||||
<span className="wallet-tag wallet-tag-listed">마켓 등록됨</span>
|
|
||||||
) : (
|
) : (
|
||||||
<span className="wallet-tag wallet-tag-no">미등록</span>
|
<span style={{ color: '#e57373' }}>로드 실패 (#{asset.id})</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{asset._loaded && (
|
||||||
{/* 마켓 등록 버튼 */}
|
<span className={`wallet-tag ${asset.tradeable ? 'wallet-tag-ok' : 'wallet-tag-no'}`}>
|
||||||
{!asset.listed && asset.tradable && (
|
{asset.tradeable ? '거래 가능' : '거래 불가'}
|
||||||
<div style={{ marginTop: 12 }}>
|
</span>
|
||||||
{listingAsset === asset.id ? (
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
className="wallet-input"
|
|
||||||
placeholder="가격 (TOL)"
|
|
||||||
value={listingPrice}
|
|
||||||
onChange={(e) => setListingPrice(e.target.value)}
|
|
||||||
min="1"
|
|
||||||
style={{ width: 140 }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
className="btn-primary"
|
|
||||||
disabled={submitting || !listingPrice}
|
|
||||||
onClick={() => handleList(asset.id)}
|
|
||||||
>
|
|
||||||
{submitting ? '처리 중...' : '등록'}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn-copy"
|
|
||||||
onClick={() => { setListingAsset(null); setListingPrice(''); }}
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="btn-primary"
|
|
||||||
onClick={(e) => { e.stopPropagation(); setListingAsset(asset.id); }}
|
|
||||||
>
|
|
||||||
마켓에 등록
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{expanded === asset.id && asset._loaded && (
|
||||||
))}
|
<div className="asset-detail">
|
||||||
|
{/* 속성 */}
|
||||||
|
{asset.properties && Object.keys(asset.properties).length > 0 && (
|
||||||
|
<div className="asset-props">
|
||||||
|
{Object.entries(asset.properties).map(([k, v]) => (
|
||||||
|
<Fragment key={k}>
|
||||||
|
<span className="asset-prop-key">{k}</span>
|
||||||
|
<span className="asset-prop-val">{String(v)}</span>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 마켓 등록 상태 */}
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
{isListed ? (
|
||||||
|
<span className="wallet-tag wallet-tag-listed">마켓 등록됨</span>
|
||||||
|
) : (
|
||||||
|
<span className="wallet-tag wallet-tag-no">미등록</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 마켓 등록 버튼 */}
|
||||||
|
{!isListed && asset.tradeable && (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
|
{listingAsset === asset.id ? (
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="wallet-input"
|
||||||
|
placeholder="가격 (TOL)"
|
||||||
|
value={listingPrice}
|
||||||
|
onChange={(e) => setListingPrice(e.target.value)}
|
||||||
|
min="1"
|
||||||
|
style={{ width: 140 }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
disabled={submitting || !listingPrice}
|
||||||
|
onClick={() => handleList(asset.id)}
|
||||||
|
>
|
||||||
|
{submitting ? '처리 중...' : '등록'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-copy"
|
||||||
|
onClick={() => { setListingAsset(null); setListingPrice(''); }}
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setListingAsset(asset.id); }}
|
||||||
|
>
|
||||||
|
마켓에 등록
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,32 +4,36 @@ import { useToast } from '../toast/useToast';
|
|||||||
|
|
||||||
export default function InventoryTab() {
|
export default function InventoryTab() {
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [slots, setSlots] = useState([]);
|
const [inventory, setInventory] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getInventory()
|
getInventory()
|
||||||
.then((data) => { if (!cancelled) setSlots(data); })
|
.then((data) => { if (!cancelled) setInventory(data); })
|
||||||
.catch(() => { if (!cancelled) toast.error('인벤토리를 불러오지 못했습니다.'); })
|
.catch(() => { if (!cancelled) toast.error('인벤토리를 불러오지 못했습니다.'); })
|
||||||
.finally(() => { if (!cancelled) setLoading(false); });
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
if (loading) return <div className="wallet-spinner">불러오는 중...</div>;
|
if (loading) return <div className="wallet-spinner">불러오는 중...</div>;
|
||||||
if (!slots || slots.length === 0) return <div className="wallet-empty">인벤토리가 비어있습니다</div>;
|
|
||||||
|
const slots = inventory?.slots;
|
||||||
|
const entries = slots ? Object.entries(slots) : [];
|
||||||
|
|
||||||
|
if (entries.length === 0) return <div className="wallet-empty">인벤토리가 비어있습니다</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{slots.map((slot, i) => {
|
{entries.map(([slotName, assetId]) => {
|
||||||
const isEmpty = !slot.item_name && !slot.item_id;
|
const isEmpty = !assetId;
|
||||||
return (
|
return (
|
||||||
<div key={slot.slot_name || i} className={`inv-slot${isEmpty ? ' inv-slot-empty' : ''}`}>
|
<div key={slotName} className={`inv-slot${isEmpty ? ' inv-slot-empty' : ''}`}>
|
||||||
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: '0.85rem' }}>
|
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: '0.85rem' }}>
|
||||||
{slot.slot_name}
|
{slotName}
|
||||||
</span>
|
</span>
|
||||||
<span style={{ color: isEmpty ? 'rgba(255,255,255,0.25)' : '#fff', fontSize: '0.9rem' }}>
|
<span style={{ color: isEmpty ? 'rgba(255,255,255,0.25)' : '#fff', fontSize: '0.9rem' }}>
|
||||||
{isEmpty ? '비어있음' : `${slot.item_name} #${slot.item_id}`}
|
{isEmpty ? '비어있음' : assetId}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { getMarketListings, getWallet, buyFromMarket, cancelListing } from '../../api/chain';
|
import { getMarketListings, getListing, getWallet, buyFromMarket, cancelListing } from '../../api/chain';
|
||||||
import { useToast } from '../toast/useToast';
|
import { useToast } from '../toast/useToast';
|
||||||
import { useConfirm } from '../confirm/useConfirm';
|
import { useConfirm } from '../confirm/useConfirm';
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export default function MarketTab() {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const [listings, setListings] = useState([]);
|
const [listings, setListings] = useState([]);
|
||||||
const [myAddress, setMyAddress] = useState('');
|
const [myPubKey, setMyPubKey] = useState('');
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filter, setFilter] = useState('all');
|
const [filter, setFilter] = useState('all');
|
||||||
const [processing, setProcessing] = useState(null);
|
const [processing, setProcessing] = useState(null);
|
||||||
@@ -20,9 +20,20 @@ export default function MarketTab() {
|
|||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
Promise.all([getMarketListings(), getWallet()])
|
Promise.all([getMarketListings(), getWallet()])
|
||||||
.then(([l, w]) => {
|
.then(([result, w]) => {
|
||||||
setListings(l || []);
|
const ids = result?.ids || result;
|
||||||
setMyAddress(w?.address || '');
|
setMyPubKey(w?.pubKeyHex || '');
|
||||||
|
if (!ids || !Array.isArray(ids) || ids.length === 0) {
|
||||||
|
setListings([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return Promise.all(
|
||||||
|
ids.map((id) =>
|
||||||
|
getListing(id)
|
||||||
|
.then((data) => ({ ...data, _loaded: true }))
|
||||||
|
.catch(() => ({ id, _loaded: false }))
|
||||||
|
)
|
||||||
|
).then((items) => setListings(items.filter((l) => l._loaded)));
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
toast.error('마켓 정보를 불러오지 못했습니다.');
|
toast.error('마켓 정보를 불러오지 못했습니다.');
|
||||||
@@ -34,12 +45,12 @@ export default function MarketTab() {
|
|||||||
|
|
||||||
const handleBuy = async (listing) => {
|
const handleBuy = async (listing) => {
|
||||||
const ok = await confirm(
|
const ok = await confirm(
|
||||||
`${listing.item_name || '아이템'}을(를) ${Number(listing.price).toLocaleString()} TOL에 구매하시겠습니까?`
|
`${listing.asset_id}을(를) ${Number(listing.price).toLocaleString()} TOL에 구매하시겠습니까?`
|
||||||
);
|
);
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setProcessing(listing.listing_id);
|
setProcessing(listing.id);
|
||||||
try {
|
try {
|
||||||
await buyFromMarket(listing.listing_id);
|
await buyFromMarket(listing.id);
|
||||||
toast.success('구매가 완료되었습니다.');
|
toast.success('구매가 완료되었습니다.');
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -52,9 +63,9 @@ export default function MarketTab() {
|
|||||||
const handleCancel = async (listing) => {
|
const handleCancel = async (listing) => {
|
||||||
const ok = await confirm('리스팅을 취소하시겠습니까?');
|
const ok = await confirm('리스팅을 취소하시겠습니까?');
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
setProcessing(listing.listing_id);
|
setProcessing(listing.id);
|
||||||
try {
|
try {
|
||||||
await cancelListing(listing.listing_id);
|
await cancelListing(listing.id);
|
||||||
toast.success('리스팅이 취소되었습니다.');
|
toast.success('리스팅이 취소되었습니다.');
|
||||||
load();
|
load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -67,7 +78,7 @@ export default function MarketTab() {
|
|||||||
if (loading) return <div className="wallet-spinner">불러오는 중...</div>;
|
if (loading) return <div className="wallet-spinner">불러오는 중...</div>;
|
||||||
|
|
||||||
const filtered = filter === 'mine'
|
const filtered = filter === 'mine'
|
||||||
? listings.filter((l) => l.seller === myAddress)
|
? listings.filter((l) => l.seller === myPubKey)
|
||||||
: listings;
|
: listings;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -93,11 +104,11 @@ export default function MarketTab() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((listing) => {
|
filtered.map((listing) => {
|
||||||
const isMine = listing.seller === myAddress;
|
const isMine = listing.seller === myPubKey;
|
||||||
return (
|
return (
|
||||||
<div key={listing.listing_id} className="market-item">
|
<div key={listing.id} className="market-item">
|
||||||
<div>
|
<div>
|
||||||
<strong style={{ color: '#fff' }}>{listing.item_name || '아이템'}</strong>
|
<strong style={{ color: '#fff' }}>{listing.asset_id}</strong>
|
||||||
<span className="market-seller" style={{ marginLeft: 12 }}>
|
<span className="market-seller" style={{ marginLeft: 12 }}>
|
||||||
{truncateAddr(listing.seller)}
|
{truncateAddr(listing.seller)}
|
||||||
</span>
|
</span>
|
||||||
@@ -107,18 +118,18 @@ export default function MarketTab() {
|
|||||||
{isMine ? (
|
{isMine ? (
|
||||||
<button
|
<button
|
||||||
className="btn-danger-outline"
|
className="btn-danger-outline"
|
||||||
disabled={processing === listing.listing_id}
|
disabled={processing === listing.id}
|
||||||
onClick={() => handleCancel(listing)}
|
onClick={() => handleCancel(listing)}
|
||||||
>
|
>
|
||||||
{processing === listing.listing_id ? '처리 중...' : '취소'}
|
{processing === listing.id ? '처리 중...' : '취소'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
disabled={processing === listing.listing_id}
|
disabled={processing === listing.id}
|
||||||
onClick={() => handleBuy(listing)}
|
onClick={() => handleBuy(listing)}
|
||||||
>
|
>
|
||||||
{processing === listing.listing_id ? '처리 중...' : '구매'}
|
{processing === listing.id ? '처리 중...' : '구매'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,9 +17,11 @@ export default function WalletSummary() {
|
|||||||
getInventory().catch(() => null),
|
getInventory().catch(() => null),
|
||||||
]).then(([b, assets, inv]) => {
|
]).then(([b, assets, inv]) => {
|
||||||
setBalance(b?.balance != null ? b.balance : null);
|
setBalance(b?.balance != null ? b.balance : null);
|
||||||
setAssetCount(assets != null ? assets.length : null);
|
const ids = assets?.ids || assets;
|
||||||
|
setAssetCount(Array.isArray(ids) ? ids.length : null);
|
||||||
|
const slots = inv?.slots;
|
||||||
setEquippedCount(
|
setEquippedCount(
|
||||||
inv != null ? inv.filter((s) => s.item_name || s.item_id).length : null
|
slots != null ? Object.values(slots).filter(Boolean).length : null
|
||||||
);
|
);
|
||||||
}).finally(() => setLoading(false));
|
}).finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function WalletTab() {
|
|||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
const data = await exportWalletKey(password);
|
const data = await exportWalletKey(password);
|
||||||
setPrivateKey(data.private_key);
|
setPrivateKey(data.privateKey);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 401) {
|
if (err.status === 401) {
|
||||||
setExportError(err.message);
|
setExportError(err.message);
|
||||||
@@ -90,8 +90,8 @@ export default function WalletTab() {
|
|||||||
<div style={{ marginBottom: 12 }}>
|
<div style={{ marginBottom: 12 }}>
|
||||||
<div className="wallet-label">공개키</div>
|
<div className="wallet-label">공개키</div>
|
||||||
<div className="wallet-row">
|
<div className="wallet-row">
|
||||||
<span className="wallet-mono">{truncate(wallet.public_key, 4, 4)}</span>
|
<span className="wallet-mono">{truncate(wallet.pubKeyHex, 4, 4)}</span>
|
||||||
<button className="btn-copy" onClick={() => copyToClipboard(wallet.public_key, '공개키')}>
|
<button className="btn-copy" onClick={() => copyToClipboard(wallet.pubKeyHex, '공개키')}>
|
||||||
복사
|
복사
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user