@@ -443,7 +555,8 @@ function showTab(name) {
({ club: loadClub, collection: loadCollection, packs: loadPacks,
objectives: loadObjectives, division: loadDivision,
champs: loadChamps, squad: loadSquad, draft: loadDraft,
- market: loadMarket, events: loadEvents,
+ market: loadMarket, events: loadEvents, matches: loadMatches,
+ store: loadStore, sbc: loadSbc, statistics: loadStatistics,
notifications: loadNotifications })[name]?.();
}
@@ -843,6 +956,315 @@ async function claimChampsReward(sessionId) {
} catch (e) { showToast(e.message, true); }
}
+// ── Matches ───────────────────────────────────────────────────────────────────
+
+let currentOpponent = null;
+
+async function loadMatches() {
+ await loadMatchHistory();
+}
+
+async function generateOpponent() {
+ const diff = el('match-difficulty').value;
+ try {
+ const data = await api('GET', `/matches/opponent?difficulty=${diff}`);
+ currentOpponent = data;
+ const rating = data.squad_rating ?? '—';
+ const name = data.opponent_name ?? 'AI Opponent';
+ const formation = data.formation ?? '—';
+ el('opponent-display').innerHTML = `
+
+
${diff.replace('_', ' ').toUpperCase()}
+
${name}
+
+ ${rating}
+ ${formation}
+
+
`;
+ } catch (e) { showToast(e.message, true); }
+}
+
+function quickScore(gf, ga) {
+ el('goals-for').value = gf;
+ el('goals-against').value = ga;
+}
+
+async function submitMatch() {
+ const gf = parseInt(el('goals-for').value, 10) || 0;
+ const ga = parseInt(el('goals-against').value, 10) || 0;
+ const diff = el('match-difficulty').value;
+ const opponentName = currentOpponent?.opponent_name ?? `${diff.replace('_',' ')} Bot`;
+
+ try {
+ const r = await api('POST', '/matches/result', {
+ squad_id: 'dashboard',
+ opponent_name: opponentName,
+ goals_for: gf,
+ goals_against: ga,
+ mode: 'squad_battles',
+ });
+ const outcome = gf > ga ? 'Win' : gf === ga ? 'Draw' : 'Loss';
+ const outcomeColor = gf > ga ? '#3fb950' : gf === ga ? '#8b949e' : '#f85149';
+ el('match-result-display').innerHTML = `
+
+
${outcome} ${gf}–${ga}
+
+ +${(r.coins_awarded || 0).toLocaleString()} coins · +${r.xp_awarded || 0} XP
+ ${(r.objectives_triggered || []).length ? `
Objectives: ${r.objectives_triggered.join(', ')}` : ''}
+ ${r.season ? `
Season pts: ${r.season.season_points}` : ''}
+
+
`;
+ api('GET', '/club').then(c => { el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins'; }).catch(() => {});
+ await loadMatchHistory();
+ } catch (e) { showToast(e.message, true); }
+}
+
+async function loadMatchHistory() {
+ try {
+ const data = await api('GET', '/matches?limit=20');
+ const matches = data.matches || [];
+ if (!matches.length) {
+ el('match-history').innerHTML = '
No matches played yet.';
+ return;
+ }
+ el('match-history').innerHTML = matches.map(m => {
+ const cls = m.outcome === 'win' ? 'win' : m.outcome === 'draw' ? 'draw' : 'loss';
+ const lbl = m.outcome === 'win' ? 'W' : m.outcome === 'draw' ? 'D' : 'L';
+ return `
+ ${lbl}
+ ${m.opponent_name}
+ ${m.goals_for}–${m.goals_against}
+ +${(m.coins_awarded||0).toLocaleString()}
+ ${m.mode || 'squad_battles'}
+
`;
+ }).join('');
+ } catch (e) { el('match-history').innerHTML = `
${e.message}
`; }
+}
+
+// ── Pack Store ────────────────────────────────────────────────────────────────
+
+async function loadStore() {
+ try {
+ const data = await api('GET', '/packs/store');
+ const packs = data.packs || [];
+ if (!packs.length) {
+ el('store-grid').innerHTML = '
No packs available.';
+ return;
+ }
+ el('store-grid').innerHTML = packs.map(p => `
+
+
${p.name}
+
${p.description}
+
+ ${(p.cost_coins || 0).toLocaleString()} coins
+ ${p.total_cards} cards
+
+
+
`).join('');
+ } catch (e) { el('store-grid').innerHTML = `
${e.message}
`; }
+}
+
+async function buyStorePack(defId, btn) {
+ btn.disabled = true; btn.textContent = 'Buying…';
+ try {
+ await api('POST', '/packs/buy', { pack_definition_id: defId });
+ showToast('Pack purchased! Open it in the Packs tab.');
+ api('GET', '/club').then(c => { el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins'; }).catch(() => {});
+ btn.disabled = false; btn.textContent = 'Buy';
+ } catch (e) { showToast(e.message, true); btn.disabled = false; btn.textContent = 'Buy'; }
+}
+
+// ── SBC ───────────────────────────────────────────────────────────────────────
+
+// Per-SBC selected owned card IDs: { sbcId: [ownedCardId, ...] }
+const sbcSelections = {};
+
+async function loadSbc() {
+ if (!allCards.length) {
+ try { const d = await api('GET', '/collection'); allCards = d.collection || []; } catch (_) {}
+ }
+ try {
+ const data = await api('GET', '/sbc');
+ const sbcs = data.sbcs || [];
+ if (!sbcs.length) {
+ el('sbc-list').innerHTML = '
No SBC challenges defined.';
+ return;
+ }
+ el('sbc-list').innerHTML = sbcs.map(s => renderSbcCard(s)).join('');
+ } catch (e) { el('sbc-list').innerHTML = `
${e.message}
`; }
+}
+
+function renderSbcCard(s) {
+ const r = s.requirements || {};
+ const reward = s.reward || {};
+ const reqs = [
+ r.squad_size ? `${r.squad_size} players` : '',
+ r.min_overall ? `Min OVR ${r.min_overall}` : '',
+ r.max_overall ? `Max OVR ${r.max_overall}` : '',
+ r.min_chemistry ? `Min Chem ${r.min_chemistry}` : '',
+ ...(r.required_nations || []).map(n => `Nation: ${n}`),
+ ...(r.required_leagues || []).map(l => `League: ${l}`),
+ ...(r.required_clubs || []).map(c => `Club: ${c}`),
+ r.min_players_from_same_league ? `${r.min_players_from_same_league}+ same league` : '',
+ r.min_players_from_same_nation ? `${r.min_players_from_same_nation}+ same nation` : '',
+ r.min_players_from_same_club ? `${r.min_players_from_same_club}+ same club` : '',
+ ].filter(Boolean);
+
+ const rewardStr = [
+ reward.coins ? `${reward.coins.toLocaleString()} coins` : '',
+ reward.xp ? `${reward.xp} XP` : '',
+ reward.pack_id ? reward.pack_id.replace(/_/g,' ') : '',
+ ].filter(Boolean).join(' + ');
+
+ const sel = sbcSelections[s.id] || [];
+ const needed = r.squad_size || 11;
+ const cardOpts = allCards
+ .filter(i => !i.is_loan)
+ .sort((a, b) => (b.effective_overall ?? b.card.overall) - (a.effective_overall ?? a.card.overall))
+ .map(i => {
+ const c = i.card;
+ const ovr = i.effective_overall ?? c.overall;
+ return `
`;
+ }).join('');
+
+ const selPills = sel.map(id => {
+ const item = allCards.find(i => i.owned_card_id === id);
+ const label = item ? `${item.card.name}` : id;
+ return `
${label} ×`;
+ }).join('');
+
+ return `
+
${s.name}${s.repeatable ? ' (repeatable)' : ''}
+
${s.description}
+
${reqs.map(r => `${r}`).join('')}
+
Reward: ${rewardStr || '—'}
+
+
+ Selected: ${sel.length}/${needed} cards
+
+
${selPills}
+ ${sel.length < needed ? `
+
` : ''}
+ ${sel.length >= needed ? `
+
` : ''}
+
+
`;
+}
+
+function addSbcCard(sbcId, selectEl) {
+ const val = selectEl.value;
+ if (!val) return;
+ if (!sbcSelections[sbcId]) sbcSelections[sbcId] = [];
+ if (!sbcSelections[sbcId].includes(val)) sbcSelections[sbcId].push(val);
+ selectEl.value = '';
+ refreshSbcCard(sbcId);
+}
+
+function removeSbcCard(sbcId, ownedCardId) {
+ if (sbcSelections[sbcId]) {
+ sbcSelections[sbcId] = sbcSelections[sbcId].filter(id => id !== ownedCardId);
+ }
+ refreshSbcCard(sbcId);
+}
+
+async function refreshSbcCard(sbcId) {
+ try {
+ const data = await api('GET', '/sbc');
+ const sbc = (data.sbcs || []).find(s => s.id === sbcId);
+ if (sbc) {
+ const el_card = document.getElementById(`sbc-${sbcId}`);
+ if (el_card) el_card.outerHTML = renderSbcCard(sbc);
+ }
+ } catch (_) {}
+}
+
+async function submitSbc(sbcId) {
+ const owned = sbcSelections[sbcId] || [];
+ if (!owned.length) { showToast('Select cards first', true); return; }
+ try {
+ const r = await api('POST', '/sbc/submit', { sbc_id: sbcId, owned_card_ids: owned });
+ if (r.passed) {
+ delete sbcSelections[sbcId];
+ const reward = r.reward || {};
+ showToast(`SBC complete! ${reward.coins ? reward.coins.toLocaleString() + ' coins' : ''} ${reward.pack_id ? '+ ' + reward.pack_id : ''}`);
+ allCards = [];
+ api('GET', '/club').then(c => { el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins'; }).catch(() => {});
+ await loadSbc();
+ } else {
+ showToast('Failed: ' + (r.failures || []).join('; '), true);
+ }
+ } catch (e) { showToast(e.message, true); }
+}
+
+// ── Statistics ────────────────────────────────────────────────────────────────
+
+async function loadStatistics() {
+ try {
+ const s = await api('GET', '/statistics');
+ const pg = s.position_goals || {};
+ el('career-stats').innerHTML = `
+
Matches${s.matches_played ?? 0}
+
Wins${s.wins ?? 0}
+
Draws${s.draws ?? 0}
+
Losses${s.losses ?? 0}
+
Win rate${s.matches_played > 0 ? Math.round(((s.wins??0)/s.matches_played)*100) : 0}%
+
Goals scored${s.goals_scored ?? 0}
+
Goals conceded${s.goals_conceded ?? 0}
+
Avg goals/game${s.matches_played > 0 ? ((s.goals_scored??0)/s.matches_played).toFixed(1) : '0.0'}
+
Win streak${s.current_win_streak ?? 0}
+
Best streak${s.best_win_streak ?? 0}
+
Packs opened${s.packs_opened ?? 0}
+
SBCs done${s.sbcs_completed ?? 0}
+
Coins earned${(s.total_coins_earned ?? 0).toLocaleString()}
+ `;
+ const posEntries = Object.entries(pg).sort((a, b) => b[1] - a[1]);
+ el('position-goals').innerHTML = posEntries.length
+ ? posEntries.map(([pos, goals]) => {
+ const max = posEntries[0][1] || 1;
+ const pct = Math.round((goals / max) * 100);
+ return `
`;
+ }).join('')
+ : '
No goal data yet.';
+ } catch (e) { el('career-stats').innerHTML = `
${e.message}
`; }
+
+ try {
+ const h = await api('GET', '/statistics/history?limit=20');
+ const matches = h.matches || [];
+ if (!matches.length) {
+ el('stats-history').innerHTML = '
No match history yet.';
+ return;
+ }
+ const summ = h.summary || {};
+ el('stats-history').innerHTML = `
+
+ Last ${matches.length} matches
+ ${summ.wins ?? 0}W
+ ${summ.draws ?? 0}D
+ ${summ.losses ?? 0}L
+ Avg goals: ${summ.avg_goals_per_game ?? 0}
+ Win rate: ${((summ.win_rate ?? 0)*100).toFixed(0)}%
+
` +
+ matches.map(m => {
+ const cls = m.outcome === 'win' ? 'win' : m.outcome === 'draw' ? 'draw' : 'loss';
+ const lbl = { win: 'W', draw: 'D', loss: 'L' }[m.outcome] || '?';
+ return `
+ ${lbl}
+ ${m.opponent_name}
+ ${m.goals_for}–${m.goals_against}
+ +${(m.coins_awarded||0).toLocaleString()}
+ ${new Date(m.played_at).toLocaleDateString()}
+
`;
+ }).join('');
+ } catch (e) { el('stats-history').innerHTML = `
${e.message}
`; }
+}
+
// ── Squad ─────────────────────────────────────────────────────────────────────
async function loadSquad() {
diff --git a/tests/proxy_test.rs b/tests/proxy_test.rs
index ea3fcfa..d71310f 100644
--- a/tests/proxy_test.rs
+++ b/tests/proxy_test.rs
@@ -244,6 +244,10 @@ async fn test_dashboard_contains_key_sections() {
assert!(html.contains("tab-draft"), "draft tab missing");
assert!(html.contains("tab-market"), "market tab missing");
assert!(html.contains("tab-events"), "events tab missing");
+ assert!(html.contains("tab-matches"), "matches tab missing");
+ assert!(html.contains("tab-store"), "pack store tab missing");
+ assert!(html.contains("tab-sbc"), "sbc tab missing");
+ assert!(html.contains("tab-statistics"), "statistics tab missing");
assert!(html.contains("tab-notifications"), "notifications tab missing");
}