319 lines
11 KiB
JavaScript
319 lines
11 KiB
JavaScript
// 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';
|
|
});
|