diff --git a/src/dashboard.html b/src/dashboard.html index b628ad3..13851a7 100644 --- a/src/dashboard.html +++ b/src/dashboard.html @@ -173,6 +173,38 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; .sell-form select, .sell-form input { width: 100%; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 10px; border-radius: 6px; font-size: 0.85rem; margin: 6px 0 12px; } +/* ── Matches ── */ +.match-form { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; max-width: 560px; } +.score-row { display: flex; align-items: center; gap: 12px; margin: 12px 0; } +.score-input { width: 64px; background: #0d1117; border: 1px solid #30363d; color: #f5a623; + font-size: 1.6rem; font-weight: 800; text-align: center; border-radius: 6px; padding: 4px; } +.score-sep { font-size: 1.4rem; color: #8b949e; } +.opponent-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 14px; margin: 12px 0; } +.opponent-rating { font-size: 2rem; font-weight: 800; color: #f5a623; } +.match-history-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; + border-bottom: 1px solid #21262d; font-size: 0.85rem; } +.outcome-badge { width: 28px; height: 28px; border-radius: 4px; font-weight: 700; font-size: 0.75rem; + display: flex; align-items: center; justify-content: center; flex-shrink: 0; } +.outcome-win { background: #14532d; color: #4ade80; } +.outcome-draw { background: #374151; color: #d1d5db; } +.outcome-loss { background: #7f1d1d; color: #f87171; } + +/* ── SBC ── */ +.sbc-list { display: grid; gap: 12px; margin-top: 12px; } +.sbc-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; } +.sbc-card.completed { border-color: #238636; opacity: 0.7; } +.sbc-name { font-size: 0.95rem; font-weight: 600; margin-bottom: 4px; } +.sbc-desc { font-size: 0.8rem; color: #8b949e; margin-bottom: 10px; } +.sbc-reqs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; } +.req-pill { font-size: 0.7rem; padding: 2px 8px; border-radius: 10px; background: #21262d; color: #c9d1d9; } +.sbc-reward { font-size: 0.8rem; color: #f5a623; margin-bottom: 10px; } +.sbc-picker { margin-top: 10px; } +.sbc-picker select { width: 100%; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; + padding: 6px; border-radius: 6px; font-size: 0.8rem; margin: 3px 0; } +.sbc-selected { display: flex; flex-wrap: wrap; gap: 4px; margin: 6px 0; min-height: 24px; } +.sbc-sel-pill { font-size: 0.7rem; background: #1c3250; color: #93c5fd; padding: 2px 8px; + border-radius: 10px; cursor: pointer; } + /* ── Events ── */ .events-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; margin-top: 12px; } .event-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; } @@ -203,6 +235,10 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; + + + + @@ -310,6 +346,82 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; + +
+
+
+

Simulate Match

+
+
+ + + +
+
+
+ + + +
+
Your goals — Opponent goals
+
+ + + +
+
+ +
+
+
+
+
+

Match History

+
Loading…
+
+
+
+ + +
+

+ Purchase packs with your coins. Bought packs appear in the Packs tab ready to open. +

+
Loading…
+
+ + +
+

+ Complete Squad Building Challenges to earn coins, XP, and packs. Cards submitted are consumed. +

+
Loading…
+
+ + +
+
+
+

Career Stats

+
Loading…
+
+
+

Goals by Position

+
Loading…
+
+
+
+

Recent Match Log

+
Loading…
+
+
+
@@ -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 `
+ ${pos} +
+ ${goals} +
`; + }).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"); }