step 6.2: done
This commit is contained in:
@@ -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';
|
||||
});
|
||||
Reference in New Issue
Block a user