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; + + + + @@ -265,6 +310,86 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em; + +
+
+
+
+

Active Squad

+ +
+
+
Loading…
+
+
+

Chemistry

+
Loading…
+
+
+
+ + +
+
+
+
+

FUT Draft

+ + + +
+
+
+
+
+

Picks So Far

+
+
+
+
+ + +
+
+
+

Transfer Market

+ + +
+
Loading…
+
+
+

Sell a Card

+
+ + + + + +
+
+

My Listings

+
Loading…
+
+
+
+ + +
+

+ Activate special events to unlock bonus cards in the market, score multipliers, and extra objectives. + All events are offline-only and have no expiry unless you deactivate them. +

+
Loading…
+
+
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 || '—'}
+
+ ` : ''}`; + + // 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 `
${p.position}
`; + 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 = '' + + 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(''); +} + +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'}
+ +
`; + }).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
+ +
+
`; + }).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 + ? `` + : ``} +
+
`; + }).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"); }