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:
funman300
2026-06-25 19:06:04 -07:00
parent ab6021adac
commit 7c779e0d72
2 changed files with 179 additions and 16 deletions
+161 -15
View File
@@ -244,6 +244,31 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
.event-status.on { color: #3fb950; }
.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 ── */
.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; }
@@ -409,6 +434,10 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
<!-- ── Club tab ────────────────────────────────────────────────────────────── -->
<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="section">
<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>
</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>
<!-- ── Collection tab ─────────────────────────────────────────────────────── -->
@@ -820,11 +853,16 @@ h2 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.05em;
<!-- 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 class="modal" style="max-width:640px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
<h3 id="pack-modal-title">Pack Opened!</h3>
<button class="btn btn-secondary btn-sm" onclick="closeModal()"></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>
@@ -1030,6 +1068,9 @@ async function loadClub() {
<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>`; }
loadCheckin();
loadMilestones();
}
async function saveClub() {
@@ -1122,25 +1163,65 @@ async function loadPacks() {
} 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) {
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('');
_packRevealCards = cards;
_packRevealIdx = 0;
el('pack-modal-title').textContent = `Pack Opened! (${cards.length} cards)`;
el('reveal-next-btn').style.display = cards.length ? '' : 'none';
el('reveal-all-btn').style.display = cards.length ? '' : 'none';
renderPackGrid();
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(() => {});
@@ -1155,6 +1236,71 @@ function closeModal() {
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 ───────────────────────────────────────────────────────────────
async function loadObjectives() {
+18 -1
View File
@@ -243,6 +243,23 @@ const EXACT: &[ExactRoute] = &[
core_method: "GET", core_path: "/division/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 ────────────────────────────────────────────────────────────
ExactRoute {
ea_method: "GET", ea_path: "/ut/game/fut/squad/list",
@@ -704,7 +721,7 @@ mod tests {
#[test]
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 ─────────────────────────────────────────────────