Notifications
@@ -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 = '
No achievements defined.';
+ 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
+ ? `
✓ Unlocked${a.unlocked_at ? ' ' + new Date(a.unlocked_at).toLocaleDateString() : ''}`
+ : `
Locked`;
+ return `
+
+
${a.icon}
+
+
+ ${a.title}
+ ${a.rarity}
+
+
${a.description}
+
+ ${badge}
+ +${(a.reward_coins||0).toLocaleString()} coins
+
+
+
`;
+ }).join('');
+ } catch (e) { el('ach-grid').innerHTML = `
${e.message}
`; }
+}
+
+// ── Notifications ─────────────────────────────────────────────────────────────
+
const NOTIF_ICONS = {
level_up: '⬆',
objective_complete: '✓',
diff --git a/tests/proxy_test.rs b/tests/proxy_test.rs
index e18a27a..a2df291 100644
--- a/tests/proxy_test.rs
+++ b/tests/proxy_test.rs
@@ -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]