-
Active Squad
-
+
+
Squad
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
Starters (11)
+
+
Bench (up to 7)
+
+
+
+
+
+
+
+
+
+ Pick player for slot
+
+
+
+
+
@@ -557,6 +689,7 @@ function showTab(name) {
champs: loadChamps, squad: loadSquad, draft: loadDraft,
market: loadMarket, events: loadEvents, matches: loadMatches,
store: loadStore, sbc: loadSbc, statistics: loadStatistics,
+ catalog: loadCatalog, settings: loadSettings,
notifications: loadNotifications })[name]?.();
}
@@ -1265,6 +1398,117 @@ async function loadStatistics() {
} catch (e) { el('stats-history').innerHTML = `
${e.message}
`; }
}
+// ── Card Catalog ──────────────────────────────────────────────────────────────
+
+let catalogCards = [];
+let catalogOwned = new Set();
+let catalogLimit = 80;
+
+async function loadCatalog() {
+ el('catalog-grid').innerHTML = '
Loading…';
+ try {
+ const [cardsData, collData] = await Promise.all([
+ api('GET', '/cards'),
+ api('GET', '/collection').catch(() => ({ collection: [] })),
+ ]);
+ catalogCards = cardsData.cards || [];
+ catalogOwned = new Set((collData.collection || []).map(i => i.card?.id).filter(Boolean));
+ catalogLimit = 80;
+ filterCatalog();
+ } catch (e) { el('catalog-grid').innerHTML = `
${e.message}
`; }
+}
+
+function filterCatalog() {
+ const name = (el('cat-name')?.value || '').toLowerCase();
+ const pos = el('cat-pos')?.value || '';
+ const rar = el('cat-rarity')?.value || '';
+ const nat = (el('cat-nation')?.value || '').toLowerCase();
+ const lea = (el('cat-league')?.value || '').toLowerCase();
+ const club = (el('cat-club')?.value || '').toLowerCase();
+ const minO = parseInt(el('cat-min-ovr')?.value || '0', 10) || 0;
+ const maxO = parseInt(el('cat-max-ovr')?.value || '99', 10) || 99;
+
+ const filtered = catalogCards.filter(c => {
+ if (name && !c.name.toLowerCase().includes(name)) return false;
+ if (pos && c.position !== pos) return false;
+ if (rar && (c.rarity||'').toLowerCase() !== rar) return false;
+ if (nat && !c.nation.toLowerCase().includes(nat)) return false;
+ if (lea && !c.league.toLowerCase().includes(lea)) return false;
+ if (club && !c.club.toLowerCase().includes(club)) return false;
+ if (c.overall < minO || c.overall > maxO) return false;
+ return true;
+ });
+
+ el('catalog-count').textContent = `${filtered.length} card${filtered.length !== 1 ? 's' : ''} in catalog`;
+
+ const shown = filtered.slice(0, catalogLimit);
+ el('catalog-grid').innerHTML = shown.map(c => {
+ const owned = catalogOwned.has(c.id);
+ return `
+ ${owned ? '
Owned' : ''}
+
${(c.rarity||'').toUpperCase()}
+
${c.overall}
+
${c.position}
+
${c.name}
+
${c.club}
+
+ ${statBar('PAC',c.pace)}${statBar('SHO',c.shooting)}${statBar('PAS',c.passing)}
+ ${statBar('DRI',c.dribbling)}${statBar('DEF',c.defending)}${statBar('PHY',c.physical)}
+
+
`;
+ }).join('');
+
+ const moreBtn = el('catalog-more-btn');
+ if (filtered.length > catalogLimit) {
+ moreBtn.style.display = '';
+ moreBtn.textContent = `Load more (${filtered.length - catalogLimit} remaining)`;
+ } else {
+ moreBtn.style.display = 'none';
+ }
+}
+
+function catalogLoadMore() {
+ catalogLimit += 80;
+ filterCatalog();
+}
+
+// ── Settings & Profile ────────────────────────────────────────────────────────
+
+async function loadSettings() {
+ try {
+ const [profile, settings] = await Promise.all([
+ api('GET', '/profile'),
+ api('GET', '/settings'),
+ ]);
+ const xpForNext = 1000;
+ const xpPct = Math.min(100, Math.round(((profile.xp || 0) % xpForNext) / xpForNext * 100));
+ el('profile-card').innerHTML = `
+
Username${profile.username || '—'}
+
Level${profile.level ?? 1}
+
XP${(profile.xp || 0).toLocaleString()}
+
+
Profile ID: ${profile.id}
+ `;
+ const diffSel = el('set-difficulty');
+ const frmSel = el('set-formation');
+ if (diffSel) diffSel.value = settings.difficulty || 'beginner';
+ if (frmSel) frmSel.value = settings.preferred_formation || '4-4-2';
+ } catch (e) {
+ el('profile-card').innerHTML = `
${e.message}
`;
+ }
+}
+
+async function saveSettings() {
+ const difficulty = el('set-difficulty').value;
+ const preferred_formation = el('set-formation').value;
+ try {
+ await api('PUT', '/settings', { difficulty, preferred_formation });
+ const saved = el('settings-saved');
+ saved.style.display = '';
+ setTimeout(() => { saved.style.display = 'none'; }, 2000);
+ } catch (e) { showToast(e.message, true); }
+}
+
// ── Squad ─────────────────────────────────────────────────────────────────────
async function loadSquad() {
@@ -1321,6 +1565,155 @@ async function loadSquad() {
}
}
+// ── Squad Builder ────────────────────────────────────────────────────────────
+
+// Formation → position labels for each of the 11 starter slots
+const FORMATION_POSITIONS = {
+ '4-4-2': ['GK','RB','CB','CB','LB','RM','CM','CM','LM','ST','ST'],
+ '4-3-3': ['GK','RB','CB','CB','LB','CM','CM','CM','RW','ST','LW'],
+ '4-2-3-1': ['GK','RB','CB','CB','LB','CDM','CDM','CAM','RW','LW','ST'],
+ '4-1-2-1-2':['GK','RB','CB','CB','LB','CDM','CM','CM','CAM','ST','ST'],
+ '3-5-2': ['GK','CB','CB','CB','RM','CDM','CM','LM','CAM','ST','ST'],
+ '5-3-2': ['GK','RB','CB','CB','CB','LB','CM','CM','CM','ST','ST'],
+};
+const BENCH_LABELS = ['SUB 1','SUB 2','SUB 3','SUB 4','SUB 5','SUB 6','SUB 7'];
+
+// Builder state: slot index → owned card id
+const builderStarters = {};
+const builderBench = {};
+let pickerTargetSlot = null; // { type:'starter'|'bench', index }
+
+function squadMode(mode) {
+ el('squad-view-mode').style.display = mode === 'view' ? '' : 'none';
+ el('squad-build-mode').style.display = mode === 'build' ? '' : 'none';
+ el('squad-view-btn').style.borderColor = mode === 'view' ? '#f5a623' : '';
+ el('squad-view-btn').style.color = mode === 'view' ? '#f5a623' : '';
+ el('squad-build-btn').style.borderColor = mode === 'build' ? '#f5a623' : '';
+ el('squad-build-btn').style.color = mode === 'build' ? '#f5a623' : '';
+ if (mode === 'build') {
+ rebuildSlots();
+ if (!allCards.length) {
+ api('GET', '/collection').then(d => { allCards = d.collection || []; }).catch(() => {});
+ }
+ }
+}
+
+function rebuildSlots() {
+ const formation = el('builder-formation')?.value || '4-4-2';
+ const positions = FORMATION_POSITIONS[formation] || FORMATION_POSITIONS['4-4-2'];
+ el('builder-starters').innerHTML = positions.map((pos, i) => slotHtml('starter', i, pos)).join('');
+ el('builder-bench').innerHTML = BENCH_LABELS.map((lbl, i) => slotHtml('bench', i, lbl)).join('');
+}
+
+function slotHtml(type, index, label) {
+ const store = type === 'starter' ? builderStarters : builderBench;
+ const ownedId = store[index];
+ const item = ownedId ? allCards.find(c => c.owned_card_id === ownedId) : null;
+ const isGk = label === 'GK';
+ if (item) {
+ const c = item.card;
+ const ovr = item.effective_overall ?? c.overall;
+ return `
+
${label}
+
✕
+
${ovr}
+
${c.name}
+
${c.position}
+
`;
+ }
+ return `
+
${label}
+
+ Pick player
+
`;
+}
+
+function openPicker(type, index, label) {
+ pickerTargetSlot = { type, index };
+ el('picker-slot-label').textContent = label;
+ el('picker-search').value = '';
+ el('picker-overlay').style.display = '';
+ renderPicker();
+}
+
+function closePicker() {
+ el('picker-overlay').style.display = 'none';
+ pickerTargetSlot = null;
+}
+
+function renderPicker() {
+ const q = el('picker-search').value.toLowerCase();
+ const used = new Set([
+ ...Object.values(builderStarters),
+ ...Object.values(builderBench),
+ ]);
+ const cards = allCards
+ .filter(i => !i.is_loan && !used.has(i.owned_card_id))
+ .filter(i => !q || i.card.name.toLowerCase().includes(q))
+ .sort((a, b) => (b.effective_overall ?? b.card.overall) - (a.effective_overall ?? a.card.overall))
+ .slice(0, 60);
+
+ el('picker-list').innerHTML = cards.map(i => {
+ const c = i.card;
+ const ovr = i.effective_overall ?? c.overall;
+ return `
+ ${ovr}
+ ${c.position}
+ ${c.name}
+ ${c.club}
+
`;
+ }).join('') || '
No matching cards.';
+}
+
+function pickSlotCard(ownedId) {
+ if (!pickerTargetSlot) return;
+ const { type, index } = pickerTargetSlot;
+ (type === 'starter' ? builderStarters : builderBench)[index] = ownedId;
+ closePicker();
+ rebuildSlots();
+}
+
+function clearSlot(type, index) {
+ delete (type === 'starter' ? builderStarters : builderBench)[index];
+ rebuildSlots();
+}
+
+function clearBuilder() {
+ Object.keys(builderStarters).forEach(k => delete builderStarters[k]);
+ Object.keys(builderBench).forEach(k => delete builderBench[k]);
+ rebuildSlots();
+}
+
+async function saveSquad() {
+ const formation = el('builder-formation').value;
+ const name = el('builder-name').value.trim() || 'My Squad';
+ const positions = FORMATION_POSITIONS[formation] || FORMATION_POSITIONS['4-4-2'];
+
+ const players = [];
+ for (let i = 0; i < positions.length; i++) {
+ if (builderStarters[i]) {
+ players.push({ owned_card_id: builderStarters[i], position_index: i, is_captain: i === 0, is_on_bench: false });
+ }
+ }
+ for (let i = 0; i < BENCH_LABELS.length; i++) {
+ if (builderBench[i]) {
+ players.push({ owned_card_id: builderBench[i], position_index: 11 + i, is_captain: false, is_on_bench: true });
+ }
+ }
+ if (players.filter(p => !p.is_on_bench).length !== 11) {
+ showToast('Fill all 11 starter slots before saving', true);
+ return;
+ }
+ try {
+ await api('POST', '/squad', { name, formation, players });
+ showToast(`Squad "${name}" saved!`);
+ squadMode('view');
+ loadSquad();
+ } catch (e) { showToast(e.message, true); }
+}
+
// ── Draft ─────────────────────────────────────────────────────────────────────
let activeDraftSession = null;
diff --git a/tests/proxy_test.rs b/tests/proxy_test.rs
index d71310f..e18a27a 100644
--- a/tests/proxy_test.rs
+++ b/tests/proxy_test.rs
@@ -248,6 +248,8 @@ async fn test_dashboard_contains_key_sections() {
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-catalog"), "card catalog tab missing");
+ assert!(html.contains("tab-settings"), "settings tab missing");
assert!(html.contains("tab-notifications"), "notifications tab missing");
}