Phase 24: pack reveal animation, daily check-in UI, club milestones panel
- mapper.rs: 3 new exact routes (daily objective GET/claim → /club/checkin, milestones GET → /club/milestones); bump exact routes assertion to ≥59 - dashboard: pack reveal animation (card-flip-in keyframe, unrevealed placeholder slots, Reveal Next / Reveal All buttons, one-at-a-time reveal); daily check-in panel at top of club tab (7-dot streak visualisation, claim button, next reward preview); club milestones grid (10 stats: wins, goals, best streak, seasons, highest division, packs, cards, SBCs, check-ins, level) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+161
-15
@@ -244,6 +244,31 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
|
|||||||
.event-status.on { color: #3fb950; }
|
.event-status.on { color: #3fb950; }
|
||||||
.event-status.off { color: #8b949e; }
|
.event-status.off { color: #8b949e; }
|
||||||
|
|
||||||
|
/* ── Pack Reveal Animation ── */
|
||||||
|
@keyframes card-flip-in {
|
||||||
|
0% { transform: rotateY(90deg) scale(0.85); opacity:0; }
|
||||||
|
60% { transform: rotateY(-8deg) scale(1.04); opacity:1; }
|
||||||
|
100% { transform: rotateY(0) scale(1); opacity:1; }
|
||||||
|
}
|
||||||
|
.pack-reveal-card { animation: card-flip-in 0.38s ease both; }
|
||||||
|
.pack-reveal-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(140px, 1fr)); gap:10px; margin-top:12px; perspective:600px; }
|
||||||
|
.pack-reveal-actions { display:flex; gap:10px; align-items:center; margin-top:14px; }
|
||||||
|
.pack-unrevealed { background:#21262d; border:2px dashed #30363d; border-radius:8px; aspect-ratio:0.7; display:flex; align-items:center; justify-content:center; font-size:1.4rem; color:#30363d; cursor:pointer; transition:border-color 0.15s; }
|
||||||
|
.pack-unrevealed:hover { border-color:#f5a623; color:#f5a623; }
|
||||||
|
|
||||||
|
/* ── Daily Check-in ── */
|
||||||
|
.checkin-card { background:#161b22; border:1px solid #30363d; border-radius:8px; padding:16px; max-width:400px; }
|
||||||
|
.checkin-streak { display:flex; gap:6px; margin:10px 0; }
|
||||||
|
.streak-dot { width:28px; height:28px; border-radius:50%; background:#21262d; border:1px solid #30363d; display:flex; align-items:center; justify-content:center; font-size:0.7rem; font-weight:700; }
|
||||||
|
.streak-dot.done { background:#f5a623; border-color:#f5a623; color:#000; }
|
||||||
|
.streak-dot.current { background:#1c4532; border-color:#3fb950; color:#3fb950; }
|
||||||
|
|
||||||
|
/* ── Milestones ── */
|
||||||
|
.milestone-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(180px, 1fr)); gap:10px; margin-top:12px; }
|
||||||
|
.milestone-item { background:#161b22; border:1px solid #30363d; border-radius:8px; padding:14px; }
|
||||||
|
.milestone-val { font-size:1.8rem; font-weight:800; color:#f5a623; line-height:1.1; }
|
||||||
|
.milestone-lbl { font-size:0.75rem; color:#8b949e; margin-top:2px; }
|
||||||
|
|
||||||
/* ── Formation Pitch ── */
|
/* ── Formation Pitch ── */
|
||||||
.pitch-wrap { position:relative; width:100%; max-width:380px; aspect-ratio:3/4.2; background:#16502e; border-radius:10px; overflow:hidden; border:1px solid #30363d; }
|
.pitch-wrap { position:relative; width:100%; max-width:380px; aspect-ratio:3/4.2; background:#16502e; border-radius:10px; overflow:hidden; border:1px solid #30363d; }
|
||||||
.pitch-markings { position:absolute; inset:0; pointer-events:none; }
|
.pitch-markings { position:absolute; inset:0; pointer-events:none; }
|
||||||
@@ -409,6 +434,10 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
|
|||||||
|
|
||||||
<!-- ── Club tab ────────────────────────────────────────────────────────────── -->
|
<!-- ── Club tab ────────────────────────────────────────────────────────────── -->
|
||||||
<div id="tab-club" class="tab-panel active">
|
<div id="tab-club" class="tab-panel active">
|
||||||
|
<div class="section" style="margin-bottom:20px">
|
||||||
|
<h2>Daily Check-in</h2>
|
||||||
|
<div id="checkin-panel"><span class="loading">Loading…</span></div>
|
||||||
|
</div>
|
||||||
<div class="two-col">
|
<div class="two-col">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Club Info</h2>
|
<h2>Club Info</h2>
|
||||||
@@ -424,6 +453,10 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
|
|||||||
<div id="stats-info"><span class="loading">Loading…</span></div>
|
<div id="stats-info"><span class="loading">Loading…</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="section" style="margin-top:20px">
|
||||||
|
<h2>Club Milestones</h2>
|
||||||
|
<div id="milestones-grid" class="milestone-grid"><span class="loading">Loading…</span></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Collection tab ─────────────────────────────────────────────────────── -->
|
<!-- ── Collection tab ─────────────────────────────────────────────────────── -->
|
||||||
@@ -820,11 +853,16 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
|
|||||||
|
|
||||||
<!-- Pack open result modal -->
|
<!-- Pack open result modal -->
|
||||||
<div class="modal-backdrop" id="pack-modal">
|
<div class="modal-backdrop" id="pack-modal">
|
||||||
<div class="modal">
|
<div class="modal" style="max-width:640px">
|
||||||
<h3>Pack Opened!</h3>
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
|
||||||
<div id="modal-cards" class="opened-cards"></div>
|
<h3 id="pack-modal-title">Pack Opened!</h3>
|
||||||
<div style="margin-top:16px;text-align:right">
|
<button class="btn btn-secondary btn-sm" onclick="closeModal()">✕</button>
|
||||||
<button class="btn btn-secondary" onclick="closeModal()">Close</button>
|
</div>
|
||||||
|
<div id="modal-cards" class="pack-reveal-grid"></div>
|
||||||
|
<div class="pack-reveal-actions" id="pack-reveal-actions">
|
||||||
|
<button class="btn btn-primary btn-sm" id="reveal-next-btn" onclick="revealNextCard()" style="display:none">Reveal Next</button>
|
||||||
|
<button class="btn btn-secondary btn-sm" id="reveal-all-btn" onclick="revealAllCards()" style="display:none">Reveal All</button>
|
||||||
|
<button class="btn btn-secondary" id="pack-close-btn" onclick="closeModal()" style="margin-left:auto">Close</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1030,6 +1068,9 @@ async function loadClub() {
|
|||||||
<div class="info-row"><span class="lbl">Win streak</span>${s.current_win_streak ?? 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>`; }
|
} catch (e) { el('stats-info').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||||||
|
|
||||||
|
loadCheckin();
|
||||||
|
loadMilestones();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveClub() {
|
async function saveClub() {
|
||||||
@@ -1122,25 +1163,65 @@ async function loadPacks() {
|
|||||||
} catch (e) { el('pack-history').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
} catch (e) { el('pack-history').innerHTML = `<div class="error-msg">${e.message}</div>`; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pack Reveal ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let _packRevealCards = [];
|
||||||
|
let _packRevealIdx = 0;
|
||||||
|
|
||||||
|
function packCardHtml(c, animate) {
|
||||||
|
return `<div class="player-card${animate ? ' pack-reveal-card' : ''}" style="font-size:0.8rem">
|
||||||
|
<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.2rem">${c.overall}</div>
|
||||||
|
<div style="font-size:0.65rem;color:#8b949e;text-transform:uppercase">${c.position}</div>
|
||||||
|
<div class="card-name">${c.name}</div>
|
||||||
|
<div class="card-meta">${c.club}</div>
|
||||||
|
${c.nation ? `<div style="font-size:0.62rem;color:#8b949e">${c.nation}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPackGrid() {
|
||||||
|
const slots = _packRevealCards.map((c, i) => {
|
||||||
|
if (i < _packRevealIdx) return packCardHtml(c, false);
|
||||||
|
return `<div class="pack-unrevealed" onclick="revealNextCard()" title="Click to reveal">?</div>`;
|
||||||
|
}).join('');
|
||||||
|
el('modal-cards').innerHTML = slots;
|
||||||
|
const allRevealed = _packRevealIdx >= _packRevealCards.length;
|
||||||
|
el('reveal-next-btn').style.display = allRevealed ? 'none' : '';
|
||||||
|
el('reveal-all-btn').style.display = allRevealed ? 'none' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealNextCard() {
|
||||||
|
if (_packRevealIdx >= _packRevealCards.length) return;
|
||||||
|
_packRevealIdx++;
|
||||||
|
renderPackGrid();
|
||||||
|
// Re-animate the newly revealed card
|
||||||
|
const allSlots = el('modal-cards').querySelectorAll('.player-card');
|
||||||
|
const newCard = allSlots[_packRevealIdx - 1];
|
||||||
|
if (newCard) { newCard.classList.remove('pack-reveal-card'); void newCard.offsetWidth; newCard.classList.add('pack-reveal-card'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealAllCards() {
|
||||||
|
_packRevealIdx = _packRevealCards.length;
|
||||||
|
el('modal-cards').innerHTML = _packRevealCards.map(c => packCardHtml(c, false)).join('');
|
||||||
|
el('reveal-next-btn').style.display = 'none';
|
||||||
|
el('reveal-all-btn').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
async function openPack(packId, btn) {
|
async function openPack(packId, btn) {
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Opening…';
|
btn.textContent = 'Opening…';
|
||||||
try {
|
try {
|
||||||
const result = await api('POST', `/packs/open/${packId}`);
|
const result = await api('POST', `/packs/open/${packId}`);
|
||||||
const cards = result.cards || [];
|
const cards = result.cards || [];
|
||||||
el('modal-cards').innerHTML = cards.map(c => `
|
_packRevealCards = cards;
|
||||||
<div class="player-card" style="font-size:0.8rem">
|
_packRevealIdx = 0;
|
||||||
<span class="card-rarity ${rarityClass(c.rarity)}">${(c.rarity||'').toUpperCase()}</span>
|
el('pack-modal-title').textContent = `Pack Opened! (${cards.length} cards)`;
|
||||||
<div class="card-ovr" style="font-size:1.2rem">${c.overall}</div>
|
el('reveal-next-btn').style.display = cards.length ? '' : 'none';
|
||||||
<div class="card-pos" style="font-size:0.65rem">${c.position}</div>
|
el('reveal-all-btn').style.display = cards.length ? '' : 'none';
|
||||||
<div class="card-name">${c.name}</div>
|
renderPackGrid();
|
||||||
<div class="card-meta">${c.club}</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
el('pack-modal').classList.add('open');
|
el('pack-modal').classList.add('open');
|
||||||
showToast(`Opened ${cards.length} cards!`);
|
showToast(`Opened ${cards.length} cards!`);
|
||||||
loadPacks();
|
loadPacks();
|
||||||
// Refresh header coins
|
|
||||||
api('GET', '/club').then(c => {
|
api('GET', '/club').then(c => {
|
||||||
el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins';
|
el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins';
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
@@ -1155,6 +1236,71 @@ function closeModal() {
|
|||||||
el('pack-modal').classList.remove('open');
|
el('pack-modal').classList.remove('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Daily Check-in ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadCheckin() {
|
||||||
|
try {
|
||||||
|
const d = await api('GET', '/club/checkin');
|
||||||
|
const streak = d.streak_day || 0;
|
||||||
|
const available = d.available;
|
||||||
|
const dots = Array.from({length: 7}, (_, i) => {
|
||||||
|
const day = i + 1;
|
||||||
|
const cls = day < streak ? 'done' : day === streak && !available ? 'done' : day === streak + 1 && available ? 'current' : '';
|
||||||
|
return `<div class="streak-dot ${cls}" title="Day ${day}">${day}</div>`;
|
||||||
|
}).join('');
|
||||||
|
el('checkin-panel').innerHTML = `
|
||||||
|
<div class="checkin-card">
|
||||||
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:10px">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight:700;font-size:0.95rem">Daily Streak</div>
|
||||||
|
<div style="font-size:0.78rem;color:#8b949e">Day ${streak} · Next: +${(d.next_reward_coins||0).toLocaleString()} coins${d.next_reward_pack ? ' + pack' : ''}</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary btn-sm" id="checkin-btn" onclick="claimCheckin()" ${available ? '' : 'disabled'} style="margin-left:auto">
|
||||||
|
${available ? 'Claim Reward' : 'Already claimed today'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="checkin-streak">${dots}</div>
|
||||||
|
</div>`;
|
||||||
|
} catch (_) { el('checkin-panel').innerHTML = '<span class="empty">Check-in unavailable.</span>'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function claimCheckin() {
|
||||||
|
const btn = document.getElementById('checkin-btn');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await api('POST', '/club/checkin');
|
||||||
|
if (r.already_claimed) { showToast('Already claimed today!'); return; }
|
||||||
|
showToast(`Day ${r.new_streak} reward: +${r.coins_awarded.toLocaleString()} coins${r.pack_awarded ? ' + pack!' : '!'}`);
|
||||||
|
api('GET', '/club').then(c => { el('hdr-coins').textContent = (c.coins ?? 0).toLocaleString() + ' coins'; }).catch(() => {});
|
||||||
|
loadCheckin();
|
||||||
|
} catch (e) { showToast(e.message, true); if (btn) btn.disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Club Milestones ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadMilestones() {
|
||||||
|
try {
|
||||||
|
const m = await api('GET', '/club/milestones');
|
||||||
|
const items = [
|
||||||
|
{ val: m.total_wins, lbl: 'Matches Won' },
|
||||||
|
{ val: m.total_goals_scored, lbl: 'Goals Scored' },
|
||||||
|
{ val: m.best_win_streak, lbl: 'Best Win Streak' },
|
||||||
|
{ val: m.seasons_completed, lbl: 'Seasons Completed' },
|
||||||
|
{ val: `Div ${m.highest_division_reached ?? 10}`, lbl: 'Highest Division', raw: true },
|
||||||
|
{ val: m.total_packs_opened, lbl: 'Packs Opened' },
|
||||||
|
{ val: m.cards_owned, lbl: 'Cards in Collection' },
|
||||||
|
{ val: m.sbcs_completed, lbl: 'SBCs Completed' },
|
||||||
|
{ val: m.total_checkins, lbl: 'Daily Check-ins' },
|
||||||
|
{ val: `Lvl ${m.club_level ?? 1}`, lbl: 'Club Level', raw: true },
|
||||||
|
];
|
||||||
|
el('milestones-grid').innerHTML = items.map(({ val, lbl, raw }) => `
|
||||||
|
<div class="milestone-item">
|
||||||
|
<div class="milestone-val">${raw ? val : (typeof val === 'number' ? val.toLocaleString() : val ?? 0)}</div>
|
||||||
|
<div class="milestone-lbl">${lbl}</div>
|
||||||
|
</div>`).join('');
|
||||||
|
} catch (_) { el('milestones-grid').innerHTML = '<span class="empty">Milestones unavailable.</span>'; }
|
||||||
|
}
|
||||||
|
|
||||||
// ── Objectives ───────────────────────────────────────────────────────────────
|
// ── Objectives ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function loadObjectives() {
|
async function loadObjectives() {
|
||||||
|
|||||||
+18
-1
@@ -243,6 +243,23 @@ const EXACT: &[ExactRoute] = &[
|
|||||||
core_method: "GET", core_path: "/division/history",
|
core_method: "GET", core_path: "/division/history",
|
||||||
notes: "FUT division history → Core season history",
|
notes: "FUT division history → Core season history",
|
||||||
},
|
},
|
||||||
|
// ── Daily check-in ────────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/dailyObjective",
|
||||||
|
core_method: "GET", core_path: "/club/checkin",
|
||||||
|
notes: "FUT daily objective status → Core check-in status",
|
||||||
|
},
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "POST", ea_path: "/ut/game/fut/dailyObjective/claim",
|
||||||
|
core_method: "POST", core_path: "/club/checkin",
|
||||||
|
notes: "FUT daily objective claim → Core check-in claim",
|
||||||
|
},
|
||||||
|
// ── Club milestones ───────────────────────────────────────────────────────
|
||||||
|
ExactRoute {
|
||||||
|
ea_method: "GET", ea_path: "/ut/game/fut/milestones",
|
||||||
|
core_method: "GET", core_path: "/club/milestones",
|
||||||
|
notes: "FUT milestones → Core club milestones",
|
||||||
|
},
|
||||||
// ── Squad list ────────────────────────────────────────────────────────────
|
// ── Squad list ────────────────────────────────────────────────────────────
|
||||||
ExactRoute {
|
ExactRoute {
|
||||||
ea_method: "GET", ea_path: "/ut/game/fut/squad/list",
|
ea_method: "GET", ea_path: "/ut/game/fut/squad/list",
|
||||||
@@ -704,7 +721,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_total_exact_routes_count() {
|
fn test_total_exact_routes_count() {
|
||||||
assert!(EXACT.len() >= 56, "expected at least 56 exact mappings, got {}", EXACT.len());
|
assert!(EXACT.len() >= 59, "expected at least 59 exact mappings, got {}", EXACT.len());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 22 new mappings ─────────────────────────────────────────────────
|
// ── Phase 22 new mappings ─────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user