ba1b0a09f2
Match result display now shows a level-up row per level gained (new level, coins granted, milestone pack) and fires a toast notification. Settings/Profile card now uses xp_to_next_level and xp_for_next_level from the enhanced GET /profile response so the XP bar accurately shows progress within the current level rather than total XP %. Squad View mode gains an All Squads panel below Chemistry listing every saved squad (name + formation badge). Each row has a Delete button that calls DELETE /squads/:id and refreshes the view. Squads are loaded via GET /squads alongside the active squad on every Squad tab open. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2083 lines
102 KiB
HTML
2083 lines
102 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>OpenFUT — Club Dashboard</title>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body { font-family: system-ui, sans-serif; background: #0d1117; color: #c9d1d9; line-height: 1.5; }
|
||
|
||
/* ── Header ── */
|
||
header { background: #161b22; border-bottom: 1px solid #30363d; padding: 12px 24px;
|
||
display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||
header h1 { font-size: 1.1rem; color: #f5a623; }
|
||
.club-summary { margin-left: auto; display: flex; gap: 24px; align-items: center; font-size: 0.9rem; }
|
||
.coins { color: #f5a623; font-weight: 700; font-size: 1.1rem; }
|
||
.manager { color: #8b949e; }
|
||
.badge { font-size: 0.75rem; background: #238636; color: #fff; padding: 2px 8px; border-radius: 12px; }
|
||
|
||
/* ── Nav tabs ── */
|
||
nav { background: #161b22; border-bottom: 1px solid #30363d; padding: 0 24px; display: flex; gap: 0; overflow-x: auto; }
|
||
nav button { background: none; border: none; color: #8b949e; padding: 12px 18px; cursor: pointer;
|
||
font-size: 0.9rem; border-bottom: 2px solid transparent; white-space: nowrap; }
|
||
nav button:hover { color: #c9d1d9; }
|
||
nav button.active { color: #f5a623; border-bottom-color: #f5a623; }
|
||
|
||
/* ── Main ── */
|
||
main { padding: 24px; max-width: 1280px; margin: 0 auto; }
|
||
.tab-panel { display: none; }
|
||
.tab-panel.active { display: block; }
|
||
|
||
/* ── Cards ── */
|
||
.card-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; margin-top: 16px; }
|
||
.player-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 12px;
|
||
position: relative; cursor: default; transition: border-color 0.15s; }
|
||
.player-card:hover { border-color: #f5a623; }
|
||
.card-ovr { font-size: 1.6rem; font-weight: 800; color: #f5a623; line-height: 1; }
|
||
.card-pos { font-size: 0.7rem; color: #8b949e; text-transform: uppercase; letter-spacing: 0.05em; }
|
||
.card-name { font-size: 0.85rem; font-weight: 600; margin: 4px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.card-meta { font-size: 0.72rem; color: #8b949e; }
|
||
.card-rarity { position: absolute; top: 8px; right: 8px; font-size: 0.65rem;
|
||
padding: 1px 5px; border-radius: 10px; }
|
||
.rarity-gold, .rarity-raregold { background: #78350f; color: #fcd34d; }
|
||
.rarity-silver { background: #374151; color: #d1d5db; }
|
||
.rarity-bronze { background: #451a03; color: #fbbf24; }
|
||
.rarity-totw, .rarity-icon, .rarity-hero { background: #312e81; color: #a5b4fc; }
|
||
.card-upgrades { display: flex; gap: 4px; margin-top: 6px; flex-wrap: wrap; }
|
||
.upgrade-pill { font-size: 0.65rem; padding: 1px 6px; border-radius: 10px; }
|
||
.chem-pill { background: #1c4532; color: #6ee7b7; }
|
||
.train-pill { background: #1e3a5f; color: #93c5fd; }
|
||
.loan-pill { background: #451a03; color: #fbbf24; }
|
||
|
||
/* ── Stat bars ── */
|
||
.stat-bar-row { display: flex; gap: 6px; align-items: center; font-size: 0.7rem; margin: 2px 0; }
|
||
.stat-bar-label { width: 28px; color: #8b949e; text-align: right; }
|
||
.stat-bar-track { flex: 1; height: 4px; background: #21262d; border-radius: 2px; }
|
||
.stat-bar-fill { height: 100%; border-radius: 2px; background: #3fb950; }
|
||
|
||
/* ── Filters ── */
|
||
.filters { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
|
||
.filters input, .filters select { background: #161b22; border: 1px solid #30363d;
|
||
color: #c9d1d9; padding: 6px 10px; border-radius: 6px; font-size: 0.85rem; }
|
||
.filters input:focus, .filters select:focus { outline: none; border-color: #f5a623; }
|
||
|
||
/* ── Packs ── */
|
||
.pack-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; margin-top: 16px; }
|
||
.pack-item { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
|
||
.pack-item h3 { font-size: 0.95rem; color: #f5a623; margin-bottom: 4px; }
|
||
.pack-item .pack-id { font-size: 0.7rem; color: #8b949e; word-break: break-all; margin-bottom: 12px; }
|
||
|
||
/* ── Objectives ── */
|
||
.obj-list { display: grid; gap: 10px; margin-top: 16px; }
|
||
.obj-item { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 14px; }
|
||
.obj-item.completed { border-color: #238636; }
|
||
.obj-item.claimed { border-color: #30363d; opacity: 0.6; }
|
||
.obj-header { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
|
||
.obj-title { font-size: 0.9rem; font-weight: 600; }
|
||
.obj-reward { font-size: 0.8rem; color: #f5a623; }
|
||
.progress-track { height: 6px; background: #21262d; border-radius: 3px; margin-top: 8px; }
|
||
.progress-fill { height: 100%; border-radius: 3px; background: #1f6feb; }
|
||
.progress-fill.done { background: #238636; }
|
||
.obj-meta { font-size: 0.75rem; color: #8b949e; margin-top: 4px;
|
||
display: flex; justify-content: space-between; }
|
||
|
||
/* ── Division ── */
|
||
.division-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 24px; max-width: 480px; }
|
||
.division-num { font-size: 3rem; font-weight: 800; color: #f5a623; line-height: 1; }
|
||
.division-label { color: #8b949e; font-size: 0.85rem; margin-bottom: 16px; }
|
||
.record-row { display: flex; gap: 24px; margin: 12px 0; }
|
||
.record-cell { text-align: center; }
|
||
.record-cell .val { font-size: 1.4rem; font-weight: 700; }
|
||
.record-cell.w .val { color: #3fb950; }
|
||
.record-cell.d .val { color: #8b949e; }
|
||
.record-cell.l .val { color: #f85149; }
|
||
.record-cell .lbl { font-size: 0.7rem; color: #8b949e; }
|
||
|
||
/* ── FUT Champs ── */
|
||
.champs-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 24px; max-width: 560px; }
|
||
.tier-badge { display: inline-block; padding: 4px 14px; border-radius: 20px; font-weight: 700; font-size: 0.85rem;
|
||
background: #312e81; color: #a5b4fc; margin: 8px 0 16px; }
|
||
.tier-Elite { background: #78350f; color: #fcd34d; }
|
||
.tier-Gold { background: #713f12; color: #fcd34d; }
|
||
.tier-Silver { background: #374151; color: #d1d5db; }
|
||
.tier-Bronze { background: #451a03; color: #d97706; }
|
||
|
||
/* ── Notifications ── */
|
||
.notif-list { display: grid; gap: 8px; margin-top: 16px; }
|
||
.notif-item { background: #161b22; border-left: 3px solid #f5a623; border-radius: 0 6px 6px 0; padding: 12px 16px; }
|
||
.notif-item.loan_expiring { border-color: #d97706; }
|
||
.notif-item.season_ending { border-color: #1f6feb; }
|
||
.notif-item.objective_complete { border-color: #238636; }
|
||
.notif-title { font-size: 0.9rem; font-weight: 600; }
|
||
.notif-msg { font-size: 0.8rem; color: #8b949e; margin-top: 2px; }
|
||
|
||
/* ── Buttons ── */
|
||
.btn { display: inline-block; padding: 7px 14px; border-radius: 6px; border: none;
|
||
cursor: pointer; font-size: 0.85rem; font-weight: 500; }
|
||
.btn-primary { background: #f5a623; color: #000; }
|
||
.btn-primary:hover { background: #d4851a; }
|
||
.btn-secondary { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; }
|
||
.btn-secondary:hover { background: #30363d; }
|
||
.btn-danger { background: #b91c1c; color: #fff; }
|
||
.btn-danger:hover { background: #991b1b; }
|
||
.btn-sm { padding: 4px 10px; font-size: 0.78rem; }
|
||
|
||
/* ── Misc ── */
|
||
.empty { color: #8b949e; font-size: 0.9rem; padding: 24px 0; }
|
||
.loading { color: #8b949e; font-size: 0.9rem; }
|
||
.error-msg { color: #f85149; font-size: 0.85rem; padding: 8px; background: #2d1515; border-radius: 4px; }
|
||
.row-actions { display: flex; gap: 8px; align-items: center; margin-top: 10px; }
|
||
h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
|
||
color: #8b949e; border-bottom: 1px solid #30363d; padding-bottom: 8px; margin-bottom: 16px; }
|
||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
|
||
@media (max-width: 640px) { .two-col { grid-template-columns: 1fr; } }
|
||
.section { margin-bottom: 32px; }
|
||
.info-row { display: flex; gap: 8px; align-items: baseline; margin: 4px 0; font-size: 0.85rem; }
|
||
.info-row .lbl { color: #8b949e; width: 110px; flex-shrink: 0; }
|
||
.modal-backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 100; align-items: center; justify-content: center; }
|
||
.modal-backdrop.open { display: flex; }
|
||
.modal { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 24px;
|
||
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; }
|
||
|
||
/* ── Catalog ── */
|
||
.catalog-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(175px, 1fr)); gap: 10px; margin-top: 12px; }
|
||
.owned-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 8px;
|
||
border: 2px solid #3fb950; pointer-events: none; }
|
||
.owned-tag { position: absolute; bottom: 6px; left: 6px; font-size: 0.62rem; background: #14532d;
|
||
color: #4ade80; padding: 1px 5px; border-radius: 8px; }
|
||
.catalog-count { font-size: 0.8rem; color: #8b949e; margin: 8px 0; }
|
||
|
||
/* ── Settings ── */
|
||
.settings-form { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; max-width: 480px; }
|
||
.settings-form label { font-size: 0.8rem; color: #8b949e; display: block; margin-bottom: 4px; }
|
||
.settings-form select, .settings-form input {
|
||
width: 100%; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9;
|
||
padding: 8px 10px; border-radius: 6px; font-size: 0.85rem; margin-bottom: 14px; }
|
||
.profile-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 20px; max-width: 480px; }
|
||
.xp-bar { height: 6px; background: #21262d; border-radius: 3px; margin: 6px 0 14px; }
|
||
.xp-fill { height: 100%; border-radius: 3px; background: #1f6feb; }
|
||
|
||
/* ── Squad Builder ── */
|
||
.builder-slots { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; margin: 12px 0; }
|
||
.builder-slot { background: #21262d; border: 1px dashed #30363d; border-radius: 6px; padding: 8px;
|
||
min-height: 60px; cursor: pointer; transition: border-color 0.15s; position: relative; }
|
||
.builder-slot:hover { border-color: #f5a623; }
|
||
.builder-slot.filled { background: #161b22; border-style: solid; border-color: #30363d; }
|
||
.builder-slot.gk-slot { border-color: #1f6feb44; }
|
||
.slot-label { font-size: 0.68rem; color: #8b949e; text-transform: uppercase; margin-bottom: 4px; }
|
||
.slot-clear { position: absolute; top: 4px; right: 6px; font-size: 0.7rem; color: #f85149;
|
||
cursor: pointer; line-height: 1; }
|
||
|
||
/* ── 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; }
|
||
.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; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<h1>⚽ OpenFUT Dashboard</h1>
|
||
<span class="badge">Offline</span>
|
||
<div class="club-summary">
|
||
<span class="manager" id="hdr-manager">—</span>
|
||
<span id="hdr-club">—</span>
|
||
<span class="coins" id="hdr-coins">— coins</span>
|
||
</div>
|
||
</header>
|
||
|
||
<nav>
|
||
<button class="active" onclick="showTab('club')">Club</button>
|
||
<button onclick="showTab('collection')">Collection</button>
|
||
<button onclick="showTab('packs')">Packs</button>
|
||
<button onclick="showTab('objectives')">Objectives</button>
|
||
<button onclick="showTab('division')">Division</button>
|
||
<button onclick="showTab('champs')">FUT Champs</button>
|
||
<button onclick="showTab('catalog')">Card Catalog</button>
|
||
<button onclick="showTab('settings')">Settings</button>
|
||
<button onclick="showTab('matches')">Matches</button>
|
||
<button onclick="showTab('store')">Pack Store</button>
|
||
<button onclick="showTab('sbc')">SBC</button>
|
||
<button onclick="showTab('statistics')">Statistics</button>
|
||
<button onclick="showTab('squad')">Squad</button>
|
||
<button onclick="showTab('draft')">Draft</button>
|
||
<button onclick="showTab('market')">Market</button>
|
||
<button onclick="showTab('events')">Events</button>
|
||
<button onclick="showTab('notifications')">Notifications <span id="notif-badge"></span></button>
|
||
</nav>
|
||
|
||
<main>
|
||
|
||
<!-- ── Club tab ────────────────────────────────────────────────────────────── -->
|
||
<div id="tab-club" class="tab-panel active">
|
||
<div class="two-col">
|
||
<div class="section">
|
||
<h2>Club Info</h2>
|
||
<div id="club-info"><span class="loading">Loading…</span></div>
|
||
<div class="row-actions" style="margin-top:16px">
|
||
<input id="edit-name" placeholder="Club name" style="background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:6px 10px;border-radius:6px;font-size:0.85rem">
|
||
<input id="edit-manager" placeholder="Manager name" style="background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:6px 10px;border-radius:6px;font-size:0.85rem">
|
||
<button class="btn btn-primary btn-sm" onclick="saveClub()">Save</button>
|
||
</div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>Statistics</h2>
|
||
<div id="stats-info"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Collection tab ─────────────────────────────────────────────────────── -->
|
||
<div id="tab-collection" class="tab-panel">
|
||
<div class="filters">
|
||
<input id="f-name" placeholder="Name…" oninput="filterCards()">
|
||
<select id="f-pos" onchange="filterCards()">
|
||
<option value="">All positions</option>
|
||
<option>GK</option><option>CB</option><option>RB</option><option>LB</option>
|
||
<option>CDM</option><option>CM</option><option>CAM</option>
|
||
<option>RM</option><option>LM</option><option>RW</option><option>LW</option>
|
||
<option>CF</option><option>ST</option>
|
||
</select>
|
||
<select id="f-rarity" onchange="filterCards()">
|
||
<option value="">All rarities</option>
|
||
<option value="gold">Gold</option><option value="raregold">Rare Gold</option>
|
||
<option value="silver">Silver</option><option value="bronze">Bronze</option>
|
||
<option value="totw">TOTW</option><option value="icon">Icon</option>
|
||
</select>
|
||
<input id="f-club" placeholder="Club…" oninput="filterCards()">
|
||
<input id="f-nation" placeholder="Nation…" oninput="filterCards()">
|
||
<select id="f-sort" onchange="filterCards()">
|
||
<option value="ovr">Sort: OVR ↓</option>
|
||
<option value="name">Sort: Name</option>
|
||
<option value="pos">Sort: Position</option>
|
||
</select>
|
||
</div>
|
||
<div id="collection-count" style="font-size:0.8rem;color:#8b949e;margin-bottom:8px"></div>
|
||
<div id="collection-grid" class="card-grid"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
|
||
<!-- ── Packs tab ─────────────────────────────────────────────────────────── -->
|
||
<div id="tab-packs" class="tab-panel">
|
||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:16px">
|
||
<h2 style="border:none;margin:0">Unopened Packs</h2>
|
||
<button class="btn btn-secondary btn-sm" onclick="loadPacks()">Refresh</button>
|
||
</div>
|
||
<div id="packs-grid" class="pack-grid"><span class="loading">Loading…</span></div>
|
||
<div class="section" style="margin-top:32px">
|
||
<h2>Pack History</h2>
|
||
<div id="pack-history"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Objectives tab ────────────────────────────────────────────────────── -->
|
||
<div id="tab-objectives" class="tab-panel">
|
||
<div id="objectives-list" class="obj-list"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
|
||
<!-- ── Division tab ──────────────────────────────────────────────────────── -->
|
||
<div id="tab-division" class="tab-panel">
|
||
<div class="two-col">
|
||
<div>
|
||
<h2>Division Rivals</h2>
|
||
<div id="division-card"><span class="loading">Loading…</span></div>
|
||
<div style="margin-top:16px">
|
||
<button class="btn btn-primary" onclick="claimRivalsReward()">Claim Weekly Reward</button>
|
||
<p style="font-size:0.75rem;color:#8b949e;margin-top:6px">Offline mode: claim whenever you're ready to end your rivals week.</p>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<h2>Season Progress</h2>
|
||
<div id="season-progress"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── FUT Champions tab ─────────────────────────────────────────────────── -->
|
||
<div id="tab-champs" class="tab-panel">
|
||
<div class="two-col">
|
||
<div>
|
||
<h2>Current Session</h2>
|
||
<div id="champs-current"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
<div>
|
||
<h2>Past Sessions</h2>
|
||
<div id="champs-history"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Card Catalog tab ───────────────────────────────────────────────────── -->
|
||
<div id="tab-catalog" class="tab-panel">
|
||
<div class="filters">
|
||
<input id="cat-name" placeholder="Name…" oninput="filterCatalog()">
|
||
<select id="cat-pos" onchange="filterCatalog()">
|
||
<option value="">All positions</option>
|
||
<option>GK</option><option>CB</option><option>RB</option><option>LB</option>
|
||
<option>CDM</option><option>CM</option><option>CAM</option>
|
||
<option>RM</option><option>LM</option><option>RW</option><option>LW</option>
|
||
<option>CF</option><option>ST</option>
|
||
</select>
|
||
<select id="cat-rarity" onchange="filterCatalog()">
|
||
<option value="">All rarities</option>
|
||
<option value="gold">Gold</option><option value="raregold">Rare Gold</option>
|
||
<option value="silver">Silver</option><option value="bronze">Bronze</option>
|
||
<option value="totw">TOTW</option><option value="icon">Icon</option>
|
||
</select>
|
||
<input id="cat-nation" placeholder="Nation…" oninput="filterCatalog()">
|
||
<input id="cat-league" placeholder="League…" oninput="filterCatalog()">
|
||
<input id="cat-club" placeholder="Club…" oninput="filterCatalog()">
|
||
<input id="cat-min-ovr" type="number" placeholder="Min OVR" min="1" max="99" oninput="filterCatalog()" style="width:80px">
|
||
<input id="cat-max-ovr" type="number" placeholder="Max OVR" min="1" max="99" oninput="filterCatalog()" style="width:80px">
|
||
</div>
|
||
<div id="catalog-count" class="catalog-count"></div>
|
||
<div id="catalog-grid" class="catalog-grid"><span class="loading">Loading…</span></div>
|
||
<div style="margin-top:16px;text-align:center">
|
||
<button id="catalog-more-btn" class="btn btn-secondary" style="display:none" onclick="catalogLoadMore()">Load more</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Settings tab ───────────────────────────────────────────────────────── -->
|
||
<div id="tab-settings" class="tab-panel">
|
||
<div class="two-col">
|
||
<div class="section">
|
||
<h2>Profile</h2>
|
||
<div class="profile-card" id="profile-card"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>Game Settings</h2>
|
||
<div class="settings-form">
|
||
<label>Default match difficulty</label>
|
||
<select id="set-difficulty">
|
||
<option value="beginner">Beginner</option>
|
||
<option value="professional">Professional</option>
|
||
<option value="world_class">World Class</option>
|
||
<option value="legendary">Legendary</option>
|
||
<option value="ultimate">Ultimate</option>
|
||
</select>
|
||
<label>Preferred formation</label>
|
||
<select id="set-formation">
|
||
<option>4-4-2</option><option>4-3-3</option><option>4-2-3-1</option>
|
||
<option>4-1-2-1-2</option><option>3-5-2</option><option>5-3-2</option>
|
||
</select>
|
||
<button class="btn btn-primary btn-sm" onclick="saveSettings()">Save Settings</button>
|
||
<div id="settings-saved" style="font-size:0.8rem;color:#3fb950;margin-top:8px;display:none">✓ Saved</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Matches tab ───────────────────────────────────────────────────────── -->
|
||
<div id="tab-matches" class="tab-panel">
|
||
<div class="two-col">
|
||
<div>
|
||
<h2>Simulate Match</h2>
|
||
<div class="match-form">
|
||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap">
|
||
<label style="font-size:0.8rem;color:#8b949e">Difficulty:</label>
|
||
<select id="match-difficulty" style="background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:4px 8px;border-radius:6px;font-size:0.8rem">
|
||
<option value="beginner">Beginner</option>
|
||
<option value="professional" selected>Professional</option>
|
||
<option value="world_class">World Class</option>
|
||
<option value="legendary">Legendary</option>
|
||
<option value="ultimate">Ultimate</option>
|
||
</select>
|
||
<button class="btn btn-secondary btn-sm" onclick="generateOpponent()">Generate Opponent</button>
|
||
</div>
|
||
<div id="opponent-display"></div>
|
||
<div class="score-row">
|
||
<input id="goals-for" class="score-input" type="number" min="0" max="20" value="0">
|
||
<span class="score-sep">—</span>
|
||
<input id="goals-against" class="score-input" type="number" min="0" max="20" value="0">
|
||
</div>
|
||
<div style="font-size:0.75rem;color:#8b949e;margin-bottom:12px">Your goals — Opponent goals</div>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||
<button class="btn btn-secondary btn-sm" onclick="quickScore(3,0)">Win 3-0</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="quickScore(1,1)">Draw 1-1</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="quickScore(0,2)">Loss 0-2</button>
|
||
</div>
|
||
<div style="margin-top:16px">
|
||
<button class="btn btn-primary" onclick="submitMatch()">Submit Result</button>
|
||
</div>
|
||
<div id="match-result-display" style="margin-top:14px"></div>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<h2>Match History</h2>
|
||
<div id="match-history"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Pack Store tab ─────────────────────────────────────────────────────── -->
|
||
<div id="tab-store" class="tab-panel">
|
||
<p style="font-size:0.85rem;color:#8b949e;margin-bottom:16px">
|
||
Purchase packs with your coins. Bought packs appear in the Packs tab ready to open.
|
||
</p>
|
||
<div id="store-grid" class="pack-grid"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
|
||
<!-- ── SBC tab ────────────────────────────────────────────────────────────── -->
|
||
<div id="tab-sbc" class="tab-panel">
|
||
<p style="font-size:0.85rem;color:#8b949e;margin-bottom:16px">
|
||
Complete Squad Building Challenges to earn coins, XP, and packs. Cards submitted are consumed.
|
||
</p>
|
||
<div id="sbc-list" class="sbc-list"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
|
||
<!-- ── Statistics tab ────────────────────────────────────────────────────── -->
|
||
<div id="tab-statistics" class="tab-panel">
|
||
<div class="two-col">
|
||
<div>
|
||
<h2>Career Stats</h2>
|
||
<div id="career-stats"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
<div>
|
||
<h2>Goals by Position</h2>
|
||
<div id="position-goals"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
</div>
|
||
<div class="section" style="margin-top:24px">
|
||
<h2>Recent Match Log</h2>
|
||
<div id="stats-history"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Squad tab ─────────────────────────────────────────────────────────── -->
|
||
<div id="tab-squad" class="tab-panel">
|
||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:16px;flex-wrap:wrap">
|
||
<h2 style="border:none;margin:0">Squad</h2>
|
||
<button class="btn btn-secondary btn-sm" id="squad-view-btn" onclick="squadMode('view')" style="border-color:#f5a623;color:#f5a623">View</button>
|
||
<button class="btn btn-secondary btn-sm" id="squad-build-btn" onclick="squadMode('build')">Build</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="loadSquad()">Refresh</button>
|
||
</div>
|
||
<!-- View mode -->
|
||
<div id="squad-view-mode">
|
||
<div class="two-col">
|
||
<div>
|
||
<div id="squad-header"></div>
|
||
<div id="squad-grid" class="squad-grid"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
<div>
|
||
<h2>Chemistry</h2>
|
||
<div id="squad-chemistry"><span class="loading">Loading…</span></div>
|
||
<div style="margin-top:24px">
|
||
<h2>All Squads</h2>
|
||
<div id="squad-list"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<!-- Build mode -->
|
||
<div id="squad-build-mode" style="display:none">
|
||
<div class="two-col" style="margin-bottom:16px">
|
||
<div>
|
||
<label style="font-size:0.8rem;color:#8b949e">Squad name</label>
|
||
<input id="builder-name" placeholder="My Squad" style="background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:6px 10px;border-radius:6px;font-size:0.85rem;width:100%;margin:4px 0 12px">
|
||
</div>
|
||
<div>
|
||
<label style="font-size:0.8rem;color:#8b949e">Formation</label>
|
||
<select id="builder-formation" onchange="rebuildSlots()" style="background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:6px 10px;border-radius:6px;font-size:0.85rem;width:100%;margin:4px 0 12px">
|
||
<option>4-4-2</option><option>4-3-3</option><option>4-2-3-1</option>
|
||
<option>4-1-2-1-2</option><option>3-5-2</option><option>5-3-2</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<h2>Starters (11)</h2>
|
||
<div id="builder-starters" class="builder-slots"></div>
|
||
<h2 style="margin-top:16px">Bench (up to 7)</h2>
|
||
<div id="builder-bench" class="builder-slots"></div>
|
||
<div style="margin-top:16px;display:flex;gap:8px">
|
||
<button class="btn btn-primary" onclick="saveSquad()">Save Squad</button>
|
||
<button class="btn btn-secondary" onclick="clearBuilder()">Clear All</button>
|
||
</div>
|
||
<!-- Mini card picker overlay -->
|
||
<div id="picker-overlay" style="display:none;margin-top:16px">
|
||
<div style="background:#161b22;border:1px solid #30363d;border-radius:8px;padding:16px;max-width:600px">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||
<strong style="font-size:0.9rem">Pick player for slot <span id="picker-slot-label"></span></strong>
|
||
<button class="btn btn-secondary btn-sm" onclick="closePicker()">Cancel</button>
|
||
</div>
|
||
<input id="picker-search" placeholder="Filter by name…" oninput="renderPicker()"
|
||
style="background:#0d1117;border:1px solid #30363d;color:#c9d1d9;padding:6px 10px;border-radius:6px;font-size:0.85rem;width:100%;margin-bottom:10px">
|
||
<div id="picker-list" style="max-height:280px;overflow-y:auto;display:grid;gap:4px"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Draft tab ──────────────────────────────────────────────────────────── -->
|
||
<div id="tab-draft" class="tab-panel">
|
||
<div class="two-col">
|
||
<div>
|
||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:16px;flex-wrap:wrap">
|
||
<h2 style="border:none;margin:0">FUT Draft</h2>
|
||
<select id="draft-difficulty" style="background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:4px 8px;border-radius:6px;font-size:0.8rem">
|
||
<option value="beginner">Beginner</option>
|
||
<option value="professional" selected>Professional</option>
|
||
<option value="world_class">World Class</option>
|
||
<option value="legendary">Legendary</option>
|
||
<option value="ultimate">Ultimate</option>
|
||
</select>
|
||
<button class="btn btn-primary btn-sm" id="draft-start-btn" onclick="startDraft()">New Draft</button>
|
||
<button class="btn btn-danger btn-sm" id="draft-abandon-btn" style="display:none" onclick="abandonDraft()">Abandon</button>
|
||
</div>
|
||
<div id="draft-status"></div>
|
||
<div id="draft-candidates" class="draft-candidates"></div>
|
||
</div>
|
||
<div>
|
||
<h2>Picks So Far</h2>
|
||
<div id="draft-picks" class="squad-grid"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Market tab ────────────────────────────────────────────────────────── -->
|
||
<div id="tab-market" class="tab-panel">
|
||
<div class="section">
|
||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:16px;flex-wrap:wrap">
|
||
<h2 style="border:none;margin:0">Transfer Market</h2>
|
||
<button class="btn btn-secondary btn-sm" onclick="refreshMarket()">Refresh Listings</button>
|
||
<input id="m-search" placeholder="Search player…" oninput="filterMarket()"
|
||
style="background:#21262d;border:1px solid #30363d;color:#c9d1d9;padding:5px 10px;border-radius:6px;font-size:0.8rem">
|
||
</div>
|
||
<div id="market-grid" class="market-grid"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
<div class="section">
|
||
<h2>Sell a Card</h2>
|
||
<div class="sell-form">
|
||
<label style="font-size:0.8rem;color:#8b949e">Select card from collection</label>
|
||
<select id="sell-card-select"><option value="">— choose card —</option></select>
|
||
<label style="font-size:0.8rem;color:#8b949e">Buy-now price (coins)</label>
|
||
<input id="sell-price" type="number" min="100" step="50" value="1000" placeholder="Price">
|
||
<button class="btn btn-primary btn-sm" onclick="sellCard()">List for Sale</button>
|
||
</div>
|
||
<div class="section" style="margin-top:16px">
|
||
<h2>My Listings</h2>
|
||
<div id="my-listings"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── Events tab ────────────────────────────────────────────────────────── -->
|
||
<div id="tab-events" class="tab-panel">
|
||
<p style="font-size:0.85rem;color:#8b949e;margin-bottom:16px">
|
||
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.
|
||
</p>
|
||
<div id="events-grid" class="events-grid"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
|
||
<!-- ── Notifications tab ─────────────────────────────────────────────────── -->
|
||
<div id="tab-notifications" class="tab-panel">
|
||
<div id="notif-list" class="notif-list"><span class="loading">Loading…</span></div>
|
||
</div>
|
||
|
||
</main>
|
||
|
||
<!-- Pack open result modal -->
|
||
<div class="modal-backdrop" id="pack-modal">
|
||
<div class="modal">
|
||
<h3>Pack Opened!</h3>
|
||
<div id="modal-cards" class="opened-cards"></div>
|
||
<div style="margin-top:16px;text-align:right">
|
||
<button class="btn btn-secondary" onclick="closeModal()">Close</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const CORE = '{{CORE_URL}}';
|
||
let allCards = [];
|
||
|
||
// ── Utilities ────────────────────────────────────────────────────────────────
|
||
|
||
async function api(method, path, body) {
|
||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||
if (body) opts.body = JSON.stringify(body);
|
||
const r = await fetch(CORE + path, opts);
|
||
const j = await r.json().catch(() => ({ error: 'invalid JSON' }));
|
||
if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
|
||
return j;
|
||
}
|
||
|
||
function el(id) { return document.getElementById(id); }
|
||
|
||
function showToast(msg, isError = false) {
|
||
const t = document.createElement('div');
|
||
t.textContent = msg;
|
||
t.style.cssText = `position:fixed;bottom:24px;right:24px;padding:10px 18px;border-radius:6px;
|
||
background:${isError ? '#7f1d1d' : '#14532d'};color:#fff;font-size:0.85rem;z-index:200;
|
||
box-shadow:0 4px 12px rgba(0,0,0,0.4)`;
|
||
document.body.appendChild(t);
|
||
setTimeout(() => t.remove(), 3500);
|
||
}
|
||
|
||
function showTab(name) {
|
||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||
document.querySelectorAll('nav button').forEach(b => b.classList.remove('active'));
|
||
el('tab-' + name).classList.add('active');
|
||
event.target.classList.add('active');
|
||
({ club: loadClub, collection: loadCollection, packs: loadPacks,
|
||
objectives: loadObjectives, division: loadDivision,
|
||
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]?.();
|
||
}
|
||
|
||
function rarityClass(r) {
|
||
return 'rarity-' + (r || 'bronze').toLowerCase().replace(' ', '');
|
||
}
|
||
|
||
function statBar(label, val) {
|
||
const pct = Math.min(100, val);
|
||
const color = val >= 80 ? '#3fb950' : val >= 65 ? '#d29922' : '#f85149';
|
||
return `<div class="stat-bar-row">
|
||
<span class="stat-bar-label">${label}</span>
|
||
<div class="stat-bar-track"><div class="stat-bar-fill" style="width:${pct}%;background:${color}"></div></div>
|
||
<span style="font-size:0.7rem;width:22px;text-align:right">${val}</span>
|
||
</div>`;
|
||
}
|
||
|
||
function renderPlayerCard(item) {
|
||
const c = item.card;
|
||
const ovr = item.effective_overall ?? c.overall;
|
||
const pos = item.effective_position ?? c.position;
|
||
const trainBonus = item.training_bonus > 0 ? `+${item.training_bonus}` : '';
|
||
const chemStyle = item.chemistry_style && item.chemistry_style !== 'basic' ? item.chemistry_style : '';
|
||
const isLoan = item.is_loan ? `<span class="upgrade-pill loan-pill">LOAN${item.loan_matches_remaining != null ? ' ' + item.loan_matches_remaining : ''}</span>` : '';
|
||
const pills = [
|
||
chemStyle ? `<span class="upgrade-pill chem-pill">${chemStyle}</span>` : '',
|
||
trainBonus ? `<span class="upgrade-pill train-pill">OVR${trainBonus}</span>` : '',
|
||
isLoan,
|
||
].join('');
|
||
|
||
return `<div class="player-card" title="${c.club} · ${c.league} · ${c.nation}">
|
||
<span class="card-rarity ${rarityClass(c.rarity)}">${(c.rarity||'').toUpperCase()}</span>
|
||
<div class="card-ovr">${ovr}</div>
|
||
<div class="card-pos">${pos}</div>
|
||
<div class="card-name">${c.name}</div>
|
||
<div class="card-meta">${c.club}</div>
|
||
<div style="margin:6px 0 4px">
|
||
${statBar('PAC', c.pace)}${statBar('SHO', c.shooting)}${statBar('PAS', c.passing)}
|
||
${statBar('DRI', c.dribbling)}${statBar('DEF', c.defending)}${statBar('PHY', c.physical)}
|
||
</div>
|
||
${pills ? `<div class="card-upgrades">${pills}</div>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
// ── Club ─────────────────────────────────────────────────────────────────────
|
||
|
||
async function loadClub() {
|
||
try {
|
||
const club = await api('GET', '/club');
|
||
const profile = await api('GET', '/profile').catch(() => ({}));
|
||
const name = club.name || '—';
|
||
const mgr = club.manager_name || 'Player Manager';
|
||
el('hdr-club').textContent = name;
|
||
el('hdr-manager').textContent = mgr;
|
||
el('hdr-coins').textContent = (club.coins ?? 0).toLocaleString() + ' coins';
|
||
el('edit-name').value = name;
|
||
el('edit-manager').value = mgr;
|
||
el('club-info').innerHTML = `
|
||
<div class="info-row"><span class="lbl">Club name</span><strong>${name}</strong></div>
|
||
<div class="info-row"><span class="lbl">Manager</span>${mgr}</div>
|
||
<div class="info-row"><span class="lbl">Coins</span><strong style="color:#f5a623">${(club.coins ?? 0).toLocaleString()}</strong></div>
|
||
<div class="info-row"><span class="lbl">Level</span>${club.level ?? 1}</div>
|
||
<div class="info-row"><span class="lbl">Club ID</span><code style="font-size:0.75rem;color:#8b949e">${club.id}</code></div>
|
||
`;
|
||
} catch (e) { el('club-info').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||
|
||
try {
|
||
const s = await api('GET', '/statistics');
|
||
el('stats-info').innerHTML = `
|
||
<div class="info-row"><span class="lbl">Matches</span>${s.matches_played ?? 0}</div>
|
||
<div class="info-row"><span class="lbl">Wins</span><span style="color:#3fb950">${s.wins ?? 0}</span></div>
|
||
<div class="info-row"><span class="lbl">Draws</span>${s.draws ?? 0}</div>
|
||
<div class="info-row"><span class="lbl">Losses</span><span style="color:#f85149">${s.losses ?? 0}</span></div>
|
||
<div class="info-row"><span class="lbl">Goals scored</span>${s.goals_scored ?? 0}</div>
|
||
<div class="info-row"><span class="lbl">Goals conceded</span>${s.goals_conceded ?? 0}</div>
|
||
<div class="info-row"><span class="lbl">Packs opened</span>${s.packs_opened ?? 0}</div>
|
||
<div class="info-row"><span class="lbl">Win streak</span>${s.current_win_streak ?? 0}</div>
|
||
`;
|
||
} catch (e) { el('stats-info').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||
}
|
||
|
||
async function saveClub() {
|
||
const name = el('edit-name').value.trim();
|
||
const mgr = el('edit-manager').value.trim();
|
||
if (!name && !mgr) return;
|
||
try {
|
||
await api('PUT', '/club', { name: name || undefined, manager_name: mgr || undefined });
|
||
showToast('Club updated');
|
||
loadClub();
|
||
} catch (e) { showToast(e.message, true); }
|
||
}
|
||
|
||
// ── Collection ───────────────────────────────────────────────────────────────
|
||
|
||
async function loadCollection() {
|
||
try {
|
||
const data = await api('GET', '/collection');
|
||
allCards = data.collection || [];
|
||
filterCards();
|
||
} catch (e) { el('collection-grid').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||
}
|
||
|
||
function filterCards() {
|
||
const name = el('f-name').value.toLowerCase();
|
||
const pos = el('f-pos').value;
|
||
const rarity = el('f-rarity').value;
|
||
const club = el('f-club').value.toLowerCase();
|
||
const nation = el('f-nation').value.toLowerCase();
|
||
const sort = el('f-sort').value;
|
||
|
||
let cards = allCards.filter(item => {
|
||
const c = item.card;
|
||
if (!c) return false;
|
||
if (name && !c.name.toLowerCase().includes(name)) return false;
|
||
if (pos && (item.effective_position || c.position) !== pos) return false;
|
||
if (rarity && (c.rarity || '').toLowerCase() !== rarity) return false;
|
||
if (club && !c.club.toLowerCase().includes(club)) return false;
|
||
if (nation && !c.nation.toLowerCase().includes(nation)) return false;
|
||
return true;
|
||
});
|
||
|
||
if (sort === 'name') cards.sort((a, b) => a.card.name.localeCompare(b.card.name));
|
||
else if (sort === 'pos') cards.sort((a, b) => a.card.position.localeCompare(b.card.position));
|
||
else cards.sort((a, b) => (b.effective_overall ?? b.card.overall) - (a.effective_overall ?? a.card.overall));
|
||
|
||
el('collection-count').textContent = `Showing ${cards.length} of ${allCards.length} cards`;
|
||
el('collection-grid').innerHTML = cards.length
|
||
? cards.map(renderPlayerCard).join('')
|
||
: '<span class="empty">No cards match the current filters.</span>';
|
||
}
|
||
|
||
// ── Packs ────────────────────────────────────────────────────────────────────
|
||
|
||
async function loadPacks() {
|
||
try {
|
||
const data = await api('GET', '/packs');
|
||
const packs = data.packs || [];
|
||
if (!packs.length) {
|
||
el('packs-grid').innerHTML = '<span class="empty">No unopened packs.</span>';
|
||
} else {
|
||
el('packs-grid').innerHTML = packs.map(p => `
|
||
<div class="pack-item">
|
||
<h3>${p.name || p.definition_id}</h3>
|
||
<div class="pack-id">${p.pack_id}</div>
|
||
<button class="btn btn-primary btn-sm" onclick="openPack('${p.pack_id}', this)">Open Pack</button>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
} catch (e) { el('packs-grid').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||
|
||
try {
|
||
const hist = await api('GET', '/packs/history');
|
||
const history = hist.history || [];
|
||
if (!history.length) {
|
||
el('pack-history').innerHTML = '<span class="empty">No packs opened yet.</span>';
|
||
} else {
|
||
el('pack-history').innerHTML = history.slice(0, 10).map(h => `
|
||
<div style="background:#161b22;border:1px solid #30363d;border-radius:6px;padding:12px;margin-bottom:8px;">
|
||
<div style="display:flex;justify-content:space-between;font-size:0.85rem">
|
||
<strong>${h.name || h.definition_id}</strong>
|
||
<span style="color:#8b949e;font-size:0.75rem">${h.opened_at ? new Date(h.opened_at).toLocaleString() : '—'}</span>
|
||
</div>
|
||
<div style="font-size:0.75rem;color:#8b949e;margin-top:4px">${(h.cards||[]).length} cards received</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
} catch (e) { el('pack-history').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||
}
|
||
|
||
async function openPack(packId, btn) {
|
||
btn.disabled = true;
|
||
btn.textContent = 'Opening…';
|
||
try {
|
||
const result = await api('POST', `/packs/open/${packId}`);
|
||
const cards = result.cards || [];
|
||
el('modal-cards').innerHTML = cards.map(c => `
|
||
<div class="player-card" style="font-size:0.8rem">
|
||
<span class="card-rarity ${rarityClass(c.rarity)}">${(c.rarity||'').toUpperCase()}</span>
|
||
<div class="card-ovr" style="font-size:1.2rem">${c.overall}</div>
|
||
<div class="card-pos" style="font-size:0.65rem">${c.position}</div>
|
||
<div class="card-name">${c.name}</div>
|
||
<div class="card-meta">${c.club}</div>
|
||
</div>
|
||
`).join('');
|
||
el('pack-modal').classList.add('open');
|
||
showToast(`Opened ${cards.length} cards!`);
|
||
loadPacks();
|
||
// Refresh header coins
|
||
api('GET', '/club').then(c => {
|
||
el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins';
|
||
}).catch(() => {});
|
||
} catch (e) {
|
||
showToast(e.message, true);
|
||
btn.disabled = false;
|
||
btn.textContent = 'Open Pack';
|
||
}
|
||
}
|
||
|
||
function closeModal() {
|
||
el('pack-modal').classList.remove('open');
|
||
}
|
||
|
||
// ── Objectives ───────────────────────────────────────────────────────────────
|
||
|
||
async function loadObjectives() {
|
||
try {
|
||
const data = await api('GET', '/objectives');
|
||
const objs = data.objectives || [];
|
||
if (!objs.length) {
|
||
el('objectives-list').innerHTML = '<span class="empty">No objectives found.</span>';
|
||
return;
|
||
}
|
||
el('objectives-list').innerHTML = objs.map(obj => {
|
||
const pct = obj.target > 0 ? Math.min(100, Math.round((obj.current / obj.target) * 100)) : 0;
|
||
const cls = obj.claimed ? 'claimed' : obj.completed ? 'completed' : '';
|
||
const reward = [
|
||
obj.reward_coins ? `${obj.reward_coins.toLocaleString()} coins` : '',
|
||
obj.reward_pack_id ? `+ ${obj.reward_pack_id}` : '',
|
||
].filter(Boolean).join(' ');
|
||
const claimBtn = obj.completed && !obj.claimed
|
||
? `<button class="btn btn-primary btn-sm" onclick="claimObjective('${obj.id}', this)">Claim</button>` : '';
|
||
const statusLabel = obj.claimed ? '✓ Claimed' : obj.completed ? '✓ Complete' : `${obj.current}/${obj.target}`;
|
||
return `<div class="obj-item ${cls}">
|
||
<div class="obj-header">
|
||
<span class="obj-title">${obj.title}</span>
|
||
${reward ? `<span class="obj-reward">${reward}</span>` : ''}
|
||
${claimBtn}
|
||
</div>
|
||
<div class="progress-track"><div class="progress-fill${obj.completed ? ' done' : ''}" style="width:${pct}%"></div></div>
|
||
<div class="obj-meta"><span>${obj.description || ''}</span><span>${statusLabel}</span></div>
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) { el('objectives-list').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||
}
|
||
|
||
async function claimObjective(id, btn) {
|
||
btn.disabled = true;
|
||
try {
|
||
const r = await api('POST', `/objectives/${id}/claim`);
|
||
showToast(`Claimed! +${(r.coins_awarded || 0).toLocaleString()} coins`);
|
||
loadObjectives();
|
||
api('GET', '/club').then(c => { el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins'; }).catch(() => {});
|
||
} catch (e) { showToast(e.message, true); btn.disabled = false; }
|
||
}
|
||
|
||
// ── Division ─────────────────────────────────────────────────────────────────
|
||
|
||
async function loadDivision() {
|
||
try {
|
||
const d = await api('GET', '/division');
|
||
const rec = d.record || {};
|
||
const ptsMax = 30; // 10 matches × 3 pts
|
||
const ptsPct = Math.min(100, Math.round((d.season_points / ptsMax) * 100));
|
||
const promoPct = d.pts_for_promotion > 0
|
||
? Math.min(100, Math.round((d.season_points / d.pts_for_promotion) * 100)) : 100;
|
||
|
||
el('division-card').innerHTML = `
|
||
<div class="division-num">${d.division}</div>
|
||
<div class="division-label">Division · Season ${d.season_number}</div>
|
||
<div class="record-row">
|
||
<div class="record-cell w"><div class="val">${rec.wins ?? 0}</div><div class="lbl">W</div></div>
|
||
<div class="record-cell d"><div class="val">${rec.draws ?? 0}</div><div class="lbl">D</div></div>
|
||
<div class="record-cell l"><div class="val">${rec.losses ?? 0}</div><div class="lbl">L</div></div>
|
||
</div>
|
||
`;
|
||
el('season-progress').innerHTML = `
|
||
<div class="info-row"><span class="lbl">Points</span><strong>${d.season_points}</strong></div>
|
||
<div class="info-row"><span class="lbl">For promotion</span>${d.pts_for_promotion} pts needed</div>
|
||
<div class="progress-track" style="margin:8px 0 16px">
|
||
<div class="progress-fill${promoPct >= 100 ? ' done' : ''}" style="width:${promoPct}%"></div>
|
||
</div>
|
||
<div class="info-row"><span class="lbl">Matches played</span>${d.matches_played} / 10</div>
|
||
<div class="info-row"><span class="lbl">Remaining</span>${d.matches_remaining}</div>
|
||
<div class="info-row"><span class="lbl">Started</span>${d.started_at ? new Date(d.started_at).toLocaleDateString() : '—'}</div>
|
||
`;
|
||
} catch (e) {
|
||
el('division-card').innerHTML = `<div class="error-msg">${e.message}</div>`;
|
||
el('season-progress').innerHTML = '';
|
||
}
|
||
}
|
||
|
||
async function claimRivalsReward() {
|
||
try {
|
||
const r = await api('POST', '/rivals/claim-weekly');
|
||
showToast(`Week ${r.week_number} reward: +${(r.coins_awarded || 0).toLocaleString()} coins${r.pack_granted ? ' + pack' : ''}`);
|
||
loadDivision();
|
||
api('GET', '/club').then(c => { el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins'; }).catch(() => {});
|
||
} catch (e) { showToast(e.message, true); }
|
||
}
|
||
|
||
// ── FUT Champions ─────────────────────────────────────────────────────────────
|
||
|
||
let currentChampsSession = null;
|
||
|
||
async function loadChamps() {
|
||
try {
|
||
const data = await api('GET', '/fut-champs');
|
||
currentChampsSession = data.session;
|
||
renderChampsSession(data.session, data.max_matches);
|
||
} catch (e) { el('champs-current').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||
|
||
try {
|
||
const hist = await api('GET', '/fut-champs/history');
|
||
const sessions = hist.history || [];
|
||
if (!sessions.length) {
|
||
el('champs-history').innerHTML = '<span class="empty">No past sessions.</span>';
|
||
} else {
|
||
el('champs-history').innerHTML = sessions.map(s => {
|
||
const tierClass = s.reward_tier ? 'tier-' + s.reward_tier.split(' ')[0] : '';
|
||
return `<div style="background:#161b22;border:1px solid #30363d;border-radius:6px;padding:12px;margin-bottom:8px;">
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<strong>Week ${s.week_number}</strong>
|
||
${s.reward_tier ? `<span class="tier-badge ${tierClass}">${s.reward_tier}</span>` : ''}
|
||
</div>
|
||
<div style="font-size:0.8rem;color:#8b949e;margin-top:4px">
|
||
${s.wins}W ${s.draws}D ${s.losses}L · ${s.matches_played}/30 matches
|
||
${s.reward_coins ? ` · ${s.reward_coins.toLocaleString()} coins` : ''}
|
||
${s.reward_claimed ? ' · <span style="color:#3fb950">✓ claimed</span>' : ''}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
} catch (e) { el('champs-history').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||
}
|
||
|
||
function renderChampsSession(session, maxMatches) {
|
||
if (!session) {
|
||
el('champs-current').innerHTML = `
|
||
<div class="champs-card">
|
||
<p style="color:#8b949e;margin-bottom:16px">No active FUT Champions session.</p>
|
||
<button class="btn btn-primary" onclick="startChampsSession()">Start New Week</button>
|
||
</div>`;
|
||
return;
|
||
}
|
||
const pct = Math.round((session.matches_played / (maxMatches || 30)) * 100);
|
||
const tierClass = session.reward_tier ? 'tier-' + session.reward_tier.split(' ')[0] : '';
|
||
const isComplete = session.status === 'completed';
|
||
|
||
el('champs-current').innerHTML = `
|
||
<div class="champs-card">
|
||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||
<strong>Week ${session.week_number}</strong>
|
||
<span style="font-size:0.8rem;color:${isComplete ? '#3fb950' : '#8b949e'}">${isComplete ? 'Completed' : 'Active'}</span>
|
||
</div>
|
||
${session.reward_tier ? `<div class="tier-badge ${tierClass}">${session.reward_tier}</div>` : ''}
|
||
<div class="record-row" style="margin:12px 0">
|
||
<div class="record-cell w"><div class="val">${session.wins}</div><div class="lbl">W</div></div>
|
||
<div class="record-cell d"><div class="val">${session.draws}</div><div class="lbl">D</div></div>
|
||
<div class="record-cell l"><div class="val">${session.losses}</div><div class="lbl">L</div></div>
|
||
</div>
|
||
<div style="font-size:0.8rem;color:#8b949e;margin-bottom:6px">${session.matches_played} / ${maxMatches || 30} matches played</div>
|
||
<div class="progress-track" style="margin-bottom:16px">
|
||
<div class="progress-fill${isComplete ? ' done' : ''}" style="width:${pct}%"></div>
|
||
</div>
|
||
${!isComplete ? `
|
||
<div style="display:flex;gap:8px;align-items:center;margin-bottom:12px">
|
||
<span style="font-size:0.8rem;color:#8b949e">Simulate result:</span>
|
||
<button class="btn btn-secondary btn-sm" onclick="simulateChampsMatch(3,0)">Win (3-0)</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="simulateChampsMatch(1,1)">Draw (1-1)</button>
|
||
<button class="btn btn-secondary btn-sm" onclick="simulateChampsMatch(0,2)">Loss (0-2)</button>
|
||
</div>` : ''}
|
||
${isComplete && !session.reward_claimed ? `
|
||
<div style="background:#0d2818;border-radius:6px;padding:12px;margin-bottom:12px">
|
||
<div style="font-size:0.85rem"><strong>Reward ready!</strong> ${(session.reward_coins || 0).toLocaleString()} coins${session.reward_pack_id ? ' + ' + session.reward_pack_id : ''}</div>
|
||
</div>
|
||
<button class="btn btn-primary" onclick="claimChampsReward('${session.id}')">Claim Reward</button>` : ''}
|
||
${session.reward_claimed ? '<div style="color:#3fb950;font-size:0.85rem">✓ Reward claimed</div>' : ''}
|
||
</div>`;
|
||
}
|
||
|
||
async function startChampsSession() {
|
||
try {
|
||
const data = await api('POST', '/fut-champs/start');
|
||
currentChampsSession = data.session;
|
||
showToast('FUT Champions week started!');
|
||
loadChamps();
|
||
} catch (e) { showToast(e.message, true); }
|
||
}
|
||
|
||
async function simulateChampsMatch(gf, ga) {
|
||
if (!currentChampsSession) return;
|
||
try {
|
||
const r = await api('POST', `/fut-champs/${currentChampsSession.id}/result`, { goals_for: gf, goals_against: ga });
|
||
currentChampsSession = r.session;
|
||
showToast(`Match recorded: ${r.outcome}`);
|
||
loadChamps();
|
||
} catch (e) { showToast(e.message, true); }
|
||
}
|
||
|
||
async function claimChampsReward(sessionId) {
|
||
try {
|
||
const r = await api('POST', `/fut-champs/${sessionId}/claim`);
|
||
showToast(`Claimed ${r.tier}: +${(r.coins_awarded || 0).toLocaleString()} coins!`);
|
||
currentChampsSession = null;
|
||
api('GET', '/club').then(c => { el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins'; }).catch(() => {});
|
||
loadChamps();
|
||
} 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 = `
|
||
<div class="opponent-card">
|
||
<div style="font-size:0.8rem;color:#8b949e;margin-bottom:2px">${diff.replace('_', ' ').toUpperCase()}</div>
|
||
<div style="font-weight:600">${name}</div>
|
||
<div style="display:flex;gap:16px;align-items:baseline;margin-top:4px">
|
||
<span class="opponent-rating">${rating}</span>
|
||
<span class="formation-badge">${formation}</span>
|
||
</div>
|
||
</div>`;
|
||
} 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';
|
||
const levelUps = r.level_ups || [];
|
||
const levelUpHtml = levelUps.map(ev =>
|
||
`<div style="color:#f5a623;font-size:0.78rem">⬆ Level ${ev.new_level}! +${(ev.coins_granted||0).toLocaleString()} coins${ev.pack_granted ? ' + ' + ev.pack_granted : ''}</div>`
|
||
).join('');
|
||
el('match-result-display').innerHTML = `
|
||
<div style="background:#0d1117;border-radius:6px;padding:12px;border-left:3px solid ${outcomeColor}">
|
||
<div style="font-weight:700;color:${outcomeColor}">${outcome} ${gf}–${ga}</div>
|
||
<div style="font-size:0.8rem;margin-top:4px">
|
||
+${(r.coins_awarded || 0).toLocaleString()} coins · +${r.xp_awarded || 0} XP
|
||
${(r.objectives_updated || []).length ? `<br>Objectives: ${r.objectives_updated.join(', ')}` : ''}
|
||
${r.season_end ? `<br>Season ended: Division ${r.season_end.new_division ?? '—'}` : ''}
|
||
</div>
|
||
${levelUpHtml}
|
||
</div>`;
|
||
if (levelUps.length) {
|
||
showToast(`Level Up! You are now level ${levelUps[levelUps.length - 1].new_level}`);
|
||
}
|
||
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 = '<span class="empty">No matches played yet.</span>';
|
||
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 `<div class="match-history-row">
|
||
<span class="outcome-badge outcome-${cls}">${lbl}</span>
|
||
<span style="flex:1">${m.opponent_name}</span>
|
||
<span style="font-weight:600">${m.goals_for}–${m.goals_against}</span>
|
||
<span style="color:#f5a623;font-size:0.75rem">+${(m.coins_awarded||0).toLocaleString()}</span>
|
||
<span style="color:#8b949e;font-size:0.72rem">${m.mode || 'squad_battles'}</span>
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) { el('match-history').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||
}
|
||
|
||
// ── Pack Store ────────────────────────────────────────────────────────────────
|
||
|
||
async function loadStore() {
|
||
try {
|
||
const data = await api('GET', '/packs/store');
|
||
const packs = data.packs || [];
|
||
if (!packs.length) {
|
||
el('store-grid').innerHTML = '<span class="empty">No packs available.</span>';
|
||
return;
|
||
}
|
||
el('store-grid').innerHTML = packs.map(p => `
|
||
<div class="pack-item">
|
||
<h3>${p.name}</h3>
|
||
<div class="pack-id" style="word-break:normal">${p.description}</div>
|
||
<div style="margin:8px 0">
|
||
<span class="market-price">${(p.cost_coins || 0).toLocaleString()} coins</span>
|
||
<span style="font-size:0.75rem;color:#8b949e;margin-left:6px">${p.total_cards} cards</span>
|
||
</div>
|
||
<button class="btn btn-primary btn-sm" onclick="buyStorePack('${p.id}', this)">Buy</button>
|
||
</div>`).join('');
|
||
} catch (e) { el('store-grid').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||
}
|
||
|
||
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 = '<span class="empty">No SBC challenges defined.</span>';
|
||
return;
|
||
}
|
||
el('sbc-list').innerHTML = sbcs.map(s => renderSbcCard(s)).join('');
|
||
} catch (e) { el('sbc-list').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||
}
|
||
|
||
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 `<option value="${i.owned_card_id}">${ovr} ${c.name} (${c.position}, ${c.club})</option>`;
|
||
}).join('');
|
||
|
||
const selPills = sel.map(id => {
|
||
const item = allCards.find(i => i.owned_card_id === id);
|
||
const label = item ? `${item.card.name}` : id;
|
||
return `<span class="sbc-sel-pill" onclick="removeSbcCard('${s.id}','${id}')" title="Click to remove">${label} ×</span>`;
|
||
}).join('');
|
||
|
||
return `<div class="sbc-card" id="sbc-${s.id}">
|
||
<div class="sbc-name">${s.name}${s.repeatable ? ' <span style="font-size:0.7rem;color:#8b949e">(repeatable)</span>' : ''}</div>
|
||
<div class="sbc-desc">${s.description}</div>
|
||
<div class="sbc-reqs">${reqs.map(r => `<span class="req-pill">${r}</span>`).join('')}</div>
|
||
<div class="sbc-reward">Reward: ${rewardStr || '—'}</div>
|
||
<div class="sbc-picker">
|
||
<div style="font-size:0.78rem;color:#8b949e;margin-bottom:4px">
|
||
Selected: ${sel.length}/${needed} cards
|
||
</div>
|
||
<div class="sbc-selected" id="sbc-sel-${s.id}">${selPills}</div>
|
||
${sel.length < needed ? `
|
||
<select onchange="addSbcCard('${s.id}', this)">
|
||
<option value="">+ Add card…</option>
|
||
${cardOpts}
|
||
</select>` : ''}
|
||
${sel.length >= needed ? `
|
||
<button class="btn btn-primary btn-sm" onclick="submitSbc('${s.id}')">Submit SBC</button>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
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 = `
|
||
<div class="info-row"><span class="lbl">Matches</span><strong>${s.matches_played ?? 0}</strong></div>
|
||
<div class="info-row"><span class="lbl">Wins</span><span style="color:#3fb950">${s.wins ?? 0}</span></div>
|
||
<div class="info-row"><span class="lbl">Draws</span>${s.draws ?? 0}</div>
|
||
<div class="info-row"><span class="lbl">Losses</span><span style="color:#f85149">${s.losses ?? 0}</span></div>
|
||
<div class="info-row"><span class="lbl">Win rate</span>${s.matches_played > 0 ? Math.round(((s.wins??0)/s.matches_played)*100) : 0}%</div>
|
||
<div class="info-row"><span class="lbl">Goals scored</span>${s.goals_scored ?? 0}</div>
|
||
<div class="info-row"><span class="lbl">Goals conceded</span>${s.goals_conceded ?? 0}</div>
|
||
<div class="info-row"><span class="lbl">Avg goals/game</span>${s.matches_played > 0 ? ((s.goals_scored??0)/s.matches_played).toFixed(1) : '0.0'}</div>
|
||
<div class="info-row"><span class="lbl">Win streak</span>${s.current_win_streak ?? 0}</div>
|
||
<div class="info-row"><span class="lbl">Best streak</span>${s.best_win_streak ?? 0}</div>
|
||
<div class="info-row"><span class="lbl">Packs opened</span>${s.packs_opened ?? 0}</div>
|
||
<div class="info-row"><span class="lbl">SBCs done</span>${s.sbcs_completed ?? 0}</div>
|
||
<div class="info-row"><span class="lbl">Coins earned</span>${(s.total_coins_earned ?? 0).toLocaleString()}</div>
|
||
`;
|
||
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 `<div class="stat-bar-row" style="font-size:0.8rem">
|
||
<span class="stat-bar-label" style="width:36px">${pos}</span>
|
||
<div class="stat-bar-track"><div class="stat-bar-fill" style="width:${pct}%"></div></div>
|
||
<span style="font-size:0.75rem;width:22px;text-align:right">${goals}</span>
|
||
</div>`;
|
||
}).join('')
|
||
: '<span class="empty">No goal data yet.</span>';
|
||
} catch (e) { el('career-stats').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||
|
||
try {
|
||
const h = await api('GET', '/statistics/history?limit=20');
|
||
const matches = h.matches || [];
|
||
if (!matches.length) {
|
||
el('stats-history').innerHTML = '<span class="empty">No match history yet.</span>';
|
||
return;
|
||
}
|
||
const summ = h.summary || {};
|
||
el('stats-history').innerHTML = `
|
||
<div style="display:flex;gap:24px;font-size:0.85rem;margin-bottom:16px;flex-wrap:wrap">
|
||
<span>Last ${matches.length} matches</span>
|
||
<span style="color:#3fb950">${summ.wins ?? 0}W</span>
|
||
<span>${summ.draws ?? 0}D</span>
|
||
<span style="color:#f85149">${summ.losses ?? 0}L</span>
|
||
<span>Avg goals: ${summ.avg_goals_per_game ?? 0}</span>
|
||
<span>Win rate: ${((summ.win_rate ?? 0)*100).toFixed(0)}%</span>
|
||
</div>` +
|
||
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 `<div class="match-history-row">
|
||
<span class="outcome-badge outcome-${cls}">${lbl}</span>
|
||
<span style="flex:1">${m.opponent_name}</span>
|
||
<span style="font-weight:600">${m.goals_for}–${m.goals_against}</span>
|
||
<span style="color:#f5a623;font-size:0.75rem">+${(m.coins_awarded||0).toLocaleString()}</span>
|
||
<span style="color:#8b949e;font-size:0.72rem">${new Date(m.played_at).toLocaleDateString()}</span>
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) { el('stats-history').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||
}
|
||
|
||
// ── Card Catalog ──────────────────────────────────────────────────────────────
|
||
|
||
let catalogCards = [];
|
||
let catalogOwned = new Set();
|
||
let catalogLimit = 80;
|
||
|
||
async function loadCatalog() {
|
||
el('catalog-grid').innerHTML = '<span class="loading">Loading…</span>';
|
||
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 = `<div class="error-msg">${e.message}</div>`; }
|
||
}
|
||
|
||
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 `<div class="player-card" style="position:relative" title="${c.club} · ${c.league} · ${c.nation}">
|
||
${owned ? '<div class="owned-overlay"></div><span class="owned-tag">Owned</span>' : ''}
|
||
<span class="card-rarity ${rarityClass(c.rarity)}">${(c.rarity||'').toUpperCase()}</span>
|
||
<div class="card-ovr">${c.overall}</div>
|
||
<div class="card-pos">${c.position}</div>
|
||
<div class="card-name">${c.name}</div>
|
||
<div class="card-meta">${c.club}</div>
|
||
<div style="margin:6px 0 4px">
|
||
${statBar('PAC',c.pace)}${statBar('SHO',c.shooting)}${statBar('PAS',c.passing)}
|
||
${statBar('DRI',c.dribbling)}${statBar('DEF',c.defending)}${statBar('PHY',c.physical)}
|
||
</div>
|
||
</div>`;
|
||
}).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 = profile.xp_for_next_level || 1000;
|
||
const xpEarned = xpForNext - (profile.xp_to_next_level || 0);
|
||
const xpPct = Math.min(100, Math.round((xpEarned / xpForNext) * 100));
|
||
el('profile-card').innerHTML = `
|
||
<div class="info-row"><span class="lbl">Username</span><strong>${profile.username || '—'}</strong></div>
|
||
<div class="info-row"><span class="lbl">Level</span><strong>${profile.level ?? 1}</strong></div>
|
||
<div class="info-row"><span class="lbl">XP</span>${(profile.xp || 0).toLocaleString()}
|
||
<span style="color:#8b949e;font-size:0.75rem;margin-left:4px">${(profile.xp_to_next_level || 0).toLocaleString()} to next level</span>
|
||
</div>
|
||
<div class="xp-bar"><div class="xp-fill" style="width:${xpPct}%"></div></div>
|
||
<div style="font-size:0.75rem;color:#8b949e">Profile ID: <code>${profile.id}</code></div>
|
||
`;
|
||
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 = `<div class="error-msg">${e.message}</div>`;
|
||
}
|
||
}
|
||
|
||
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() {
|
||
try {
|
||
const data = await api('GET', '/squad');
|
||
const squad = data.squad;
|
||
const players = data.players || [];
|
||
const chem = data.chemistry || {};
|
||
|
||
el('squad-header').innerHTML = `
|
||
<div style="display:flex;gap:10px;align-items:center;margin-bottom:12px">
|
||
<strong>${squad?.name || 'My Squad'}</strong>
|
||
<span class="formation-badge">${squad?.formation || '—'}</span>
|
||
</div>`;
|
||
|
||
if (!players.length) {
|
||
el('squad-grid').innerHTML = '<span class="empty">No squad saved yet. Use /squad POST from the game or API.</span>';
|
||
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) =>
|
||
`<span class="chem-dot ${i < pChem ? (pChem >= 7 ? 'full' : 'partial') : 'none'}"></span>`
|
||
).join('');
|
||
return `<div class="squad-slot">
|
||
<div class="slot-pos">${p.position_label || 'Player'}${p.is_on_bench ? ' (sub)' : ''}</div>
|
||
${card ? `
|
||
<div class="card-ovr" style="font-size:1.2rem">${card.overall}</div>
|
||
<div style="font-size:0.8rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${card.name}</div>
|
||
<div style="font-size:0.7rem;color:#8b949e">${card.position} · ${card.club}</div>
|
||
<div style="margin-top:4px;font-size:0.65rem;color:#8b949e">Chem: ${pChem}/10</div>
|
||
<div>${dots}</div>
|
||
` : '<span style="color:#8b949e;font-size:0.8rem">Empty</span>'}
|
||
</div>`;
|
||
}).join('');
|
||
|
||
const total = chem.total ?? 0;
|
||
const pct = total;
|
||
const chemColor = total >= 80 ? '#3fb950' : total >= 50 ? '#d29922' : '#f85149';
|
||
el('squad-chemistry').innerHTML = `
|
||
<div style="font-size:2.5rem;font-weight:800;color:${chemColor}">${total}<span style="font-size:1rem;color:#8b949e">/100</span></div>
|
||
<div class="chem-bar" style="margin:8px 0 16px"><div class="chem-bar-fill" style="width:${pct}%;background:${chemColor}"></div></div>
|
||
<p style="font-size:0.8rem;color:#8b949e">
|
||
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.
|
||
</p>`;
|
||
} catch (e) {
|
||
el('squad-grid').innerHTML = `<div class="error-msg">${e.message}</div>`;
|
||
el('squad-chemistry').innerHTML = '';
|
||
}
|
||
await loadSquadList();
|
||
}
|
||
|
||
async function loadSquadList() {
|
||
try {
|
||
const data = await api('GET', '/squads');
|
||
const squads = data.squads || [];
|
||
if (!squads.length) {
|
||
el('squad-list').innerHTML = '<span class="empty">No squads saved yet.</span>';
|
||
return;
|
||
}
|
||
el('squad-list').innerHTML = squads.map(s => `
|
||
<div style="background:#21262d;border-radius:6px;padding:8px 12px;margin-bottom:6px;
|
||
display:flex;align-items:center;gap:10px;font-size:0.85rem">
|
||
<span style="flex:1;font-weight:600">${s.name}</span>
|
||
<span class="formation-badge">${s.formation}</span>
|
||
<button class="btn btn-danger btn-sm" onclick="deleteSquad('${s.id}', this)">Delete</button>
|
||
</div>`).join('');
|
||
} catch (e) { el('squad-list').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||
}
|
||
|
||
async function deleteSquad(squadId, btn) {
|
||
if (!confirm('Delete this squad?')) return;
|
||
btn.disabled = true;
|
||
try {
|
||
await api('DELETE', `/squads/${squadId}`);
|
||
showToast('Squad deleted');
|
||
loadSquad();
|
||
} catch (e) { showToast(e.message, true); btn.disabled = false; }
|
||
}
|
||
|
||
// ── 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 `<div class="builder-slot filled${isGk ? ' gk-slot' : ''}" onclick="openPicker('${type}',${index},'${label}')">
|
||
<div class="slot-label">${label}</div>
|
||
<span class="slot-clear" onclick="event.stopPropagation();clearSlot('${type}',${index})">✕</span>
|
||
<div style="font-size:0.75rem;font-weight:700;color:#f5a623">${ovr}</div>
|
||
<div style="font-size:0.78rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${c.name}</div>
|
||
<div style="font-size:0.65rem;color:#8b949e">${c.position}</div>
|
||
</div>`;
|
||
}
|
||
return `<div class="builder-slot${isGk ? ' gk-slot' : ''}" onclick="openPicker('${type}',${index},'${label}')">
|
||
<div class="slot-label">${label}</div>
|
||
<div style="color:#8b949e;font-size:0.8rem;margin-top:8px">+ Pick player</div>
|
||
</div>`;
|
||
}
|
||
|
||
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 `<div onclick="pickSlotCard('${i.owned_card_id}')" style="
|
||
display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;
|
||
background:#21262d;cursor:pointer;font-size:0.82rem"
|
||
onmouseover="this.style.background='#30363d'" onmouseout="this.style.background='#21262d'">
|
||
<span style="font-weight:700;color:#f5a623;width:28px;text-align:right">${ovr}</span>
|
||
<span style="font-size:0.65rem;color:#8b949e;width:28px;text-transform:uppercase">${c.position}</span>
|
||
<span style="flex:1;font-weight:600">${c.name}</span>
|
||
<span style="font-size:0.7rem;color:#8b949e">${c.club}</span>
|
||
</div>`;
|
||
}).join('') || '<span class="empty">No matching cards.</span>';
|
||
}
|
||
|
||
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;
|
||
|
||
async function loadDraft() {
|
||
el('draft-status').innerHTML = '<span class="loading">Loading…</span>';
|
||
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 = `
|
||
<div style="font-size:0.85rem;color:#8b949e;margin-bottom:12px">
|
||
Select difficulty and start a new draft to pick 11 players (GK → ST).
|
||
Each slot shows 5 candidates; pick one to advance.
|
||
</div>`;
|
||
}
|
||
|
||
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 = '<span class="empty">Draft abandoned.</span>';
|
||
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) =>
|
||
`<span class="pick-dot ${i < progress.filled ? 'filled' : ''}"></span>`
|
||
).join('');
|
||
|
||
el('draft-status').innerHTML = `
|
||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
|
||
<strong>${isComplete ? 'Draft Complete!' : `Picking: ${data.current_position || '—'}`}</strong>
|
||
<span style="font-size:0.8rem;color:#8b949e">${progress.filled || 0}/${progress.total || 11}</span>
|
||
</div>
|
||
<div class="pick-slots" style="margin-bottom:12px">${dots}</div>
|
||
${isComplete ? `
|
||
<div style="background:#0d2818;border-radius:6px;padding:12px;margin-bottom:12px">
|
||
<div style="font-size:0.9rem"><strong>Reward:</strong> ${(data.reward?.coins || 0).toLocaleString()} coins
|
||
${data.reward?.pack_id ? ' + ' + data.reward.pack_id : ''}
|
||
· Squad Rating: ${data.reward?.squad_rating || data.squad_rating || '—'}</div>
|
||
</div>
|
||
<button class="btn btn-primary btn-sm" onclick="startDraft()">Start Another Draft</button>` : ''}`;
|
||
|
||
// Candidates
|
||
if (!isComplete && candidates.length) {
|
||
el('draft-candidates').innerHTML = candidates.map(cand => {
|
||
const c = cand.card;
|
||
if (!c) return '';
|
||
return `<div class="draft-candidate" onclick="pickDraftCard('${cand.card_id}')">
|
||
<span class="card-rarity ${rarityClass(c.rarity)}" style="position:static;display:inline-block;margin-bottom:4px">${(c.rarity||'').toUpperCase()}</span>
|
||
<div class="card-ovr" style="font-size:1.3rem">${c.overall}</div>
|
||
<div style="font-size:0.7rem;color:#8b949e;text-transform:uppercase">${c.position}</div>
|
||
<div style="font-size:0.85rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${c.name}</div>
|
||
<div style="font-size:0.7rem;color:#8b949e">${c.club}</div>
|
||
<div style="margin-top:6px;font-size:0.7rem">
|
||
${statBar('PAC',c.pace)}${statBar('SHO',c.shooting)}${statBar('PAS',c.passing)}
|
||
</div>
|
||
</div>`;
|
||
}).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 `<div class="squad-slot"><div class="slot-pos">${p.position}</div><span style="color:#8b949e;font-size:0.8rem">—</span></div>`;
|
||
return `<div class="squad-slot">
|
||
<div class="slot-pos">${p.position}</div>
|
||
<div class="card-ovr" style="font-size:1.1rem">${c.overall}</div>
|
||
<div style="font-size:0.8rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${c.name}</div>
|
||
<div style="font-size:0.7rem;color:#8b949e">${c.position}</div>
|
||
</div>`;
|
||
}).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 = '<option value="">— choose card —</option>' +
|
||
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 `<option value="${i.owned_card_id}">${ovr} ${c.name} (${c.position})</option>`;
|
||
}).join('');
|
||
}
|
||
|
||
async function loadMarketListings() {
|
||
try {
|
||
const data = await api('GET', '/market');
|
||
allMarketListings = data.listings || [];
|
||
filterMarket();
|
||
} catch (e) { el('market-grid').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||
}
|
||
|
||
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 = '<span class="empty">No listings found. Click "Refresh Listings" to populate the market.</span>';
|
||
return;
|
||
}
|
||
el('market-grid').innerHTML = filtered.map(l => {
|
||
const c = l.card;
|
||
if (!c) return '';
|
||
return `<div class="market-item">
|
||
<span class="card-rarity ${rarityClass(c.rarity)}" style="position:static;display:inline-block;margin-bottom:4px">${(c.rarity||'').toUpperCase()}</span>
|
||
<div class="card-ovr" style="font-size:1.3rem">${c.overall}</div>
|
||
<div style="font-size:0.7rem;color:#8b949e;text-transform:uppercase">${c.position}</div>
|
||
<div style="font-size:0.85rem;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${c.name}</div>
|
||
<div style="font-size:0.7rem;color:#8b949e">${c.club} · ${c.nation}</div>
|
||
<div class="market-price">${(l.listing?.buy_now_price || l.buy_now_price || 0).toLocaleString()} coins</div>
|
||
<div class="market-seller">${l.listing?.seller_name || l.seller_name || 'NPC'}</div>
|
||
<button class="btn btn-primary btn-sm" style="margin-top:8px;width:100%"
|
||
onclick="buyListing('${l.listing?.id || l.id}', this)">Buy</button>
|
||
</div>`;
|
||
}).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 = '<span class="empty">No active listings.</span>';
|
||
return;
|
||
}
|
||
el('my-listings').innerHTML = listings.map(l => {
|
||
const c = l.card;
|
||
return `<div style="background:#161b22;border:1px solid #30363d;border-radius:6px;padding:10px;margin-bottom:8px;display:flex;align-items:center;gap:12px">
|
||
<div>
|
||
<div style="font-size:0.85rem;font-weight:600">${c?.name || l.listing?.card_id || '—'}</div>
|
||
<div style="font-size:0.75rem;color:#8b949e">${c?.position || ''} · ${c?.club || ''}</div>
|
||
</div>
|
||
<div style="margin-left:auto;text-align:right">
|
||
<div class="market-price" style="font-size:0.9rem">${(l.listing?.buy_now_price || 0).toLocaleString()} coins</div>
|
||
<button class="btn btn-danger btn-sm" style="margin-top:4px"
|
||
onclick="cancelListing('${l.listing?.id || ''}', this)">Cancel</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) { el('my-listings').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||
}
|
||
|
||
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 = '<span class="empty">No events defined in data/events.json.</span>';
|
||
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 `<div class="event-card${active ? ' active' : ''}">
|
||
<div class="event-name">${ev.name}</div>
|
||
<div class="event-desc">${ev.description || ''}</div>
|
||
${bonuses ? `<div style="font-size:0.75rem;color:#f5a623;margin-bottom:8px">${bonuses}</div>` : ''}
|
||
<div style="display:flex;align-items:center;justify-content:space-between">
|
||
<span class="event-status ${active ? 'on' : 'off'}">${active ? '● Active' : '○ Inactive'}</span>
|
||
${active
|
||
? `<button class="btn btn-secondary btn-sm" onclick="toggleEvent('${ev.id}', false, this)">Deactivate</button>`
|
||
: `<button class="btn btn-primary btn-sm" onclick="toggleEvent('${ev.id}', true, this)">Activate</button>`}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
} catch (e) { el('events-grid').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||
}
|
||
|
||
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() {
|
||
try {
|
||
const data = await api('GET', '/notifications');
|
||
const notifs = data.notifications || [];
|
||
el('notif-badge').textContent = notifs.length > 0 ? ` (${notifs.length})` : '';
|
||
if (!notifs.length) {
|
||
el('notif-list').innerHTML = '<span class="empty">No notifications right now.</span>';
|
||
return;
|
||
}
|
||
el('notif-list').innerHTML = notifs.map(n => `
|
||
<div class="notif-item ${n.type}">
|
||
<div class="notif-title">${n.title}</div>
|
||
<div class="notif-msg">${n.message}</div>
|
||
</div>`).join('');
|
||
} catch (e) { el('notif-list').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||
}
|
||
|
||
// ── Init ─────────────────────────────────────────────────────────────────────
|
||
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
loadClub();
|
||
// Load notification badge count silently
|
||
api('GET', '/notifications').then(d => {
|
||
const n = (d.notifications || []).length;
|
||
el('notif-badge').textContent = n > 0 ? ` (${n})` : '';
|
||
}).catch(() => {});
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|