fix: 지갑 UI 필드명/응답 구조 불일치 수정
All checks were successful
Client CI/CD / test (push) Successful in 9s
Client CI/CD / deploy (push) Successful in 13s

서버 실제 응답과 프론트엔드 필드명이 맞지 않아 공개키 미표시,
인벤토리/마켓 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:
2026-03-23 14:41:07 +09:00
parent 60be6e1d39
commit 1691638fe9
6 changed files with 139 additions and 117 deletions

View File

@@ -51,6 +51,10 @@ export async function getMarketListings() {
return apiFetch('/api/chain/market');
}
export async function getListing(id) {
return apiFetch(`/api/chain/market/${id}`);
}
function idempotentPost(path, body) {
return apiFetch(path, {
method: 'POST',

View File

@@ -14,8 +14,9 @@ export default function AssetsTab() {
const load = useCallback(() => {
setLoading(true);
getAssets()
.then((ids) => {
if (!ids || ids.length === 0) {
.then((result) => {
const ids = result?.ids || result;
if (!ids || !Array.isArray(ids) || ids.length === 0) {
setAssets([]);
return;
}
@@ -49,7 +50,7 @@ export default function AssetsTab() {
await listOnMarket(assetId, price);
toast.success('마켓에 등록되었습니다.');
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);
setListingPrice('');
@@ -65,16 +66,15 @@ export default function AssetsTab() {
return (
<div>
{assets.map((asset) => (
{assets.map((asset) => {
const isListed = !!asset.active_listing_id;
return (
<div key={asset.id} className="asset-item">
<div className="asset-header" onClick={() => toggleExpand(asset.id)}>
<div>
{asset._loaded ? (
<>
<strong style={{ color: '#fff' }}>{asset.item_name || asset.template_name || '자산'}</strong>
{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>
</>
) : (
@@ -82,8 +82,8 @@ export default function AssetsTab() {
)}
</div>
{asset._loaded && (
<span className={`wallet-tag ${asset.tradable ? 'wallet-tag-ok' : 'wallet-tag-no'}`}>
{asset.tradable ? '거래 가능' : '거래 불가'}
<span className={`wallet-tag ${asset.tradeable ? 'wallet-tag-ok' : 'wallet-tag-no'}`}>
{asset.tradeable ? '거래 가능' : '거래 불가'}
</span>
)}
</div>
@@ -104,7 +104,7 @@ export default function AssetsTab() {
{/* 마켓 등록 상태 */}
<div style={{ marginTop: 12 }}>
{asset.listed ? (
{isListed ? (
<span className="wallet-tag wallet-tag-listed">마켓 등록됨</span>
) : (
<span className="wallet-tag wallet-tag-no">미등록</span>
@@ -112,7 +112,7 @@ export default function AssetsTab() {
</div>
{/* 마켓 등록 버튼 */}
{!asset.listed && asset.tradable && (
{!isListed && asset.tradeable && (
<div style={{ marginTop: 12 }}>
{listingAsset === asset.id ? (
<div style={{ display: 'flex', gap: 8 }}>
@@ -152,7 +152,8 @@ export default function AssetsTab() {
</div>
)}
</div>
))}
);
})}
</div>
);
}

View File

@@ -4,32 +4,36 @@ import { useToast } from '../toast/useToast';
export default function InventoryTab() {
const toast = useToast();
const [slots, setSlots] = useState([]);
const [inventory, setInventory] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
getInventory()
.then((data) => { if (!cancelled) setSlots(data); })
.then((data) => { if (!cancelled) setInventory(data); })
.catch(() => { if (!cancelled) toast.error('인벤토리를 불러오지 못했습니다.'); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
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 (
<div>
{slots.map((slot, i) => {
const isEmpty = !slot.item_name && !slot.item_id;
{entries.map(([slotName, assetId]) => {
const isEmpty = !assetId;
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' }}>
{slot.slot_name}
{slotName}
</span>
<span style={{ color: isEmpty ? 'rgba(255,255,255,0.25)' : '#fff', fontSize: '0.9rem' }}>
{isEmpty ? '비어있음' : `${slot.item_name} #${slot.item_id}`}
{isEmpty ? '비어있음' : assetId}
</span>
</div>
);

View File

@@ -1,5 +1,5 @@
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 { useConfirm } from '../confirm/useConfirm';
@@ -12,7 +12,7 @@ export default function MarketTab() {
const toast = useToast();
const confirm = useConfirm();
const [listings, setListings] = useState([]);
const [myAddress, setMyAddress] = useState('');
const [myPubKey, setMyPubKey] = useState('');
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('all');
const [processing, setProcessing] = useState(null);
@@ -20,9 +20,20 @@ export default function MarketTab() {
const load = useCallback(() => {
setLoading(true);
Promise.all([getMarketListings(), getWallet()])
.then(([l, w]) => {
setListings(l || []);
setMyAddress(w?.address || '');
.then(([result, w]) => {
const ids = result?.ids || result;
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(() => {
toast.error('마켓 정보를 불러오지 못했습니다.');
@@ -34,12 +45,12 @@ export default function MarketTab() {
const handleBuy = async (listing) => {
const ok = await confirm(
`${listing.item_name || '아이템'}을(를) ${Number(listing.price).toLocaleString()} TOL에 구매하시겠습니까?`
`${listing.asset_id}을(를) ${Number(listing.price).toLocaleString()} TOL에 구매하시겠습니까?`
);
if (!ok) return;
setProcessing(listing.listing_id);
setProcessing(listing.id);
try {
await buyFromMarket(listing.listing_id);
await buyFromMarket(listing.id);
toast.success('구매가 완료되었습니다.');
load();
} catch (err) {
@@ -52,9 +63,9 @@ export default function MarketTab() {
const handleCancel = async (listing) => {
const ok = await confirm('리스팅을 취소하시겠습니까?');
if (!ok) return;
setProcessing(listing.listing_id);
setProcessing(listing.id);
try {
await cancelListing(listing.listing_id);
await cancelListing(listing.id);
toast.success('리스팅이 취소되었습니다.');
load();
} catch (err) {
@@ -67,7 +78,7 @@ export default function MarketTab() {
if (loading) return <div className="wallet-spinner">불러오는 ...</div>;
const filtered = filter === 'mine'
? listings.filter((l) => l.seller === myAddress)
? listings.filter((l) => l.seller === myPubKey)
: listings;
return (
@@ -93,11 +104,11 @@ export default function MarketTab() {
</div>
) : (
filtered.map((listing) => {
const isMine = listing.seller === myAddress;
const isMine = listing.seller === myPubKey;
return (
<div key={listing.listing_id} className="market-item">
<div key={listing.id} className="market-item">
<div>
<strong style={{ color: '#fff' }}>{listing.item_name || '아이템'}</strong>
<strong style={{ color: '#fff' }}>{listing.asset_id}</strong>
<span className="market-seller" style={{ marginLeft: 12 }}>
{truncateAddr(listing.seller)}
</span>
@@ -107,18 +118,18 @@ export default function MarketTab() {
{isMine ? (
<button
className="btn-danger-outline"
disabled={processing === listing.listing_id}
disabled={processing === listing.id}
onClick={() => handleCancel(listing)}
>
{processing === listing.listing_id ? '처리 중...' : '취소'}
{processing === listing.id ? '처리 중...' : '취소'}
</button>
) : (
<button
className="btn-primary"
disabled={processing === listing.listing_id}
disabled={processing === listing.id}
onClick={() => handleBuy(listing)}
>
{processing === listing.listing_id ? '처리 중...' : '구매'}
{processing === listing.id ? '처리 중...' : '구매'}
</button>
)}
</div>

View File

@@ -17,9 +17,11 @@ export default function WalletSummary() {
getInventory().catch(() => null),
]).then(([b, assets, inv]) => {
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(
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));
}, []);

View File

@@ -58,7 +58,7 @@ export default function WalletTab() {
setExporting(true);
try {
const data = await exportWalletKey(password);
setPrivateKey(data.private_key);
setPrivateKey(data.privateKey);
} catch (err) {
if (err.status === 401) {
setExportError(err.message);
@@ -90,8 +90,8 @@ export default function WalletTab() {
<div style={{ marginBottom: 12 }}>
<div className="wallet-label">공개키</div>
<div className="wallet-row">
<span className="wallet-mono">{truncate(wallet.public_key, 4, 4)}</span>
<button className="btn-copy" onClick={() => copyToClipboard(wallet.public_key, '공개키')}>
<span className="wallet-mono">{truncate(wallet.pubKeyHex, 4, 4)}</span>
<button className="btn-copy" onClick={() => copyToClipboard(wallet.pubKeyHex, '공개키')}>
복사
</button>
</div>