diff --git a/src/dashboard.html b/src/dashboard.html
index 8be0fe3..b628ad3 100644
--- a/src/dashboard.html
+++ b/src/dashboard.html
@@ -141,6 +141,47 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
max-width: 480px; width: 90%; max-height: 80vh; overflow-y: auto; }
.modal h3 { color: #f5a623; margin-bottom: 16px; }
.opened-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; margin-top: 12px; }
+
+/* ── Squad ── */
+.squad-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; margin-top: 12px; }
+.squad-slot { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 10px; }
+.squad-slot .slot-pos { font-size: 0.7rem; text-transform: uppercase; color: #8b949e; margin-bottom: 4px; }
+.chem-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 2px; }
+.chem-dot.full { background: #3fb950; }
+.chem-dot.partial { background: #d29922; }
+.chem-dot.none { background: #f85149; }
+.chem-bar { height: 8px; background: #21262d; border-radius: 4px; overflow: hidden; margin: 6px 0; }
+.chem-bar-fill { height: 100%; border-radius: 4px; background: #3fb950; }
+.formation-badge { font-size: 0.75rem; background: #1c3250; color: #93c5fd; padding: 2px 8px; border-radius: 10px; }
+
+/* ── Draft ── */
+.draft-candidates { display: grid; grid-template-columns: repeat(auto-fill, minmax(165px, 1fr)); gap: 10px; margin-top: 12px; }
+.draft-candidate { background: #161b22; border: 2px solid #30363d; border-radius: 8px; padding: 10px;
+ cursor: pointer; transition: border-color 0.15s; }
+.draft-candidate:hover { border-color: #f5a623; }
+.pick-slots { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 8px; }
+.pick-dot { width: 12px; height: 12px; border-radius: 50%; background: #21262d; border: 1px solid #30363d; }
+.pick-dot.filled { background: #f5a623; border-color: #f5a623; }
+.draft-progress { font-size: 0.8rem; color: #8b949e; margin-bottom: 12px; }
+
+/* ── Market ── */
+.market-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; margin-top: 12px; }
+.market-item { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 12px; }
+.market-price { color: #f5a623; font-weight: 700; font-size: 1rem; margin: 6px 0; }
+.market-seller { font-size: 0.7rem; color: #8b949e; }
+.sell-form { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; max-width: 480px; }
+.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; }
+
+/* ── 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; }
+.event-card.active { border-color: #f5a623; }
+.event-name { font-size: 0.95rem; font-weight: 600; margin-bottom: 4px; }
+.event-desc { font-size: 0.8rem; color: #8b949e; margin-bottom: 10px; }
+.event-status { font-size: 0.75rem; font-weight: 600; }
+.event-status.on { color: #3fb950; }
+.event-status.off { color: #8b949e; }
@@ -162,6 +203,10 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
Loading…
@@ -317,7 +442,9 @@ function showTab(name) {
event.target.classList.add('active');
({ club: loadClub, collection: loadCollection, packs: loadPacks,
objectives: loadObjectives, division: loadDivision,
- champs: loadChamps, notifications: loadNotifications })[name]?.();
+ champs: loadChamps, squad: loadSquad, draft: loadDraft,
+ market: loadMarket, events: loadEvents,
+ notifications: loadNotifications })[name]?.();
}
function rarityClass(r) {
@@ -716,6 +843,352 @@ async function claimChampsReward(sessionId) {
} catch (e) { showToast(e.message, true); }
}
+// ── Squad ─────────────────────────────────────────────────────────────────────
+
+async function loadSquad() {
+ try {
+ const data = await api('GET', '/squad');
+ const squad = data.squad;
+ const players = data.players || [];
+ const chem = data.chemistry || {};
+
+ el('squad-header').innerHTML = `
+
+ ${squad?.name || 'My Squad'}
+ ${squad?.formation || '—'}
+
`;
+
+ if (!players.length) {
+ el('squad-grid').innerHTML = '
No squad saved yet. Use /squad POST from the game or API. ';
+ el('squad-chemistry').innerHTML = '';
+ return;
+ }
+
+ const playerChems = chem.players || {};
+ el('squad-grid').innerHTML = players.map(p => {
+ const card = p.card;
+ const pChem = playerChems[p.owned_card_id] ?? 0;
+ const dots = Array.from({length: 10}, (_, i) =>
+ `
`
+ ).join('');
+ return `
+
${p.position_label || 'Player'}${p.is_on_bench ? ' (sub)' : ''}
+ ${card ? `
+
${card.overall}
+
${card.name}
+
${card.position} · ${card.club}
+
Chem: ${pChem}/10
+
${dots}
+ ` : '
Empty '}
+
`;
+ }).join('');
+
+ const total = chem.total ?? 0;
+ const pct = total;
+ const chemColor = total >= 80 ? '#3fb950' : total >= 50 ? '#d29922' : '#f85149';
+ el('squad-chemistry').innerHTML = `
+
${total}/100
+
+
+ Chemistry is earned through same-club, same-league, and same-nation links between adjacent players.
+ Each player earns up to 10 chem points; total is capped at 100.
+
`;
+ } catch (e) {
+ el('squad-grid').innerHTML = `
${e.message}
`;
+ el('squad-chemistry').innerHTML = '';
+ }
+}
+
+// ── Draft ─────────────────────────────────────────────────────────────────────
+
+let activeDraftSession = null;
+
+async function loadDraft() {
+ el('draft-status').innerHTML = '
Loading… ';
+ el('draft-candidates').innerHTML = '';
+ el('draft-picks').innerHTML = '';
+ activeDraftSession = null;
+
+ // Try to find an active session via history approach — Core has no "list active" endpoint,
+ // so we just show the start UI and let the user start or continue manually.
+ el('draft-status').innerHTML = `
+
+ Select difficulty and start a new draft to pick 11 players (GK → ST).
+ Each slot shows 5 candidates; pick one to advance.
+
`;
+}
+
+async function startDraft() {
+ const diff = el('draft-difficulty').value;
+ try {
+ const data = await api('POST', `/draft/start?difficulty=${diff}`);
+ activeDraftSession = data;
+ renderDraftSession(data);
+ el('draft-start-btn').style.display = 'none';
+ el('draft-abandon-btn').style.display = '';
+ showToast('Draft started!');
+ } catch (e) { showToast(e.message, true); }
+}
+
+async function abandonDraft() {
+ if (!activeDraftSession?.session_id) return;
+ if (!confirm('Abandon this draft? No rewards will be granted.')) return;
+ try {
+ await api('POST', `/draft/sessions/${activeDraftSession.session_id}/abandon`);
+ activeDraftSession = null;
+ el('draft-start-btn').style.display = '';
+ el('draft-abandon-btn').style.display = 'none';
+ el('draft-status').innerHTML = '
Draft abandoned. ';
+ el('draft-candidates').innerHTML = '';
+ el('draft-picks').innerHTML = '';
+ showToast('Draft abandoned.');
+ } catch (e) { showToast(e.message, true); }
+}
+
+async function pickDraftCard(cardId) {
+ if (!activeDraftSession?.session_id) return;
+ try {
+ const data = await api('POST', `/draft/sessions/${activeDraftSession.session_id}/pick`, { card_id: cardId });
+ activeDraftSession = data;
+ renderDraftSession(data);
+ if (data.just_completed) {
+ const r = data.reward || {};
+ showToast(`Draft complete! ${(r.coins || 0).toLocaleString()} coins earned (avg OVR ${r.squad_rating || 0})`);
+ el('draft-abandon-btn').style.display = 'none';
+ api('GET', '/club').then(c => { el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins'; }).catch(() => {});
+ }
+ } catch (e) { showToast(e.message, true); }
+}
+
+function renderDraftSession(data) {
+ const picks = data.picks || [];
+ const candidates = data.candidates || [];
+ const progress = data.progress || {};
+ const status = data.status;
+ const isComplete = status === 'completed';
+
+ // Dots progress bar
+ const dots = Array.from({length: progress.total || 11}, (_, i) =>
+ `
`
+ ).join('');
+
+ el('draft-status').innerHTML = `
+
+ ${isComplete ? 'Draft Complete!' : `Picking: ${data.current_position || '—'}`}
+ ${progress.filled || 0}/${progress.total || 11}
+
+
${dots}
+ ${isComplete ? `
+
+
Reward: ${(data.reward?.coins || 0).toLocaleString()} coins
+ ${data.reward?.pack_id ? ' + ' + data.reward.pack_id : ''}
+ · Squad Rating: ${data.reward?.squad_rating || data.squad_rating || '—'}
+
+
Start Another Draft ` : ''}`;
+
+ // Candidates
+ if (!isComplete && candidates.length) {
+ el('draft-candidates').innerHTML = candidates.map(cand => {
+ const c = cand.card;
+ if (!c) return '';
+ return `
+
${(c.rarity||'').toUpperCase()}
+
${c.overall}
+
${c.position}
+
${c.name}
+
${c.club}
+
+ ${statBar('PAC',c.pace)}${statBar('SHO',c.shooting)}${statBar('PAS',c.passing)}
+
+
`;
+ }).join('');
+ } else if (isComplete) {
+ el('draft-candidates').innerHTML = '';
+ el('draft-start-btn').style.display = '';
+ el('draft-abandon-btn').style.display = 'none';
+ }
+
+ // Picks list
+ el('draft-picks').innerHTML = picks.map(p => {
+ const c = p.card;
+ if (!c) return `
`;
+ return `
+
${p.position}
+
${c.overall}
+
${c.name}
+
${c.position}
+
`;
+ }).join('');
+}
+
+// ── Market ────────────────────────────────────────────────────────────────────
+
+let allMarketListings = [];
+
+async function loadMarket() {
+ await Promise.all([loadMarketListings(), loadMyListings()]);
+ // Populate sell card dropdown from collection
+ if (!allCards.length) {
+ try { const d = await api('GET', '/collection'); allCards = d.collection || []; } catch (_) {}
+ }
+ const sel = el('sell-card-select');
+ sel.innerHTML = '
— choose card — ' +
+ 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 `
${ovr} ${c.name} (${c.position}) `;
+ }).join('');
+}
+
+async function loadMarketListings() {
+ try {
+ const data = await api('GET', '/market');
+ allMarketListings = data.listings || [];
+ filterMarket();
+ } catch (e) { el('market-grid').innerHTML = `
${e.message}
`; }
+}
+
+function filterMarket() {
+ const q = (el('m-search')?.value || '').toLowerCase();
+ const filtered = q
+ ? allMarketListings.filter(l => l.card && l.card.name.toLowerCase().includes(q))
+ : allMarketListings;
+
+ if (!filtered.length) {
+ el('market-grid').innerHTML = '
No listings found. Click "Refresh Listings" to populate the market. ';
+ return;
+ }
+ el('market-grid').innerHTML = filtered.map(l => {
+ const c = l.card;
+ if (!c) return '';
+ return `
+
${(c.rarity||'').toUpperCase()}
+
${c.overall}
+
${c.position}
+
${c.name}
+
${c.club} · ${c.nation}
+
${(l.listing?.buy_now_price || l.buy_now_price || 0).toLocaleString()} coins
+
${l.listing?.seller_name || l.seller_name || 'NPC'}
+
Buy
+
`;
+ }).join('');
+}
+
+async function refreshMarket() {
+ try {
+ await api('POST', '/market/refresh');
+ showToast('Market refreshed!');
+ await loadMarketListings();
+ } catch (e) { showToast(e.message, true); }
+}
+
+async function buyListing(listingId, btn) {
+ btn.disabled = true; btn.textContent = 'Buying…';
+ try {
+ const r = await api('POST', '/market/buy', { listing_id: listingId });
+ showToast(`Bought ${r.card?.name || 'card'} for ${(r.price || 0).toLocaleString()} coins`);
+ api('GET', '/club').then(c => { el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins'; }).catch(() => {});
+ allCards = []; // invalidate collection cache
+ await loadMarketListings();
+ } catch (e) { showToast(e.message, true); btn.disabled = false; btn.textContent = 'Buy'; }
+}
+
+async function sellCard() {
+ const ownedCardId = el('sell-card-select').value;
+ const price = parseInt(el('sell-price').value, 10);
+ if (!ownedCardId) { showToast('Select a card first', true); return; }
+ if (!price || price < 100) { showToast('Price must be at least 100 coins', true); return; }
+ try {
+ await api('POST', '/market/sell', { owned_card_id: ownedCardId, buy_now_price: price });
+ showToast('Card listed for sale!');
+ allCards = [];
+ await loadMyListings();
+ } catch (e) { showToast(e.message, true); }
+}
+
+async function loadMyListings() {
+ try {
+ const data = await api('GET', '/market/my-listings');
+ const listings = data.listings || [];
+ if (!listings.length) {
+ el('my-listings').innerHTML = '
No active listings. ';
+ return;
+ }
+ el('my-listings').innerHTML = listings.map(l => {
+ const c = l.card;
+ return `
+
+
${c?.name || l.listing?.card_id || '—'}
+
${c?.position || ''} · ${c?.club || ''}
+
+
+
${(l.listing?.buy_now_price || 0).toLocaleString()} coins
+
Cancel
+
+
`;
+ }).join('');
+ } catch (e) { el('my-listings').innerHTML = `
${e.message}
`; }
+}
+
+async function cancelListing(listingId, btn) {
+ btn.disabled = true;
+ try {
+ await api('DELETE', `/market/listings/${listingId}`);
+ showToast('Listing cancelled');
+ allCards = [];
+ await loadMyListings();
+ } catch (e) { showToast(e.message, true); btn.disabled = false; }
+}
+
+// ── Events ────────────────────────────────────────────────────────────────────
+
+async function loadEvents() {
+ try {
+ const data = await api('GET', '/events');
+ const events = data.events || [];
+ if (!events.length) {
+ el('events-grid').innerHTML = '
No events defined in data/events.json. ';
+ return;
+ }
+ el('events-grid').innerHTML = events.map(ev => {
+ const active = ev.is_active;
+ const effects = ev.effects || {};
+ const bonuses = [
+ effects.score_multiplier && effects.score_multiplier !== 1 ? `Score ×${effects.score_multiplier}` : '',
+ effects.coin_multiplier && effects.coin_multiplier !== 1 ? `Coins ×${effects.coin_multiplier}` : '',
+ (effects.bonus_market_cards || []).length ? `+${effects.bonus_market_cards.length} market cards` : '',
+ effects.pack_discount_pct ? `-${effects.pack_discount_pct}% packs` : '',
+ ].filter(Boolean).join(' · ');
+ return `
+
${ev.name}
+
${ev.description || ''}
+ ${bonuses ? `
${bonuses}
` : ''}
+
+ ${active ? '● Active' : '○ Inactive'}
+ ${active
+ ? `Deactivate `
+ : `Activate `}
+
+
`;
+ }).join('');
+ } catch (e) { el('events-grid').innerHTML = `
${e.message}
`; }
+}
+
+async function toggleEvent(eventId, activate, btn) {
+ btn.disabled = true;
+ try {
+ const path = activate ? `/events/${eventId}/activate` : `/events/${eventId}/deactivate`;
+ await api('POST', path);
+ showToast(activate ? 'Event activated!' : 'Event deactivated');
+ loadEvents();
+ } catch (e) { showToast(e.message, true); btn.disabled = false; }
+}
+
// ── Notifications ─────────────────────────────────────────────────────────────
async function loadNotifications() {
diff --git a/tests/proxy_test.rs b/tests/proxy_test.rs
index 0125808..ea3fcfa 100644
--- a/tests/proxy_test.rs
+++ b/tests/proxy_test.rs
@@ -240,6 +240,10 @@ async fn test_dashboard_contains_key_sections() {
assert!(html.contains("tab-objectives"), "objectives tab missing");
assert!(html.contains("tab-division"), "division tab missing");
assert!(html.contains("tab-champs"), "fut champs tab missing");
+ assert!(html.contains("tab-squad"), "squad tab missing");
+ 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-notifications"), "notifications tab missing");
}