Phase 20: achievements tab and unlock toasts
New Achievements tab (tab-achievements) shows all 18 achievement cards in a responsive grid. Each card displays: icon, title, rarity badge (color- coded: common=grey, rare=blue, epic=purple), description, reward coins, and either a green "Unlocked <date>" label or a dimmed "Locked" state. Unlocked achievements sort first (newest-first); locked sort alphabetically after. Header nav button shows earned count badge next to "Achievements". Match result handler reads achievements_unlocked from the Core response and fires one toast per newly unlocked achievement immediately after match submission. Level-up toasts continue to work independently. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+66
-1
@@ -275,6 +275,7 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
<button onclick="showTab('market')">Market</button>
|
||||
<button onclick="showTab('events')">Events</button>
|
||||
<button onclick="showTab('notifications')">Notifications <span id="notif-badge"></span></button>
|
||||
<button onclick="showTab('achievements')">Achievements <span id="ach-badge"></span></button>
|
||||
</nav>
|
||||
|
||||
<main>
|
||||
@@ -639,6 +640,15 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
</div>
|
||||
|
||||
<!-- ── Notifications tab ─────────────────────────────────────────────────── -->
|
||||
<div id="tab-achievements" class="tab-panel">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2 style="margin:0">Achievements <span id="ach-progress" style="font-size:0.85rem;color:#8b949e;font-weight:400"></span></h2>
|
||||
</div>
|
||||
<div id="ach-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:12px">
|
||||
<span class="loading">Loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-notifications" class="tab-panel">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2 style="margin:0">Notifications <span id="notif-unread-label" style="font-size:0.85rem;color:#8b949e;font-weight:400"></span></h2>
|
||||
@@ -698,7 +708,7 @@ function showTab(name) {
|
||||
market: loadMarket, events: loadEvents, matches: loadMatches,
|
||||
store: loadStore, sbc: loadSbc, statistics: loadStatistics,
|
||||
catalog: loadCatalog, settings: loadSettings,
|
||||
notifications: loadNotifications })[name]?.();
|
||||
notifications: loadNotifications, achievements: loadAchievements })[name]?.();
|
||||
}
|
||||
|
||||
function rarityClass(r) {
|
||||
@@ -1189,6 +1199,8 @@ async function submitMatch() {
|
||||
if (levelUps.length) {
|
||||
showToast(`Level Up! You are now level ${levelUps[levelUps.length - 1].new_level}`);
|
||||
}
|
||||
const achUnlocked = r.achievements_unlocked || [];
|
||||
achUnlocked.forEach(a => showToast(`Achievement unlocked: ${a.icon || ''} ${a.title}`));
|
||||
api('GET', '/club').then(c => { el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins'; }).catch(() => {});
|
||||
await loadMatchHistory();
|
||||
} catch (e) { showToast(e.message, true); }
|
||||
@@ -2080,6 +2092,59 @@ async function toggleEvent(eventId, activate, btn) {
|
||||
|
||||
// ── Notifications ─────────────────────────────────────────────────────────────
|
||||
|
||||
// ── Achievements ─────────────────────────────────────────────────────────────
|
||||
|
||||
const ACH_RARITY_COLOR = { common: '#8b949e', rare: '#58a6ff', epic: '#bc8cff', legendary: '#f5a623' };
|
||||
|
||||
async function loadAchievements() {
|
||||
try {
|
||||
const data = await api('GET', '/achievements');
|
||||
const achs = data.achievements || [];
|
||||
const earned = data.earned || 0;
|
||||
const total = data.total || achs.length;
|
||||
|
||||
el('ach-progress').textContent = `${earned} / ${total} earned`;
|
||||
el('ach-badge').textContent = earned > 0 ? ` (${earned})` : '';
|
||||
|
||||
if (!achs.length) {
|
||||
el('ach-grid').innerHTML = '<span class="empty">No achievements defined.</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort: unlocked first (by unlock date desc), then locked alphabetically
|
||||
const unlocked = achs.filter(a => a.unlocked).sort((a, b) => (b.unlocked_at || '').localeCompare(a.unlocked_at || ''));
|
||||
const locked = achs.filter(a => !a.unlocked).sort((a, b) => a.title.localeCompare(b.title));
|
||||
|
||||
el('ach-grid').innerHTML = [...unlocked, ...locked].map(a => {
|
||||
const rc = ACH_RARITY_COLOR[a.rarity] || '#8b949e';
|
||||
const opacity = a.unlocked ? '1' : '0.45';
|
||||
const badge = a.unlocked
|
||||
? `<span style="color:#3fb950;font-size:0.72rem">✓ Unlocked${a.unlocked_at ? ' ' + new Date(a.unlocked_at).toLocaleDateString() : ''}</span>`
|
||||
: `<span style="color:#6e7681;font-size:0.72rem">Locked</span>`;
|
||||
return `
|
||||
<div style="background:#161b22;border-radius:8px;padding:14px 16px;
|
||||
border:1px solid ${a.unlocked ? rc : '#30363d'};opacity:${opacity};
|
||||
display:flex;gap:14px;align-items:flex-start">
|
||||
<span style="font-size:1.8rem;min-width:36px;text-align:center">${a.icon}</span>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<strong style="color:${rc}">${a.title}</strong>
|
||||
<span style="font-size:0.65rem;background:${rc}22;color:${rc};
|
||||
padding:1px 6px;border-radius:4px;text-transform:uppercase">${a.rarity}</span>
|
||||
</div>
|
||||
<div style="font-size:0.8rem;color:#8b949e;margin:3px 0">${a.description}</div>
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:4px">
|
||||
${badge}
|
||||
<span style="font-size:0.72rem;color:#f5a623">+${(a.reward_coins||0).toLocaleString()} coins</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch (e) { el('ach-grid').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||||
}
|
||||
|
||||
// ── Notifications ─────────────────────────────────────────────────────────────
|
||||
|
||||
const NOTIF_ICONS = {
|
||||
level_up: '⬆',
|
||||
objective_complete: '✓',
|
||||
|
||||
@@ -251,6 +251,7 @@ async fn test_dashboard_contains_key_sections() {
|
||||
assert!(html.contains("tab-catalog"), "card catalog tab missing");
|
||||
assert!(html.contains("tab-settings"), "settings tab missing");
|
||||
assert!(html.contains("tab-notifications"), "notifications tab missing");
|
||||
assert!(html.contains("tab-achievements"), "achievements tab missing");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
Reference in New Issue
Block a user