step 6.2: done

This commit is contained in:
bepis
2026-10-21 00:59:57 +11:00
parent ba78d8b098
commit 2930c70010
5 changed files with 563 additions and 12 deletions
+318
View File
@@ -0,0 +1,318 @@
// Shop page: buy and sell items.
let shopCache = null;
let currentTab = 'buy';
// ─── API helpers ──────────────────────────────────────────────────────────
const api = {
get(path) { return fetch(`/api${path}`, { credentials: 'include' }).then(r => r.json()); },
post(path, body) {
return fetch(`/api${path}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(body),
}).then(r => r.json());
},
};
// ─── Display helpers ──────────────────────────────────────────────────────
const ITEM_EMOJI = { weapon: '⚔️', armor: '🛡️', trinket: '💍', consumable: '🧪', material: '📦' };
function formatBonuses(bonuses) {
const parts = [];
if (bonuses.bonus_attack) parts.push(`+${bonuses.bonus_attack} ATK`);
if (bonuses.bonus_defense) parts.push(`+${bonuses.bonus_defense} DEF`);
if (bonuses.bonus_str) parts.push(`+${bonuses.bonus_str} STR`);
if (bonuses.bonus_agi) parts.push(`+${bonuses.bonus_agi} AGI`);
if (bonuses.bonus_int) parts.push(`+${bonuses.bonus_int} INT`);
if (bonuses.bonus_vit) parts.push(`+${bonuses.bonus_vit} VIT`);
return parts.join(', ');
}
function formatEffect(item) {
if (!item.effect) return '';
const match = item.effect.match(/^(heal|energy):(\d+)$/);
if (!match) return '';
const [_full, type, value] = match;
return type === 'heal' ? `Heals ${value} HP` : `Restores ${value} Energy`;
}
// ─── Render: character info ──────────────────────────────────────────────
function renderCharacterInfo(character) {
document.getElementById('player-name').textContent = `${character.name} (Lv.${character.level})`;
document.getElementById('player-gold').textContent = `💰 ${character.gold}`;
}
// ─── Render: shop stock (buy tab) ────────────────────────────────────────
function renderShopStock(items) {
const container = document.getElementById('shop-stock');
container.innerHTML = '';
const purchasable = items.filter((i) => i.buyPrice > 0);
if (purchasable.length === 0) {
const p = document.createElement('p');
p.className = 'placeholder';
p.textContent = 'No items available.';
container.appendChild(p);
return;
}
for (const item of purchasable) {
const card = document.createElement('div');
card.className = 'shop-item';
const header = document.createElement('div');
header.className = 'shop-item-header';
const icon = document.createElement('span');
icon.className = 'shop-item-icon';
icon.textContent = ITEM_EMOJI[item.type] || '❓';
header.appendChild(icon);
const name = document.createElement('span');
name.className = `shop-item-name rarity-${item.rarity}`;
name.textContent = item.name;
header.appendChild(name);
const rarityBadge = document.createElement('span');
rarityBadge.className = `shop-item-rarity bg-rarity-${item.rarity}`;
rarityBadge.textContent = item.rarity;
header.appendChild(rarityBadge);
card.appendChild(header);
const stats = document.createElement('div');
stats.className = 'shop-item-stats';
stats.textContent = formatBonuses(item) || formatEffect(item) || '—';
card.appendChild(stats);
const meta = document.createElement('div');
meta.className = 'shop-item-meta';
const levelReq = document.createElement('span');
levelReq.className = item.levelOk ? '' : 'shop-item-locked';
levelReq.textContent = item.levelOk
? `Lv.${item.levelReq}`
: `Requires Lv.${item.levelReq}`;
meta.appendChild(levelReq);
const price = document.createElement('span');
price.className = 'shop-item-price';
price.textContent = `${item.buyPrice} G`;
meta.appendChild(price);
card.appendChild(meta);
const buyBtn = document.createElement('button');
buyBtn.className = 'btn btn-shop-buy';
buyBtn.textContent = 'Buy';
buyBtn.disabled = !item.levelOk || !item.canAfford;
buyBtn.dataset.itemId = item.itemId;
buyBtn.title = !item.levelOk
? `Requires level ${item.levelReq}`
: !item.canAfford
? `Need ${item.buyPrice} gold (have ${shopCache?.character?.gold ?? 0})`
: '';
card.appendChild(buyBtn);
container.appendChild(card);
}
container.querySelectorAll('.btn-shop-buy').forEach((btn) => {
btn.addEventListener('click', handleBuy);
});
}
// ─── Render: sell list (sell tab) ────────────────────────────────────────
function renderSellList(materials) {
const container = document.getElementById('sell-list');
container.innerHTML = '';
if (materials.length === 0) {
const p = document.createElement('p');
p.className = 'placeholder';
p.textContent = 'No materials to sell.';
container.appendChild(p);
return;
}
for (const item of materials) {
const card = document.createElement('div');
card.className = 'shop-item';
const header = document.createElement('div');
header.className = 'shop-item-header';
const icon = document.createElement('span');
icon.className = 'shop-item-icon';
icon.textContent = ITEM_EMOJI[item.type] || '❓';
header.appendChild(icon);
const name = document.createElement('span');
name.className = `shop-item-name rarity-${item.rarity}`;
name.textContent = item.name;
header.appendChild(name);
const rarityBadge = document.createElement('span');
rarityBadge.className = `shop-item-rarity bg-rarity-${item.rarity}`;
rarityBadge.textContent = item.rarity;
header.appendChild(rarityBadge);
card.appendChild(header);
const meta = document.createElement('div');
meta.className = 'shop-item-meta';
const sellPrice = document.createElement('span');
sellPrice.className = 'shop-item-sell-price';
sellPrice.textContent = `${item.sellPrice} G each`;
meta.appendChild(sellPrice);
const qty = document.createElement('span');
qty.className = 'shop-item-qty-label';
qty.textContent = `x${item.quantity}`;
meta.appendChild(qty);
card.appendChild(meta);
const sellBtn = document.createElement('button');
sellBtn.className = 'btn btn-shop-sell';
sellBtn.textContent = 'Sell';
sellBtn.dataset.itemId = item.itemId;
card.appendChild(sellBtn);
container.appendChild(card);
}
container.querySelectorAll('.btn-shop-sell').forEach((btn) => {
btn.addEventListener('click', handleSell);
});
}
// ─── Buy handler ─────────────────────────────────────────────────────────
async function handleBuy(e) {
const btn = e.target;
if (btn.disabled) return;
const itemId = Number(btn.dataset.itemId);
if (isNaN(itemId)) return;
const qtyInput = document.getElementById('buy-qty');
const qty = Math.max(1, Math.min(99, parseInt(qtyInput.value, 10) || 1));
btn.disabled = true;
btn.textContent = 'Buying...';
try {
const data = await api.post('/shop/buy', { itemId, quantity: qty });
if (!data || !data.ok) {
showToast(data?.error?.message || 'Purchase failed.', 'error');
return;
}
shopCache = data.data;
renderCharacterInfo(data.data.character);
renderShopStock(data.data.stock);
showToast(`Bought ${data.data.stock.find(i => i.itemId === itemId)?.name || 'item'}.`, 'info');
} catch (err) {
showToast('Purchase failed.', 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Buy';
}
}
// ─── Sell handler ────────────────────────────────────────────────────────
async function handleSell(e) {
const btn = e.target;
if (btn.disabled) return;
const itemId = Number(btn.dataset.itemId);
if (isNaN(itemId)) return;
btn.disabled = true;
btn.textContent = 'Selling...';
try {
const data = await api.post('/shop/sell', { itemId, quantity: 1 });
if (!data || !data.ok) {
showToast(data?.error?.message || 'Sale failed.', 'error');
return;
}
shopCache = data.data;
renderCharacterInfo(data.data.character);
renderSellList(data.data.stock.filter((i) => i.buyPrice === 0));
const item = data.data.stock.find((i) => i.itemId === itemId);
showToast(`Sold ${item?.name || 'item'}.`, 'info');
} catch (err) {
showToast('Sale failed.', 'error');
} finally {
btn.disabled = false;
btn.textContent = 'Sell';
}
}
// ─── Tab switching ───────────────────────────────────────────────────────
function switchTab(tab) {
currentTab = tab;
document.querySelectorAll('.tab').forEach((t) => {
const isActive = t.dataset.tab === tab;
t.classList.toggle('active', isActive);
t.setAttribute('aria-selected', isActive);
});
document.getElementById('buy-tab').style.display = tab === 'buy' ? '' : 'none';
document.getElementById('sell-tab').style.display = tab === 'sell' ? '' : 'none';
}
// ─── Toast notifications ─────────────────────────────────────────────────
function showToast(message, type = 'info') {
document.querySelectorAll('.toast').forEach((t) => t.remove());
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), 4000);
}
// ─── Init ────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
try {
const me = await api.get('/auth/me');
if (!me || !me.ok) { window.location.href = '/login.html'; return; }
if (!me.data.hasHero) { window.location.href = '/create-hero.html'; return; }
const shop = await api.get('/shop');
if (!shop || !shop.ok) {
showToast('Failed to load shop.', 'error');
return;
}
shopCache = shop.data;
renderCharacterInfo(shop.data.character);
renderShopStock(shop.data.stock);
renderSellList(shop.data.stock.filter((i) => i.buyPrice === 0));
} catch (_) {
window.location.href = '/login.html';
}
document.querySelectorAll('.tab').forEach((tab) => {
tab.addEventListener('click', () => switchTab(tab.dataset.tab));
});
});
// Logout handler
document.getElementById('logout-btn')?.addEventListener('click', async (e) => {
e.preventDefault();
try { await api.post('/auth/logout', {}); } catch (_) {}
window.location.href = '/login.html';
});
+52
View File
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Emberfall — Shop</title>
<link rel="stylesheet" href="/css/main.css">
</head>
<body>
<div class="game-container">
<!-- Top bar -->
<header class="top-bar">
<div class="logo">Emberfall</div>
<div class="user-info">
<span id="player-name"></span>
<span id="player-gold" class="gold-display"></span>
<a href="/game.html">Game</a>
<a href="/api/auth/logout" id="logout-btn">Logout</a>
</div>
</header>
<main class="main-content">
<!-- Shop panel -->
<section class="panel shop-panel" style="grid-column: 1 / -1;">
<h3>Shop</h3>
<div class="tabs" role="tablist">
<button class="tab active" data-tab="buy" role="tab" aria-selected="true">Buy</button>
<button class="tab" data-tab="sell" role="tab" aria-selected="false">Sell</button>
</div>
<!-- Buy tab -->
<div id="buy-tab">
<div id="shop-stock" class="shop-stock">
<p class="loading">Loading shop...</p>
</div>
</div>
<!-- Sell tab -->
<div id="sell-tab" style="display:none;">
<div id="sell-list" class="sell-list">
<p class="loading">Loading inventory...</p>
</div>
</div>
</section>
</main>
</div>
<script src="/js/auth.js"></script>
<script src="/js/shop.js"></script>
</body>
</html>