diff --git a/.circleci/config.yml b/.circleci/config.yml index a4fab1541..9e69c5919 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -226,6 +226,7 @@ workflows: branches: only: - dev + - mm-final-2025-reveal - deployQa: context: org-global diff --git a/public/mm-final-2025-reveal/index.html b/public/mm-final-2025-reveal/index.html new file mode 100644 index 000000000..fcd9a0112 --- /dev/null +++ b/public/mm-final-2025-reveal/index.html @@ -0,0 +1,279 @@ + + + + + + + Marathon Match Tournament 2025 - Champions Reveal + + + + + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
๐Ÿ†
+
+
+

Marathon Match Tournament 2025

+
+

The champions are about to be revealed...

+
+
+
+
+
+ + +
+ +
+
+
+

+ The Marathon Match + Tournament Final + Has Concluded! +

+

After 24 hours of intense competition, the champions have emerged.

+
+
+
+ + +
+
+
+
+

The Challenge

+

The problem that tested our finalists

+
+
+
๐Ÿงฉ
+

Grid Puzzle Game

+
+

+ Finalists faced a challenging tile-matching puzzle on an Nร—N grid. + Each tile has 4 numbers (one per edge), and the goal is to place tiles so that + adjacent edges match. When two edges match with value k, you score + kยณ points. With limited discards and a hidden tile queue, competitors + had to balance strategy, timing, and optimization to maximize their score. +

+ +
+ +
Example Solution Animation
+
+ +
+
+ ๐ŸŽฏ +
+ Objective +

Place tiles to match edges and maximize score

+
+
+
+ โšก +
+ Scoring +

Matching edges with value k earns kยณ points +

+
+
+
+ ๐ŸŽฒ +
+ Complexity +

Grid size 4-16, hand size 1-N, up to 20 edge values

+
+
+
+
+

Problem Setter: dimkadimon

+

Problem Testers: JacoCronje and nika

+
+
+
+
+
+ + +
+
+
+
+

The Finalists

+

Elite competitors who made it to the final round

+
+
+ +
+
+
+
+ + +
+
+
+

The Leaderboard

+

From provisional scores to final results

+
+ +
+
+
+

PROVISIONAL SCORES

+
Calculated during the 24-hour + challenge
+
+
+
Transitioning to Final Scores...
+
+
+
+
+
+ +
+ + + + + + + + + + + + +
RANKMEMBERSCORECHANGE
+
+
+
+
+ + +
+
+
+

And the Champions Are...

+
+ 3 +
+
+
+
+ + +
+
+
+ +
+
+
+
2
+
๐Ÿ†
+
+
+
+
๐Ÿฅˆ
+
+
+

Loading...

+

-

+
-
+
+
2nd
+
+
+
+ + +
+
+
+
1
+
๐Ÿ‘‘
+
๐Ÿ†
+
+
+
+
๐Ÿฅ‡
+
+
+

Loading...

+

-

+
-
+
+
CHAMPION
+
+
+
+ + +
+
+
+
3
+
๐Ÿ†
+
+
+
+
๐Ÿฅ‰
+
+
+

Loading...

+

-

+
-
+
+
3rd
+
+
+
+
+ + +
+

Congratulations to All Finalists!

+

Your dedication, skill, and strategic thinking made this tournament unforgettable.

+
+
+
24
+
Hours
+
+
+
-
+
Finalists
+
+
+
1
+
Champion
+
+
+
+
+
+ +
+ + + + + \ No newline at end of file diff --git a/public/mm-final-2025-reveal/photos/Acarreo.png b/public/mm-final-2025-reveal/photos/Acarreo.png new file mode 100644 index 000000000..c5d77f365 Binary files /dev/null and b/public/mm-final-2025-reveal/photos/Acarreo.png differ diff --git a/public/mm-final-2025-reveal/photos/Daiver19.png b/public/mm-final-2025-reveal/photos/Daiver19.png new file mode 100644 index 000000000..88b8f2d45 Binary files /dev/null and b/public/mm-final-2025-reveal/photos/Daiver19.png differ diff --git a/public/mm-final-2025-reveal/photos/eulerscheZahl.png b/public/mm-final-2025-reveal/photos/eulerscheZahl.png new file mode 100644 index 000000000..8ebf44b60 Binary files /dev/null and b/public/mm-final-2025-reveal/photos/eulerscheZahl.png differ diff --git a/public/mm-final-2025-reveal/photos/frictionless.png b/public/mm-final-2025-reveal/photos/frictionless.png new file mode 100644 index 000000000..6ff8f2a78 Binary files /dev/null and b/public/mm-final-2025-reveal/photos/frictionless.png differ diff --git a/public/mm-final-2025-reveal/photos/gaha.png b/public/mm-final-2025-reveal/photos/gaha.png new file mode 100644 index 000000000..26bb7e14f Binary files /dev/null and b/public/mm-final-2025-reveal/photos/gaha.png differ diff --git a/public/mm-final-2025-reveal/photos/kovi.png b/public/mm-final-2025-reveal/photos/kovi.png new file mode 100644 index 000000000..397a173a1 Binary files /dev/null and b/public/mm-final-2025-reveal/photos/kovi.png differ diff --git a/public/mm-final-2025-reveal/photos/krismaz.png b/public/mm-final-2025-reveal/photos/krismaz.png new file mode 100644 index 000000000..fdfb36558 Binary files /dev/null and b/public/mm-final-2025-reveal/photos/krismaz.png differ diff --git a/public/mm-final-2025-reveal/photos/marwar22.png b/public/mm-final-2025-reveal/photos/marwar22.png new file mode 100644 index 000000000..f0fd5ffbd Binary files /dev/null and b/public/mm-final-2025-reveal/photos/marwar22.png differ diff --git a/public/mm-final-2025-reveal/photos/sullyper.png b/public/mm-final-2025-reveal/photos/sullyper.png new file mode 100644 index 000000000..73dd74cbc Binary files /dev/null and b/public/mm-final-2025-reveal/photos/sullyper.png differ diff --git a/public/mm-final-2025-reveal/photos/therealbeef.png b/public/mm-final-2025-reveal/photos/therealbeef.png new file mode 100644 index 000000000..6eb08b445 Binary files /dev/null and b/public/mm-final-2025-reveal/photos/therealbeef.png differ diff --git a/public/mm-final-2025-reveal/photos/wleite.png b/public/mm-final-2025-reveal/photos/wleite.png new file mode 100644 index 000000000..dec57aa2f Binary files /dev/null and b/public/mm-final-2025-reveal/photos/wleite.png differ diff --git a/public/mm-final-2025-reveal/photos/xIlledanx.png b/public/mm-final-2025-reveal/photos/xIlledanx.png new file mode 100644 index 000000000..641103a0b Binary files /dev/null and b/public/mm-final-2025-reveal/photos/xIlledanx.png differ diff --git a/public/mm-final-2025-reveal/script.js b/public/mm-final-2025-reveal/script.js new file mode 100644 index 000000000..c96865b2e --- /dev/null +++ b/public/mm-final-2025-reveal/script.js @@ -0,0 +1,688 @@ +// Configuration - Actual tournament data with real names, countries, and local photos +const CONFIG = { + winners: { + first: { + name: "wleite", + fullName: "Wladimir Leite", + country: "Brazil", + countryCode: "BR", + score: "96.50406762453507", + photo: "photos/wleite.png" + }, + second: { + name: "Daiver19", + fullName: "Dmytro Kovalenko", + country: "United States", + countryCode: "US", + score: "96.28012606883388", + photo: "photos/Daiver19.png" + }, + third: { + name: "sullyper", + fullName: "Benjamin Butin", + country: "United States", + countryCode: "US", + score: "95.11713424850961", + photo: "photos/sullyper.png" + } + }, + finalists: [ + { name: "wleite", fullName: "Wladimir Leite", country: "Brazil", countryCode: "BR", photo: "photos/wleite.png" }, + { name: "Daiver19", fullName: "Dmytro Kovalenko", country: "United States", countryCode: "US", photo: "photos/Daiver19.png" }, + { name: "sullyper", fullName: "Benjamin Butin", country: "United States", countryCode: "US", photo: "photos/sullyper.png" }, + { name: "gaha", fullName: "Szymon Mikler", country: "Poland", countryCode: "PL", photo: "photos/gaha.png" }, + { name: "xllledanx", fullName: "Erik Kvanli", country: "Norway", countryCode: "NO", photo: "photos/xIlledanx.png" }, + { name: "frictionless", fullName: "Jeremy Sawicki", country: "United States", countryCode: "US", photo: "photos/frictionless.png" }, + { name: "Acarreo", fullName: "Alvaro Carrera", country: "Argentina", countryCode: "AR", photo: "photos/Acarreo.png" }, + { name: "eulerscheZahl", fullName: "Ralph Pulletz", country: "Germany", countryCode: "DE", photo: "photos/eulerscheZahl.png" }, + { name: "kovi", fullName: "Laszlo Kovacs", country: "Hungary", countryCode: "HU", photo: "photos/kovi.png" }, + { name: "marwar22", fullName: "Marcin Wrรณbel", country: "Poland", countryCode: "PL", photo: "photos/marwar22.png" }, + { name: "krismaz", fullName: "Krzysztof Maziarz", country: "Poland", countryCode: "PL", photo: "photos/krismaz.png" }, + { name: "therealbeef", fullName: "Jan Nunnink", country: "South Korea", countryCode: "KR", photo: "photos/therealbeef.png" } + ], + provisionalScores: [ + { name: "Daiver19", score: 95.92760634916554, submissionId: "provisional" }, + { name: "wleite", score: 95.8428963143793, submissionId: "provisional" }, + { name: "sullyper", score: 94.27375548042926, submissionId: "provisional" }, + { name: "gaha", score: 93.60821110159368, submissionId: "provisional" }, + { name: "xllledanx", score: 93.23566314334215, submissionId: "provisional" }, + { name: "eulerscheZahl", score: 92.99606424429436, submissionId: "provisional" }, + { name: "marwar22", score: 92.9930951680365, submissionId: "provisional" }, + { name: "frictionless", score: 92.4923426686158, submissionId: "provisional" }, + { name: "Acarreo", score: 92.45887119436483, submissionId: "provisional" }, + { name: "kovi", score: 91.64713287113597, submissionId: "provisional" }, + { name: "therealbeef", score: 88.62352311257933, submissionId: "provisional" }, + { name: "krismaz", score: 88.59439182080432, submissionId: "provisional" } + ], + finalScores: [ + { name: "wleite", score: 96.50406762453507, submissionId: "CERfxZpF4SIRNX" }, + { name: "Daiver19", score: 96.28012606883388, submissionId: "EINAK7cSSO_7xX" }, + { name: "sullyper", score: 95.11713424850961, submissionId: "uFx3lGhq4d2mHC" }, + { name: "gaha", score: 93.61257767955452, submissionId: "CyFKBljw8duopJ" }, + { name: "xllledanx", score: 93.55553699719715, submissionId: "nt_MP5KiE1wAJL" }, + { name: "frictionless", score: 93.46929839889309, submissionId: "El9FikEjInD7J-" }, + { name: "Acarreo", score: 93.28178845167851, submissionId: "LQZS_4pZB9jluJ" }, + { name: "eulerscheZahl", score: 93.11112488260213, submissionId: "C2h6ObvYcTwsFM" }, + { name: "kovi", score: 92.59779214174542, submissionId: "rafMzgvB9CIMIm" }, + { name: "marwar22", score: 92.3025036061689, submissionId: "AsCoTAGKQIG8YB" }, + { name: "krismaz", score: 89.4048802902513, submissionId: "F-8sneXwKKth6c" }, + { name: "therealbeef", score: 88.70712272070001, submissionId: "NnFvq2ulEEOlyW" } + ], + timing: { + intro: 3000, + hero: 4000, + problem: 5000, + finalists: 8000, + leaderboardProvisional: 6000, + leaderboardTransition: 3000, + leaderboardFinal: 40000, // Increased to allow slower reveal cadence + countdown: 4000, + podium: 6000, + cta: 5000 + } +}; + +// Simple presentation controller +let currentStep = 0; +const steps = [ + { id: 'section-hero', duration: CONFIG.timing.hero }, + // { id: 'section-problem', duration: CONFIG.timing.problem }, // Removed - skipping The Challenge page + { id: 'section-finalists', duration: CONFIG.timing.finalists }, + { id: 'section-leaderboard', duration: CONFIG.timing.leaderboardProvisional + CONFIG.timing.leaderboardTransition + CONFIG.timing.leaderboardFinal }, + { id: 'section-countdown', duration: CONFIG.timing.countdown }, + { id: 'section-podium', duration: CONFIG.timing.podium } +]; + +// Initialize on page load +document.addEventListener('DOMContentLoaded', () => { + initializePage(); +}); + +function initializePage() { + setTimeout(() => { + fadeOutIntro(); + }, CONFIG.timing.intro); +} + +function fadeOutIntro() { + const introScreen = document.getElementById('intro-screen'); + const mainContent = document.getElementById('main-content'); + + introScreen.classList.add('fade-out'); + + setTimeout(() => { + introScreen.classList.add('hidden'); + mainContent.classList.remove('hidden'); + + // Initialize data + populateFinalists(); + populateLeaderboard(CONFIG.provisionalScores, false); + initPuzzleAnimation(); + + // Start presentation + startPresentation(); + }, 500); +} + +function startPresentation() { + // Hide all sections + steps.forEach(step => { + const section = document.getElementById(step.id); + if (section) { + section.style.display = 'none'; + section.classList.remove('active'); + } + }); + + // Show first section + showStep(0); +} + +function showStep(stepIndex) { + if (stepIndex >= steps.length) { + console.log('Presentation complete'); + return; + } + + const step = steps[stepIndex]; + const section = document.getElementById(step.id); + + if (!section) { + console.error(`Section ${step.id} not found, skipping`); + setTimeout(() => showStep(stepIndex + 1), 1000); + return; + } + + // Hide previous section + if (stepIndex > 0) { + const prevSection = document.getElementById(steps[stepIndex - 1].id); + if (prevSection) { + prevSection.style.display = 'none'; + prevSection.classList.remove('active'); + } + } + + // Show current section + section.style.display = 'flex'; + section.classList.add('active'); + section.style.opacity = '1'; + section.style.visibility = 'visible'; + + // Handle step-specific logic + handleStepLogic(stepIndex); + + // Advance to next step + setTimeout(() => { + showStep(stepIndex + 1); + }, step.duration); +} + +function handleStepLogic(stepIndex) { + const step = steps[stepIndex]; + + switch (step.id) { + case 'section-leaderboard': + setTimeout(() => { + transitionToFinalScores(); + }, CONFIG.timing.leaderboardProvisional); + break; + case 'section-countdown': + startCountdown(); + break; + case 'section-podium': + populateWinners(); + break; + } +} + +function populateFinalists() { + const finalistsGrid = document.getElementById('finalists-grid'); + if (!finalistsGrid) return; + + finalistsGrid.innerHTML = ''; + + // Sort finalists alphabetically by handle (name) + const sortedFinalists = [...CONFIG.finalists].sort((a, b) => { + return (a.name || '').localeCompare(b.name || '', 'en', { sensitivity: 'base' }); + }); + + sortedFinalists.forEach((finalist) => { + const card = document.createElement('div'); + card.className = 'finalist-card'; + + // Load local image with fallback to initials + const imageUrl = finalist.photo || `photos/${finalist.name}.png`; + const initials = getInitials(finalist.fullName || finalist.name); + const flag = getCountryFlag(finalist.countryCode); + + const avatarHTML = `${finalist.fullName || finalist.name}`; + const avatarPlaceholder = `
${initials}
`; + + card.innerHTML = ` +
+ ${avatarHTML} + ${avatarPlaceholder} +
+
${finalist.name}
+
${finalist.fullName || ''}
+
${flag} ${finalist.country}
+ `; + + finalistsGrid.appendChild(card); + }); + + const totalFinalistsEl = document.getElementById('total-finalists'); + if (totalFinalistsEl) { + totalFinalistsEl.textContent = CONFIG.finalists.length; + } +} + +function getInitials(name) { + return name.split(' ').map(n => n[0]).join('').toUpperCase().substring(0, 2) || '?'; +} + +// Helper function to get country flag emoji from country code +function getCountryFlag(countryCode) { + if (!countryCode) return ''; + // Convert country code to flag emoji + const codePoints = countryCode + .toUpperCase() + .split('') + .map(char => 127397 + char.charCodeAt()); + return String.fromCodePoint(...codePoints); +} + +function populateLeaderboard(scores, isFinal) { + const tbody = document.getElementById('leaderboard-body'); + if (!tbody) return; + + tbody.innerHTML = ''; + + const sortedScores = [...scores].sort((a, b) => b.score - a.score); + + sortedScores.forEach((entry, index) => { + const rank = index + 1; + const row = document.createElement('tr'); + + let rankClass = ''; + if (rank === 1) rankClass = 'top-1'; + else if (rank === 2) rankClass = 'top-2'; + else if (rank === 3) rankClass = 'top-3'; + + // Find finalist info for country flag and photo + const finalistInfo = CONFIG.finalists.find(f => f.name === entry.name); + const flag = finalistInfo ? getCountryFlag(finalistInfo.countryCode) : ''; + const photo = finalistInfo ? (finalistInfo.photo || `photos/${finalistInfo.name}.png`) : ''; + const initials = finalistInfo ? getInitials(finalistInfo.fullName || finalistInfo.name) : entry.name.substring(0, 2).toUpperCase(); + + // Create avatar HTML + const avatarHTML = photo + ? `${entry.name}` + : ''; + const avatarPlaceholder = `
${initials}
`; + + row.innerHTML = ` + ${rank} + +
+
+ ${avatarHTML} + ${avatarPlaceholder} +
+
+
${entry.name}
+ ${finalistInfo ? `
${flag} ${finalistInfo.country}
` : ''} +
+
+ + ${entry.score.toFixed(14)} + `; + + if (isFinal) { + const changeInfo = getRankChange(entry.name); + const changeCell = document.createElement('td'); + changeCell.className = 'change-cell'; + + if (changeInfo.change === 'up') { + changeCell.innerHTML = `โ†‘ ${changeInfo.diff}`; + row.classList.add('rank-up'); + } else if (changeInfo.change === 'down') { + changeCell.innerHTML = `โ†“ ${changeInfo.diff}`; + row.classList.add('rank-down'); + } else if (changeInfo.change === 'new') { + changeCell.innerHTML = `NEW`; + row.classList.add('rank-up'); + } else { + changeCell.innerHTML = `โ€”`; + row.classList.add('rank-same'); + } + + row.appendChild(changeCell); + } + + tbody.appendChild(row); + }); +} + +function getRankChange(name) { + const wasInProvisional = CONFIG.provisionalScores.some(entry => entry.name === name); + + if (!wasInProvisional) { + return { change: 'new', diff: 0 }; + } + + const sortedProvisional = [...CONFIG.provisionalScores].sort((a, b) => b.score - a.score); + const provisionalRank = sortedProvisional.findIndex(entry => entry.name === name) + 1; + + const sortedFinal = [...CONFIG.finalScores].sort((a, b) => b.score - a.score); + const finalRank = sortedFinal.findIndex(entry => entry.name === name) + 1; + + const diff = provisionalRank - finalRank; + + if (diff > 0) { + return { change: 'up', diff: Math.abs(diff) }; + } else if (diff < 0) { + return { change: 'down', diff: Math.abs(diff) }; + } else { + return { change: 'same', diff: 0 }; + } +} + +function transitionToFinalScores() { + const title = document.getElementById('leaderboard-title'); + const subtitle = document.getElementById('leaderboard-subtitle'); + const transitionIndicator = document.getElementById('transition-indicator'); + const changeHeader = document.getElementById('change-header'); + const tbody = document.getElementById('leaderboard-body'); + + if (!title || !subtitle || !transitionIndicator || !changeHeader || !tbody) return; + + transitionIndicator.classList.remove('hidden'); + + setTimeout(() => { + title.textContent = 'FINAL SCORES'; + subtitle.textContent = 'Official tournament results'; + transitionIndicator.classList.add('hidden'); + changeHeader.classList.remove('hidden'); + + // Fade out provisional scores + const rows = Array.from(tbody.querySelectorAll('tr')); + rows.forEach((row, index) => { + setTimeout(() => { + row.style.opacity = '0'; + row.style.transform = 'translateX(-20px)'; + row.style.transition = 'all 0.3s ease'; + }, index * 50); + }); + + setTimeout(() => { + // Populate final scores but keep them hidden + populateLeaderboard(CONFIG.finalScores, true); + + const newRows = Array.from(tbody.querySelectorAll('tr')); + + // Hide all rows initially + newRows.forEach((row) => { + row.style.opacity = '0'; + row.style.transform = 'translateY(30px) scale(0.95)'; + row.style.transition = 'none'; + }); + + // Reveal from last to first (reverse order) + // Use 2.5 seconds per placement (2500ms delay between each) + newRows.forEach((row, index) => { + // Rows are in order: index 0 = rank 1, index 1 = rank 2, etc. + // To reveal from last to first, reverse the index + const reverseIndex = newRows.length - 1 - index; + const actualRank = index + 1; // The actual rank of this row (index 0 = rank 1) + const delay = reverseIndex * 2500; // 2.5 seconds per placement + + setTimeout(() => { + // Check if this is top 3 for special treatment + const isTopThree = actualRank <= 3; + + if (isTopThree) { + // Special treatment for top 3: more dramatic reveal + row.style.transition = 'all 0.8s cubic-bezier(0.34, 1.56, 0.64, 1)'; // Bouncy animation + row.style.transform = 'translateY(0) scale(1.05)'; + row.style.opacity = '1'; + + // Add glow effect for top 3 + if (actualRank === 1) { + row.style.boxShadow = '0 0 30px rgba(255, 215, 0, 0.6)'; + row.style.borderLeft = '4px solid var(--gold)'; + } else if (actualRank === 2) { + row.style.boxShadow = '0 0 25px rgba(192, 192, 192, 0.5)'; + row.style.borderLeft = '4px solid var(--silver)'; + } else if (actualRank === 3) { + row.style.boxShadow = '0 0 25px rgba(205, 127, 50, 0.5)'; + row.style.borderLeft = '4px solid var(--bronze)'; + } + + // Scale back to normal after bounce + setTimeout(() => { + row.style.transform = 'translateY(0) scale(1)'; + if (actualRank === 1) { + row.style.boxShadow = '0 0 20px rgba(255, 215, 0, 0.4)'; + } else if (actualRank === 2) { + row.style.boxShadow = '0 0 15px rgba(192, 192, 192, 0.3)'; + } else if (actualRank === 3) { + row.style.boxShadow = '0 0 15px rgba(205, 127, 50, 0.3)'; + } + }, 800); + + // Highlight rank changes for top 3 + if (row.classList.contains('rank-up') || row.classList.contains('rank-down')) { + setTimeout(() => { + row.classList.add('rank-change'); + setTimeout(() => { + row.classList.remove('rank-change'); + }, 1500); + }, 400); + } + } else { + // Regular reveal for other placements + row.style.transition = 'all 0.6s ease'; + row.style.transform = 'translateY(0) scale(1)'; + row.style.opacity = '1'; + + // Highlight rank changes + if (row.classList.contains('rank-up') || row.classList.contains('rank-down')) { + setTimeout(() => { + row.classList.add('rank-change'); + setTimeout(() => { + row.classList.remove('rank-change'); + }, 1000); + }, 300); + } + } + }, delay); + }); + }, 500); + }, CONFIG.timing.leaderboardTransition); +} + +function startCountdown() { + const countdownElement = document.getElementById('countdown'); + if (!countdownElement) return; + + let count = 3; + + const countdownInterval = setInterval(() => { + countdownElement.textContent = count; + countdownElement.style.animation = 'none'; + void countdownElement.offsetWidth; + countdownElement.style.animation = 'countdownPulse 1s ease infinite'; + + count--; + + if (count < 0) { + clearInterval(countdownInterval); + } + }, 1000); +} + +function populateWinners() { + const firstName = document.getElementById('first-name'); + const firstCountry = document.getElementById('first-country'); + const firstScore = document.getElementById('first-score'); + const firstAvatar = document.querySelector('.first-avatar'); + + const firstFlag = getCountryFlag(CONFIG.winners.first.countryCode); + const firstInitials = getInitials(CONFIG.winners.first.fullName); + if (firstName) { + firstName.innerHTML = `
${CONFIG.winners.first.name}
${CONFIG.winners.first.fullName}
`; + } + if (firstCountry) firstCountry.innerHTML = `${firstFlag} ${CONFIG.winners.first.country}`; + if (firstScore) firstScore.textContent = CONFIG.winners.first.score; + if (firstAvatar) { + const firstImageUrl = CONFIG.winners.first.photo || `photos/${CONFIG.winners.first.name}.png`; + firstAvatar.innerHTML = ` + ${CONFIG.winners.first.fullName} +
${firstInitials}
+ `; + } + + const secondName = document.getElementById('second-name'); + const secondCountry = document.getElementById('second-country'); + const secondScore = document.getElementById('second-score'); + const secondAvatar = document.querySelector('.second-avatar'); + + const secondFlag = getCountryFlag(CONFIG.winners.second.countryCode); + const secondInitials = getInitials(CONFIG.winners.second.fullName); + if (secondName) { + secondName.innerHTML = `
${CONFIG.winners.second.name}
${CONFIG.winners.second.fullName}
`; + } + if (secondCountry) secondCountry.innerHTML = `${secondFlag} ${CONFIG.winners.second.country}`; + if (secondScore) secondScore.textContent = CONFIG.winners.second.score; + if (secondAvatar) { + const secondImageUrl = CONFIG.winners.second.photo || `photos/${CONFIG.winners.second.name}.png`; + secondAvatar.innerHTML = ` + ${CONFIG.winners.second.fullName} +
${secondInitials}
+ `; + } + + const thirdName = document.getElementById('third-name'); + const thirdCountry = document.getElementById('third-country'); + const thirdScore = document.getElementById('third-score'); + const thirdAvatar = document.querySelector('.third-avatar'); + + const thirdFlag = getCountryFlag(CONFIG.winners.third.countryCode); + const thirdInitials = getInitials(CONFIG.winners.third.fullName); + if (thirdName) { + thirdName.innerHTML = `
${CONFIG.winners.third.name}
${CONFIG.winners.third.fullName}
`; + } + if (thirdCountry) thirdCountry.innerHTML = `${thirdFlag} ${CONFIG.winners.third.country}`; + if (thirdScore) thirdScore.textContent = CONFIG.winners.third.score; + if (thirdAvatar) { + const thirdImageUrl = CONFIG.winners.third.photo || `photos/${CONFIG.winners.third.name}.png`; + thirdAvatar.innerHTML = ` + ${CONFIG.winners.third.fullName} +
${thirdInitials}
+ `; + } +} + +// Puzzle Animation +function initPuzzleAnimation() { + const canvas = document.getElementById('puzzle-animation'); + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + const gridSize = 6; + const cellSize = Math.min(canvas.width, canvas.height) / gridSize; + + // Set canvas size + canvas.width = gridSize * cellSize; + canvas.height = gridSize * cellSize; + + // Color patterns for tiles (matching the problem description) + const colors = [ + ['#3B82F6', '#EF4444', '#10B981', '#EC4899'], // Blue, Red, Green, Pink + ['#FBBF24', '#F97316', '#10B981', '#EF4444'], // Yellow, Orange, Green, Red + ['#EF4444', '#10B981', '#EC4899', '#3B82F6'], // Red, Green, Pink, Blue + ['#6B7280', '#6B7280', '#6B7280', '#6B7280'], // All Gray + ['#10B981', '#FBBF24', '#6B7280', '#F97316'], // Green, Yellow, Gray, Orange + ['#6B7280', '#3B82F6', '#10B981', '#EF4444'], // Gray, Blue, Green, Red + ['#EF4444', '#3B82F6', '#6B7280', '#10B981'], // Red, Blue, Gray, Green + ['#3B82F6', '#EC4899', '#EF4444', '#FBBF24'] // Blue, Pink, Red, Yellow + ]; + + let animationFrame = 0; + const totalFrames = 200; // Total animation frames + const tilesToPlace = 36; // 6x6 grid + + function drawTile(x, y, colorPattern, alpha = 1) { + const size = cellSize * 0.9; + const offset = (cellSize - size) / 2; + + ctx.save(); + ctx.globalAlpha = alpha; + + // Draw tile background + ctx.fillStyle = '#FFFFFF'; + ctx.fillRect(x * cellSize + offset, y * cellSize + offset, size, size); + + // Draw border + ctx.strokeStyle = '#E5E7EB'; + ctx.lineWidth = 2; + ctx.strokeRect(x * cellSize + offset, y * cellSize + offset, size, size); + + // Draw four triangles + const centerX = x * cellSize + cellSize / 2; + const centerY = y * cellSize + cellSize / 2; + const halfSize = size / 2; + + // Top-left triangle + ctx.fillStyle = colorPattern[0]; + ctx.beginPath(); + ctx.moveTo(centerX - halfSize, centerY - halfSize); + ctx.lineTo(centerX, centerY - halfSize); + ctx.lineTo(centerX - halfSize, centerY); + ctx.closePath(); + ctx.fill(); + + // Top-right triangle + ctx.fillStyle = colorPattern[1]; + ctx.beginPath(); + ctx.moveTo(centerX, centerY - halfSize); + ctx.lineTo(centerX + halfSize, centerY - halfSize); + ctx.lineTo(centerX + halfSize, centerY); + ctx.closePath(); + ctx.fill(); + + // Bottom-left triangle + ctx.fillStyle = colorPattern[2]; + ctx.beginPath(); + ctx.moveTo(centerX - halfSize, centerY); + ctx.lineTo(centerX, centerY + halfSize); + ctx.lineTo(centerX - halfSize, centerY + halfSize); + ctx.closePath(); + ctx.fill(); + + // Bottom-right triangle + ctx.fillStyle = colorPattern[3]; + ctx.beginPath(); + ctx.moveTo(centerX, centerY + halfSize); + ctx.lineTo(centerX + halfSize, centerY); + ctx.lineTo(centerX + halfSize, centerY + halfSize); + ctx.closePath(); + ctx.fill(); + + ctx.restore(); + } + + function animate() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Draw grid background + ctx.fillStyle = '#F5F7FA'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // Draw grid lines + ctx.strokeStyle = '#E5E7EB'; + ctx.lineWidth = 1; + for (let i = 0; i <= gridSize; i++) { + ctx.beginPath(); + ctx.moveTo(i * cellSize, 0); + ctx.lineTo(i * cellSize, canvas.height); + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(0, i * cellSize); + ctx.lineTo(canvas.width, i * cellSize); + ctx.stroke(); + } + + // Calculate how many tiles to show + const progress = (animationFrame % totalFrames) / totalFrames; + const tilesShown = Math.floor(progress * tilesToPlace); + + // Place tiles in a spiral pattern for visual appeal + const positions = []; + for (let i = 0; i < gridSize; i++) { + for (let j = 0; j < gridSize; j++) { + positions.push([j, i]); + } + } + + // Shuffle positions for more interesting animation + for (let i = positions.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [positions[i], positions[j]] = [positions[j], positions[i]]; + } + + for (let i = 0; i < tilesShown && i < positions.length; i++) { + const [x, y] = positions[i]; + const colorIndex = i % colors.length; + const fadeIn = Math.min(1, (tilesShown - i) * 0.2); + drawTile(x, y, colors[colorIndex], fadeIn); + } + + animationFrame++; + requestAnimationFrame(animate); + } + + animate(); +} diff --git a/public/mm-final-2025-reveal/styles.css b/public/mm-final-2025-reveal/styles.css new file mode 100644 index 000000000..a57bfbc0c --- /dev/null +++ b/public/mm-final-2025-reveal/styles.css @@ -0,0 +1,1617 @@ +@import url('https://fonts.googleapis.com/css2?family=Figtree:wght@600;700;800&family=Nunito+Sans:wght@400;500;600;700&display=swap'); + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --font-title: 'Figtree', 'Inter', 'Segoe UI', sans-serif; + --font-body: 'Nunito Sans', 'Inter', 'Segoe UI', sans-serif; + + /* Brand Colors */ + --tc-primary: #00797A; + /* highlight / accent */ + --tc-primary-dark: #0F172A; + /* dark background anchor */ + --tc-secondary: #00797A; + --tc-accent: #00797A; + --tc-purple: #00797A; + --tc-blue-light: #00797A; + --tc-link: #00797A; + --tc-link-hover: #0F172A; + + /* Background Colors */ + --bg-white: #FFFFFF; + --bg-light: #F5F7FA; + --bg-gray: #E5E7EB; + --bg-dark: #0F172A; + /* dark backgrounds */ + --bg-darker: #0F172A; + + /* Medal Colors */ + --gold: #FFD700; + --silver: #C0C0C0; + --bronze: #CD7F32; + + /* Card Colors */ + --card-bg: #FFFFFF; + --card-border: #E5E7EB; + --card-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + + /* Text Colors */ + --text-primary: #0A0A0A; + --text-secondary: #767676; + --text-muted: #9CA3AF; + --text-white: #FFFFFF; + + /* Gradients */ + --gradient-tc: linear-gradient(135deg, #00797A 0%, #0F172A 100%); + --gradient-hero: linear-gradient(135deg, #00797A 0%, #0F172A 50%, #00797A 100%); + --gradient-gold: linear-gradient(135deg, #FFD700 0%, #FFA500 100%); + --gradient-silver: linear-gradient(135deg, #C0C0C0 0%, #E8E8E8 100%); + --gradient-bronze: linear-gradient(135deg, #CD7F32 0%, #D4A574 100%); + + /* Shadows */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); + --shadow-glow: 0 0 40px rgba(0, 121, 122, 0.3); + --shadow-glow-gold: 0 0 60px rgba(255, 215, 0, 0.4); +} + +body { + font-family: var(--font-body); + background: var(--bg-light); + color: var(--text-primary); + overflow: hidden; + line-height: 1.6; + margin: 0; + padding: 0; + width: 100%; + height: 100vh; + position: relative; +} + +h1, +h2, +h3, +h4, +h5, +h6, +.intro-title, +.hero-title, +.section-title, +.leaderboard-title, +.podium-title, +.cta-title { + font-family: var(--font-title); +} + +.container { + max-width: 1400px; + margin: 0 auto; + padding: 0 20px; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.hidden { + display: none !important; +} + +.main-content { + position: relative; + width: 100%; + height: 100vh; + overflow: hidden; +} + +/* Full Screen Presentation Sections */ +.presentation-section { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100vh; + display: none; + align-items: center; + justify-content: center; + opacity: 1; + visibility: visible; + transform: scale(1); + z-index: 10; + pointer-events: auto; + overflow: hidden; + transition: opacity 0.5s ease, transform 0.5s ease; +} + +.presentation-section.active { + display: flex; + opacity: 1; + visibility: visible; + transform: scale(1); +} + +/* Intro Screen */ +.intro-screen { + background: var(--bg-white); + position: relative; + overflow: hidden; +} + +.intro-screen::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: radial-gradient(circle at 50% 50%, rgba(0, 102, 255, 0.1) 0%, transparent 70%); + animation: pulseGlow 3s ease-in-out infinite; +} + +@keyframes pulseGlow { + + 0%, + 100% { + opacity: 0.5; + } + + 50% { + opacity: 1; + } +} + +.intro-content { + text-align: center; + z-index: 2; + position: relative; +} + +.logo-container { + margin-bottom: 3rem; +} + +.globe-trophy-container { + width: 200px; + height: 200px; + margin: 0 auto 2rem; + position: relative; + display: flex; + align-items: center; + justify-content: center; +} + +.globe { + width: 180px; + height: 180px; + position: relative; + animation: globeRotate 20s linear infinite; +} + +.globe-sphere { + width: 100%; + height: 100%; + border-radius: 50%; + background: + radial-gradient(circle at 30% 30%, rgba(34, 139, 34, 0.4) 0%, transparent 25%), + radial-gradient(circle at 70% 40%, rgba(139, 69, 19, 0.3) 0%, transparent 20%), + radial-gradient(circle at 50% 60%, rgba(34, 139, 34, 0.3) 0%, transparent 18%), + radial-gradient(circle at 20% 70%, rgba(139, 69, 19, 0.25) 0%, transparent 15%), + radial-gradient(circle at 80% 20%, rgba(34, 139, 34, 0.35) 0%, transparent 22%), + linear-gradient(135deg, #0f172a 0%, #0f7f80 20%, #2fa9aa 40%, #0f7f80 60%, #0f172a 80%, #0f172a 100%); + position: relative; + box-shadow: + inset -30px -30px 60px rgba(0, 0, 0, 0.4), + inset 30px 30px 60px rgba(255, 255, 255, 0.15), + 0 0 80px rgba(59, 130, 246, 0.4), + 0 0 120px rgba(59, 130, 246, 0.2); + overflow: hidden; +} + +.globe-lines { + position: absolute; + width: 100%; + height: 1px; + background: rgba(255, 255, 255, 0.2); + left: 0; +} + +.globe-lines:nth-child(1) { + top: 10%; + transform: rotate(0deg); +} + +.globe-lines:nth-child(2) { + top: 30%; + transform: rotate(0deg); +} + +.globe-lines:nth-child(3) { + top: 50%; + transform: translateY(-50%); + height: 100%; + width: 1px; + background: rgba(255, 255, 255, 0.25); + left: 50%; +} + +.globe-lines:nth-child(4) { + top: 70%; + transform: rotate(0deg); +} + +.globe-lines:nth-child(5) { + top: 90%; + transform: rotate(0deg); +} + +.trophy { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 4rem; + z-index: 10; + animation: trophyFloat 3s ease-in-out infinite; + filter: drop-shadow(0 0 20px rgba(255, 215, 0, 0.8)); + text-shadow: 0 0 30px rgba(255, 215, 0, 0.6); + pointer-events: none; +} + +@keyframes globeRotate { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + +/* Add more Earth-like details with pseudo-elements */ +.globe-sphere::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 50%; + background: + radial-gradient(ellipse 40% 30% at 25% 35%, rgba(34, 139, 34, 0.5) 0%, transparent 50%), + radial-gradient(ellipse 35% 25% at 70% 45%, rgba(139, 69, 19, 0.4) 0%, transparent 50%), + radial-gradient(ellipse 30% 20% at 50% 65%, rgba(34, 139, 34, 0.4) 0%, transparent 50%), + radial-gradient(ellipse 25% 15% at 15% 75%, rgba(139, 69, 19, 0.3) 0%, transparent 50%), + radial-gradient(ellipse 30% 25% at 85% 25%, rgba(34, 139, 34, 0.45) 0%, transparent 50%); + pointer-events: none; +} + +.globe-sphere::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 50%; + background: + radial-gradient(circle at 30% 30%, rgba(255, 255, 255, 0.1) 0%, transparent 30%), + radial-gradient(circle at 70% 70%, rgba(0, 0, 0, 0.1) 0%, transparent 30%); + pointer-events: none; +} + +@keyframes trophyFloat { + + 0%, + 100% { + transform: translate(-50%, -50%) translateY(0px) scale(1); + } + + 50% { + transform: translate(-50%, -50%) translateY(-10px) scale(1.05); + } +} + +.intro-title { + font-size: clamp(2rem, 5vw, 3rem); + font-weight: 800; + color: var(--tc-primary); + margin-bottom: 1rem; + letter-spacing: -0.02em; +} + +.intro-subtitle { + font-size: clamp(1rem, 2vw, 1.2rem); + color: var(--text-secondary); + margin-bottom: 2rem; +} + +.loading-bar { + width: 400px; + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + margin: 0 auto; + overflow: hidden; +} + +.loading-progress { + height: 100%; + background: var(--gradient-tc); + width: 0%; + animation: loading 2.5s ease-in-out forwards; + box-shadow: 0 0 20px rgba(0, 121, 122, 0.6); +} + +@keyframes loading { + to { + width: 100%; + } +} + +/* Hero Section */ +.hero-section { + background: var(--gradient-hero); + position: relative; + overflow: hidden; +} + +.hero-section .hero-content { + color: var(--text-white); +} + +.hero-section::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: + radial-gradient(circle at 20% 50%, rgba(0, 121, 122, 0.15) 0%, transparent 50%), + radial-gradient(circle at 80% 50%, rgba(15, 23, 42, 0.15) 0%, transparent 50%); + animation: backgroundShift 10s ease-in-out infinite; +} + +@keyframes backgroundShift { + + 0%, + 100% { + opacity: 0.5; + } + + 50% { + opacity: 1; + } +} + +.hero-content { + text-align: center; + z-index: 2; + position: relative; +} + +.hero-title { + font-size: clamp(2rem, 6vw, 4rem); + font-weight: 900; + line-height: 1.1; + margin-bottom: 1rem; + color: var(--text-white); +} + +.hero-title .text-line { + display: block; + opacity: 0; + transform: translateY(50px); + animation: heroReveal 1s ease forwards; +} + +.hero-title .text-line:nth-child(1) { + animation-delay: 0.2s; +} + +.hero-title .text-line:nth-child(2) { + animation-delay: 0.4s; +} + +.hero-title .text-line:nth-child(3) { + animation-delay: 0.6s; +} + +.hero-title .highlight { + color: var(--tc-primary); +} + +@keyframes heroReveal { + to { + opacity: 1; + transform: translateY(0); + } +} + +.hero-subtitle { + font-size: clamp(1rem, 2.5vw, 1.4rem); + color: rgba(255, 255, 255, 0.9); + opacity: 0; + animation: heroReveal 1s ease forwards; + animation-delay: 0.8s; +} + +/* Problem Section */ +.problem-section { + background: var(--bg-white); + position: relative; +} + +.problem-content { + max-width: 900px; + width: 100%; + margin: 0 auto; + padding: 0 1rem; +} + +.section-header { + text-align: center; + margin-bottom: 1.5rem; +} + +.section-header h2 { + font-size: clamp(2rem, 5vw, 3rem); + font-weight: 800; + color: var(--tc-primary); + margin-bottom: 0.5rem; + letter-spacing: -0.02em; +} + +.section-subtitle { + font-size: clamp(0.9rem, 1.5vw, 1.1rem); + color: var(--text-secondary); +} + +.problem-card { + background: var(--card-bg); + border-radius: 12px; + padding: 2rem; + box-shadow: var(--shadow-lg); + border: 1px solid var(--card-border); + max-width: 900px; + width: 100%; +} + +.problem-icon { + font-size: clamp(3rem, 6vw, 4rem); + text-align: center; + margin-bottom: 1rem; +} + +.problem-card h3 { + font-size: clamp(1.5rem, 3vw, 2rem); + margin-bottom: 1rem; + text-align: center; + color: var(--text-primary); +} + +.problem-details p { + font-size: clamp(0.9rem, 1.5vw, 1.1rem); + color: var(--text-secondary); + margin-bottom: 1.5rem; + text-align: center; + line-height: 1.6; +} + +.problem-summary { + font-size: clamp(1rem, 1.6vw, 1.2rem) !important; + color: var(--text-primary) !important; + margin-bottom: 2rem !important; + line-height: 1.8 !important; + max-width: 800px; + margin-left: auto; + margin-right: auto; +} + +.problem-animation-container { + margin: 2rem 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +.puzzle-animation { + width: 100%; + max-width: 500px; + height: 500px; + border: 2px solid var(--card-border); + border-radius: 12px; + background: var(--bg-light); + box-shadow: var(--shadow-md); +} + +.animation-label { + font-size: clamp(0.8rem, 1.2vw, 0.9rem); + color: var(--text-secondary); + font-style: italic; +} + +.problem-features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin: 1.5rem 0; +} + +.feature-item { + display: flex; + gap: 1rem; + padding: 1rem; + background: var(--bg-light); + border-radius: 8px; + border: 1px solid var(--card-border); + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.feature-item:hover { + transform: translateY(-3px); + box-shadow: var(--shadow-md); + border-color: var(--tc-primary); +} + +.feature-icon { + font-size: clamp(1.5rem, 3vw, 2rem); + flex-shrink: 0; +} + +.feature-item strong { + display: block; + margin-bottom: 0.3rem; + color: var(--text-primary); + font-size: clamp(0.9rem, 1.5vw, 1rem); +} + +.feature-item p { + font-size: clamp(0.8rem, 1.2vw, 0.9rem); + color: var(--text-secondary); + margin: 0; + text-align: left; +} + +.problem-credits { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + justify-content: center; + gap: 2rem; + flex-wrap: wrap; +} + +.problem-credits p { + margin: 0; + font-size: clamp(0.8rem, 1.2vw, 0.9rem); +} + +/* Finalists Section */ +.finalists-section { + background: var(--bg-light); + position: relative; +} + +.finalists-content { + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 0 1rem; +} + +.finalists-grid { + display: grid; + grid-template-columns: repeat(6, minmax(140px, 1fr)); + gap: 1rem; + margin-top: 1.5rem; + max-width: 1200px; + width: 100%; +} + +.finalist-card { + background: var(--card-bg); + border-radius: 12px; + padding: 1rem; + text-align: center; + border: 1px solid var(--card-border); + box-shadow: var(--shadow-sm); + transition: all 0.3s ease; + opacity: 0; + transform: translateY(30px) scale(0.9); + animation: finalistReveal 0.6s ease forwards; +} + +.finalist-card:nth-child(1) { + animation-delay: 0.1s; +} + +.finalist-card:nth-child(2) { + animation-delay: 0.2s; +} + +.finalist-card:nth-child(3) { + animation-delay: 0.3s; +} + +.finalist-card:nth-child(4) { + animation-delay: 0.4s; +} + +.finalist-card:nth-child(5) { + animation-delay: 0.5s; +} + +.finalist-card:nth-child(6) { + animation-delay: 0.6s; +} + +.finalist-card:nth-child(7) { + animation-delay: 0.7s; +} + +.finalist-card:nth-child(8) { + animation-delay: 0.8s; +} + +.finalist-card:nth-child(9) { + animation-delay: 0.9s; +} + +.finalist-card:nth-child(10) { + animation-delay: 1.0s; +} + +.finalist-card:nth-child(11) { + animation-delay: 1.1s; +} + +.finalist-card:nth-child(12) { + animation-delay: 1.2s; +} + +.finalist-card:hover { + transform: translateY(-5px); + box-shadow: var(--shadow-md); + border-color: var(--tc-primary); +} + +@keyframes finalistReveal { + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.finalist-avatar { + width: 80px; + height: 80px; + margin: 0 auto 0.8rem; + border-radius: 50%; + background: var(--bg-light); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.8rem; + border: 2px solid var(--card-border); + overflow: hidden; + position: relative; +} + +.finalist-avatar img.finalist-photo { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.finalist-avatar .avatar-placeholder { + font-size: 1.8rem; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-light); + color: var(--text-secondary); + font-weight: 700; +} + +.finalist-name { + font-size: clamp(0.9rem, 1.5vw, 1rem); + font-weight: 600; + margin-bottom: 0.2rem; + color: var(--text-primary); +} + +.finalist-fullname { + font-size: clamp(0.7rem, 1.1vw, 0.8rem); + color: var(--text-secondary); + margin-bottom: 0.2rem; + font-weight: 400; +} + +.finalist-country { + font-size: clamp(0.75rem, 1.2vw, 0.85rem); + color: var(--text-secondary); +} + +/* Leaderboard Section - Topcoder AI Hub Style */ +.leaderboard-transition-section { + background: var(--bg-white); + position: relative; +} + +.leaderboard-container { + background: var(--card-bg); + border-radius: 8px; + padding: 0.75rem; + box-shadow: var(--shadow-md); + border: 1px solid var(--card-border); + max-width: 1000px; + width: 100%; + margin: 0 auto; + max-height: 88vh; + overflow-y: hidden; +} + +.leaderboard-header { + margin-bottom: 0.4rem; + position: relative; +} + +.leaderboard-title-container { + text-align: center; + margin-bottom: 0.4rem; +} + +.leaderboard-title { + font-size: clamp(1.2rem, 2.5vw, 1.6rem); + font-weight: 800; + margin-bottom: 0.2rem; + color: var(--tc-primary); + transition: all 0.5s ease; + letter-spacing: 0.05em; +} + +.leaderboard-subtitle { + font-size: clamp(0.75rem, 1.3vw, 0.9rem); + color: var(--text-secondary); +} + +.transition-indicator { + text-align: center; + margin-top: 1rem; + padding: 1rem; + background: rgba(0, 102, 255, 0.1); + border-radius: 10px; + border: 2px solid rgba(0, 102, 255, 0.3); +} + +.transition-text { + font-size: clamp(0.9rem, 1.5vw, 1.1rem); + color: var(--text-primary); + margin-bottom: 0.8rem; + font-weight: 600; +} + +.transition-bar { + width: 100%; + height: 6px; + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; + overflow: hidden; +} + +.transition-progress { + height: 100%; + background: var(--gradient-tc); + width: 0%; + animation: transitionProgress 2s ease forwards; + box-shadow: 0 0 20px rgba(0, 102, 255, 0.6); +} + +@keyframes transitionProgress { + to { + width: 100%; + } +} + +.leaderboard-table-wrapper { + overflow-x: auto; + overflow-y: hidden; + border-radius: 8px; + border: 1px solid var(--card-border); + max-height: calc(88vh - 120px); +} + +.leaderboard-table { + width: 100%; + border-collapse: collapse; + background: var(--card-bg); + border-radius: 8px; + overflow: hidden; +} + +.leaderboard-table thead { + background: var(--bg-light); + border-bottom: 2px solid var(--card-border); +} + +.leaderboard-table th { + padding: 0.4rem 0.6rem; + text-align: left; + font-weight: 600; + font-size: clamp(0.65rem, 1vw, 0.8rem); + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-secondary); + border-bottom: none; +} + +.leaderboard-table th.rank-col { + width: 100px; + text-align: center; +} + +.leaderboard-table th.member-col { + width: auto; +} + +.leaderboard-table th.score-col { + width: 250px; + text-align: right; +} + +.leaderboard-table th.change-col { + width: 150px; + text-align: center; +} + +.leaderboard-table tbody tr { + border-bottom: 1px solid var(--card-border); + transition: all 0.2s ease; +} + +.leaderboard-table tbody tr:last-child { + border-bottom: none; +} + +.leaderboard-table tbody tr:hover { + background: var(--bg-light); +} + +.leaderboard-table tbody tr.rank-change { + animation: rankChangeHighlight 1s ease; +} + +.leaderboard-table tbody tr.rank-up { + background: rgba(76, 175, 80, 0.08); + border-left: 3px solid #4caf50; +} + +.leaderboard-table tbody tr.rank-down { + background: rgba(244, 67, 54, 0.08); + border-left: 3px solid #f44336; +} + +.leaderboard-table tbody tr.rank-same { + border-left: 3px solid transparent; +} + +@keyframes rankChangeHighlight { + 0% { + background: rgba(0, 102, 255, 0.4); + } + + 100% { + background: transparent; + } +} + +.leaderboard-table td { + padding: 0.4rem 0.6rem; + color: var(--text-primary); + font-size: clamp(0.7rem, 1vw, 0.85rem); +} + +.leaderboard-table td.rank-cell { + text-align: center; + font-weight: 800; + font-size: clamp(0.85rem, 1.6vw, 1rem); +} + +.leaderboard-table td.rank-cell.top-1 { + color: var(--gold); + text-shadow: 0 0 20px rgba(255, 215, 0, 0.5); +} + +.leaderboard-table td.rank-cell.top-2 { + color: var(--silver); +} + +.leaderboard-table td.rank-cell.top-3 { + color: var(--bronze); +} + +.leaderboard-table td.member-cell { + font-weight: 600; + font-size: clamp(0.85rem, 1.3vw, 1rem); + color: var(--text-primary); +} + +.member-info { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.member-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; + background: var(--bg-light); + border: 2px solid var(--card-border); + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.leaderboard-avatar { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.leaderboard-avatar-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.85rem; + font-weight: 700; + color: var(--text-secondary); + background: var(--bg-light); +} + +.member-details { + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.member-name { + font-weight: 600; + color: var(--text-primary); + line-height: 1.2; +} + +.member-country { + font-size: clamp(0.65rem, 0.9vw, 0.75rem); + color: var(--text-secondary); + font-weight: 400; + line-height: 1.2; +} + +.leaderboard-table td.score-cell { + text-align: right; + font-family: 'Courier New', monospace; + font-size: clamp(0.65rem, 1vw, 0.8rem); + color: var(--text-secondary); +} + +.leaderboard-table td.change-cell { + text-align: center; + font-weight: 700; +} + +.change-indicator { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.3rem 0.6rem; + border-radius: 12px; + font-size: clamp(0.7rem, 1vw, 0.85rem); + font-weight: 600; +} + +.change-up { + background: rgba(76, 175, 80, 0.15); + color: #16a34a; + border: 1px solid rgba(76, 175, 80, 0.3); +} + +.change-down { + background: rgba(244, 67, 54, 0.15); + color: #dc2626; + border: 1px solid rgba(244, 67, 54, 0.3); +} + +.change-same { + background: var(--bg-light); + color: var(--text-secondary); + border: 1px solid var(--card-border); +} + +.change-new { + background: rgba(0, 121, 122, 0.15); + color: var(--tc-primary); + border: 1px solid rgba(0, 121, 122, 0.3); +} + +/* Countdown Section */ +.countdown-section { + background: var(--gradient-hero); + position: relative; +} + +.countdown-content { + color: var(--text-white); +} + +.countdown-content { + text-align: center; +} + +.countdown-content h2 { + font-size: clamp(2rem, 5vw, 3rem); + margin-bottom: 1.5rem; + color: var(--text-white); + font-weight: 800; + letter-spacing: -0.02em; +} + +.countdown-timer { + display: inline-block; +} + +.countdown-number { + font-size: clamp(6rem, 15vw, 10rem); + font-weight: 900; + color: var(--text-white); + display: inline-block; + animation: countdownPulse 1s ease infinite; + line-height: 1; + text-shadow: 0 0 30px rgba(255, 255, 255, 0.5); +} + +@keyframes countdownPulse { + + 0%, + 100% { + transform: scale(1); + } + + 50% { + transform: scale(1.15); + } +} + +/* Podium Section */ +.podium-section { + background: var(--bg-white); + position: relative; + overflow: hidden; +} + +.podium-section::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: + radial-gradient(circle at 30% 30%, rgba(255, 215, 0, 0.1) 0%, transparent 50%), + radial-gradient(circle at 70% 70%, rgba(0, 102, 255, 0.1) 0%, transparent 50%); +} + +.podium-container { + display: flex; + justify-content: center; + align-items: flex-end; + gap: 40px; + max-width: 900px; + width: 100%; + margin: 0 auto; + padding: 0.5rem 0; + flex-wrap: wrap; + position: relative; + z-index: 2; +} + +.podium-place { + flex: 1; + min-width: 200px; + max-width: 280px; +} + +.podium-pedestal { + position: relative; +} + +.podium-base { + background: var(--card-bg); + border-radius: 12px 12px 0 0; + padding: 1.5rem 1rem 2.5rem; + text-align: center; + position: relative; + border: 1px solid var(--card-border); + box-shadow: var(--shadow-md); +} + +.first-base { + height: clamp(120px, 18vh, 160px); + background: var(--gradient-gold); + border-color: var(--gold); + box-shadow: 0 20px 60px rgba(255, 215, 0, 0.4), var(--shadow-glow-gold); +} + +.second-base { + height: clamp(100px, 15vh, 130px); + background: var(--gradient-silver); + border-color: var(--silver); + box-shadow: 0 20px 60px rgba(192, 192, 192, 0.3); +} + +.third-base { + height: clamp(85px, 13vh, 110px); + background: var(--gradient-bronze); + border-color: var(--bronze); + box-shadow: 0 20px 60px rgba(205, 127, 50, 0.3); +} + +.podium-number { + font-size: clamp(1.8rem, 4vw, 2.5rem); + font-weight: 900; + position: absolute; + top: 0.5rem; + left: 50%; + transform: translateX(-50%); + z-index: 10; + line-height: 1; +} + +.first-base .podium-number { + color: #0A0A0A; + text-shadow: 0 0 20px rgba(255, 255, 255, 0.5); +} + +.second-base .podium-number { + color: #0A0A0A; +} + +.third-base .podium-number { + color: #fff; +} + +.crown { + font-size: clamp(1.8rem, 3vw, 2.2rem); + position: absolute; + top: -1.2rem; + left: 50%; + transform: translateX(-50%); + animation: crownFloat 3s ease-in-out infinite; + filter: drop-shadow(0 0 20px rgba(255, 215, 0, 0.8)); +} + +@keyframes crownFloat { + + 0%, + 100% { + transform: translateX(-50%) translateY(0) rotate(0deg); + } + + 50% { + transform: translateX(-50%) translateY(-15px) rotate(5deg); + } +} + +.podium-trophy { + font-size: clamp(2rem, 4vw, 3rem); + position: absolute; + bottom: 0.3rem; + left: 50%; + transform: translateX(-50%); + animation: trophyFloat 3s ease-in-out infinite; + filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3)); + z-index: 5; + line-height: 1; +} + +.trophy-gold { + filter: drop-shadow(0 0 20px rgba(255, 215, 0, 0.8)) drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3)); + animation: trophyFloat 3s ease-in-out infinite, trophyGlow 2s ease-in-out infinite; +} + +.trophy-silver { + filter: drop-shadow(0 0 15px rgba(192, 192, 192, 0.6)) drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3)); +} + +.trophy-bronze { + filter: drop-shadow(0 0 15px rgba(205, 127, 50, 0.6)) drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3)); +} + +@keyframes trophyFloat { + + 0%, + 100% { + transform: translateX(-50%) translateY(0) rotate(0deg); + } + + 50% { + transform: translateX(-50%) translateY(-8px) rotate(-5deg); + } +} + +@keyframes trophyGlow { + + 0%, + 100% { + filter: drop-shadow(0 0 20px rgba(255, 215, 0, 0.8)) drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3)); + } + + 50% { + filter: drop-shadow(0 0 30px rgba(255, 215, 0, 1)) drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3)); + } +} + +.winner-card { + background: var(--card-bg); + border-radius: 12px; + padding: 1rem; + margin-top: 0.5rem; + text-align: center; + border: 1px solid var(--card-border); + box-shadow: var(--shadow-md); + transition: transform 0.3s ease; + position: relative; +} + +.first-winner { + border-color: var(--gold); + box-shadow: 0 25px 80px rgba(255, 215, 0, 0.5), var(--shadow-glow-gold); + transform: scale(1.08); +} + +.second-winner { + border-color: var(--silver); +} + +.third-winner { + border-color: var(--bronze); +} + +.winner-avatar { + width: clamp(70px, 10vw, 90px); + height: clamp(70px, 10vw, 90px); + margin: 0 auto 0.6rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: clamp(2rem, 4vw, 2.5rem); + position: relative; + overflow: hidden; + border: 3px solid; +} + +.winner-avatar img.winner-photo { + width: 100%; + height: 100%; + object-fit: cover; +} + +.first-avatar { + background: var(--gradient-gold); + border-color: var(--gold); + box-shadow: 0 0 40px rgba(255, 215, 0, 0.6); + animation: avatarGlow 2s ease-in-out infinite; +} + +.second-avatar { + background: var(--gradient-silver); + border-color: var(--silver); + box-shadow: 0 0 30px rgba(192, 192, 192, 0.5); +} + +.third-avatar { + background: var(--gradient-bronze); + border-color: var(--bronze); + box-shadow: 0 0 30px rgba(205, 127, 50, 0.5); +} + +@keyframes avatarGlow { + + 0%, + 100% { + box-shadow: 0 0 40px rgba(255, 215, 0, 0.6); + } + + 50% { + box-shadow: 0 0 60px rgba(255, 215, 0, 0.9); + } +} + +.avatar-placeholder { + font-size: 4rem; +} + +.winner-info { + margin-bottom: 0.8rem; +} + +.winner-name { + font-size: clamp(1.3rem, 2.5vw, 1.8rem); + font-weight: 700; + margin-bottom: 0.3rem; + color: var(--text-primary); +} + +.winner-name .winner-fullname { + font-size: clamp(0.9rem, 1.5vw, 1.1rem); + font-weight: 400; + color: var(--text-secondary); + margin-top: 0.2rem; +} + +.first-winner .winner-name { + color: #0A0A0A; + text-shadow: none; +} + +.winner-country { + font-size: clamp(0.9rem, 1.5vw, 1rem); + color: var(--text-secondary); + margin-bottom: 1rem; +} + +.winner-score { + font-size: clamp(1rem, 2vw, 1.3rem); + font-weight: 700; + color: var(--text-primary); + padding: 0.6rem 1.2rem; + background: rgba(255, 255, 255, 0.1); + border-radius: 10px; + display: inline-block; + font-family: 'Courier New', monospace; +} + +.winner-badge { + position: absolute; + top: -15px; + right: -15px; + padding: 0.8rem 1.5rem; + border-radius: 25px; + font-size: 0.9rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 2px; + box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3); +} + +.first-badge { + background: var(--gold); + color: #0A0A0A; + box-shadow: 0 0 30px rgba(255, 215, 0, 0.8); +} + +.second-badge { + background: var(--silver); + color: #0A0A0A; +} + +.third-badge { + background: var(--bronze); + color: #fff; +} + +.reveal-podium { + opacity: 0; + transform: translateY(80px) scale(0.8); + animation: revealPodium 1.2s ease forwards; + animation-delay: 0.3s; +} + +.reveal-podium-delay { + opacity: 0; + transform: translateY(80px) scale(0.8); + animation: revealPodium 1.2s ease forwards; + animation-delay: 0.5s; +} + +.reveal-podium-delay-2 { + opacity: 0; + transform: translateY(80px) scale(0.8); + animation: revealPodium 1.2s ease forwards; + animation-delay: 0.7s; +} + +@keyframes revealPodium { + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.celebration-message { + text-align: center; + margin-top: 80px; + padding: 1.5rem; + background: var(--card-bg); + border-radius: 12px; + border: 1px solid var(--card-border); + box-shadow: var(--shadow-sm); + max-width: 700px; + width: 100%; + margin-left: auto; + margin-right: auto; + position: relative; + z-index: 2; +} + +.celebration-message h2 { + font-size: clamp(1.5rem, 3vw, 2rem); + margin-bottom: 1rem; + color: var(--tc-primary); + font-weight: 800; +} + +.celebration-message p { + font-size: clamp(0.9rem, 1.5vw, 1.1rem); + color: var(--text-secondary); + margin-bottom: 1.5rem; +} + +.celebration-stats { + display: flex; + justify-content: center; + gap: 2rem; + flex-wrap: wrap; + margin-top: 1.5rem; +} + +.stat-item { + text-align: center; +} + +.stat-number { + font-size: clamp(2rem, 4vw, 3rem); + font-weight: 900; + color: var(--tc-primary); + margin-bottom: 0.5rem; + line-height: 1; +} + +.stat-label { + font-size: clamp(0.8rem, 1.2vw, 1rem); + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 1px; +} + +/* Leaderboard CTA Section */ +.leaderboard-section { + background: var(--bg-light); + position: relative; +} + +.leaderboard-cta { + text-align: center; + padding: 2rem; + background: var(--card-bg); + border-radius: 12px; + border: 1px solid var(--card-border); + box-shadow: var(--shadow-sm); + max-width: 700px; + width: 100%; + margin: 0 auto; +} + +.leaderboard-cta h3 { + font-size: clamp(1.5rem, 3vw, 2rem); + margin-bottom: 1rem; + font-weight: 800; +} + +.leaderboard-cta p { + font-size: clamp(0.9rem, 1.5vw, 1.1rem); + color: var(--text-secondary); + margin-bottom: 1.5rem; +} + +.cta-button { + display: inline-block; + padding: 1.2rem 3rem; + background: var(--gradient-tc); + color: var(--text-primary); + text-decoration: none; + border-radius: 50px; + font-weight: 700; + font-size: 1.2rem; + transition: transform 0.3s ease, box-shadow 0.3s ease; + box-shadow: 0 10px 30px rgba(0, 102, 255, 0.4); +} + +.cta-button:hover { + transform: translateY(-3px); + box-shadow: 0 15px 40px rgba(0, 102, 255, 0.6); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .podium-container { + flex-direction: column; + align-items: center; + } + + .podium-place { + width: 100%; + max-width: 250px; + } + + .first-place { + order: 1; + } + + .second-place { + order: 2; + } + + .third-place { + order: 3; + } + + .countdown-number { + font-size: clamp(4rem, 12vw, 6rem); + } + + .problem-features { + grid-template-columns: 1fr; + } + + .celebration-stats { + gap: 3rem; + } + + .container { + padding: 0 15px; + } + + .leaderboard-container { + max-height: 85vh; + padding: 0.8rem; + } + + .podium-container { + gap: 0.8rem; + padding: 0.3rem 0; + } + + .first-base { + height: clamp(100px, 15vh, 130px); + } + + .second-base { + height: clamp(85px, 12vh, 110px); + } + + .third-base { + height: clamp(70px, 10vh, 90px); + } +} \ No newline at end of file diff --git a/src/apps/calendar/index.ts b/src/apps/calendar/index.ts new file mode 100644 index 000000000..6f39cd49b --- /dev/null +++ b/src/apps/calendar/index.ts @@ -0,0 +1 @@ +export * from './src' diff --git a/src/apps/calendar/src/CalendarApp.tsx b/src/apps/calendar/src/CalendarApp.tsx new file mode 100644 index 000000000..39bc823bc --- /dev/null +++ b/src/apps/calendar/src/CalendarApp.tsx @@ -0,0 +1,33 @@ +import { FC, useContext, useEffect, useMemo } from 'react' +import { Outlet, Routes } from 'react-router-dom' + +import { routerContext, RouterContextData } from '~/libs/core' + +import { CalendarContextProvider, Layout, SWRConfigProvider } from './lib' +import { toolTitle } from './calendar-app.routes' +import './lib/styles/index.scss' + +const CalendarApp: FC = () => { + const { getChildRoutes }: RouterContextData = useContext(routerContext) + const childRoutes = useMemo(() => getChildRoutes(toolTitle), [getChildRoutes]) + + useEffect(() => { + document.body.classList.add('calendar-app') + return () => { + document.body.classList.remove('calendar-app') + } + }, []) + + return ( + + + + + {childRoutes} + + + + ) +} + +export default CalendarApp diff --git a/src/apps/calendar/src/calendar-app.routes.tsx b/src/apps/calendar/src/calendar-app.routes.tsx new file mode 100644 index 000000000..b3eec295f --- /dev/null +++ b/src/apps/calendar/src/calendar-app.routes.tsx @@ -0,0 +1,43 @@ +import { AppSubdomain, ToolTitle } from '~/config' +import { lazyLoad, LazyLoadedComponent, PlatformRoute } from '~/libs/core' +import { UserRole } from '~/libs/core/lib/profile/profile-functions/profile-factory/user-role.enum' + +import { personalCalendarRouteId, rootRoute, teamCalendarRouteId } from './config/routes.config' + +const CalendarApp: LazyLoadedComponent = lazyLoad(() => import('./CalendarApp')) +const PersonalCalendarPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/personal-calendar'), + 'PersonalCalendarPage', +) +const TeamCalendarPage: LazyLoadedComponent = lazyLoad( + () => import('./pages/team-calendar'), + 'TeamCalendarPage', +) + +export const toolTitle: string = ToolTitle.calendar + +export const calendarRoutes: ReadonlyArray = [ + { + authRequired: true, + children: [ + { + element: , + id: personalCalendarRouteId, + route: '', + title: 'Personal Calendar', + }, + { + element: , + id: teamCalendarRouteId, + route: 'team-calendar', + title: 'Team Calendar', + }, + ], + domain: AppSubdomain.calendar, + element: , + id: toolTitle, + rolesRequired: [UserRole.topcoderStaff, UserRole.administrator], + route: rootRoute, + title: toolTitle, + }, +] diff --git a/src/apps/calendar/src/config/index.config.ts b/src/apps/calendar/src/config/index.config.ts new file mode 100644 index 000000000..f1a930826 --- /dev/null +++ b/src/apps/calendar/src/config/index.config.ts @@ -0,0 +1,4 @@ +/** + * Common calendar config constants. + */ +export const APP_NAME = 'Calendar' diff --git a/src/apps/calendar/src/config/routes.config.ts b/src/apps/calendar/src/config/routes.config.ts new file mode 100644 index 000000000..194a42193 --- /dev/null +++ b/src/apps/calendar/src/config/routes.config.ts @@ -0,0 +1,9 @@ +import { AppSubdomain, EnvironmentConfig } from '~/config' + +export const rootRoute: string + = EnvironmentConfig.SUBDOMAIN === AppSubdomain.calendar + ? '' + : `/${AppSubdomain.calendar}` + +export const personalCalendarRouteId = 'personal-calendar' +export const teamCalendarRouteId = 'team-calendar' diff --git a/src/apps/calendar/src/index.ts b/src/apps/calendar/src/index.ts new file mode 100644 index 000000000..393b0b665 --- /dev/null +++ b/src/apps/calendar/src/index.ts @@ -0,0 +1,2 @@ +export { calendarRoutes } from './calendar-app.routes' +export { rootRoute as calendarRootRoute } from './config/routes.config' diff --git a/src/apps/calendar/src/lib/components/Calendar/Calendar.module.scss b/src/apps/calendar/src/lib/components/Calendar/Calendar.module.scss new file mode 100644 index 000000000..29891004a --- /dev/null +++ b/src/apps/calendar/src/lib/components/Calendar/Calendar.module.scss @@ -0,0 +1,116 @@ +.calendar { + position: relative; + background: #ffffff; + border: 1px solid var(--calendar-border); + border-radius: 12px; + padding: 16px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.04); +} + +.dayNames { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 8px; + margin-bottom: 12px; + color: #6c757d; + text-align: center; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.dayName { + padding: 4px 0; +} + +.grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 8px; +} + +.cell { + position: relative; + min-height: 88px; + width: 100%; + border-radius: 10px; + border: 1px solid var(--calendar-border); + background: var(--status-available); + display: flex; + align-items: flex-start; + justify-content: flex-end; + padding: 10px; + cursor: pointer; + transition: background-color 0.12s ease, transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease; + font-weight: 700; + color: #2d2d2d; +} + +.cell:hover { + transform: translateY(-2px); + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.05); + background: var(--calendar-hover); +} + +.cell:disabled { + cursor: not-allowed; + opacity: 0.75; +} + +.empty { + background: transparent; + border: none; + pointer-events: none; +} + +.dateNumber { + font-size: 16px; + line-height: 1; +} + +.status-available { + background: var(--status-available); +} + +.status-leave { + background: var(--status-leave); + color: #ffffff; +} + +.status-holiday { + background: var(--status-holiday); +} + +.status-weekend { + background: var(--status-weekend); +} + +.selected { + border-color: var(--status-selected); + box-shadow: 0 0 0 2px rgba(77, 171, 247, 0.3); +} + +.loading { + pointer-events: none; +} + +.loadingOverlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.7); + border-radius: 12px; +} + +@media (max-width: 768px) { + .calendar { + padding: 12px; + } + + .cell { + min-height: 72px; + } +} diff --git a/src/apps/calendar/src/lib/components/Calendar/Calendar.tsx b/src/apps/calendar/src/lib/components/Calendar/Calendar.tsx new file mode 100644 index 000000000..6b2b9ff10 --- /dev/null +++ b/src/apps/calendar/src/lib/components/Calendar/Calendar.tsx @@ -0,0 +1,124 @@ +import { MouseEvent, useMemo } from 'react' +import classNames from 'classnames' + +import { LoadingSpinner } from '~/libs/ui' + +import { LeaveDate } from '../../models' +import { + getDateKey, + getMonthDates, + getStatusColor, + getStatusForDate, +} from '../../utils' + +import styles from './Calendar.module.scss' + +const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + +interface CalendarProps { + currentDate: Date + leaveDates: LeaveDate[] + selectedDates: Set + onDateClick: (dateKey: string) => void + isLoading: boolean +} + +export const Calendar = (props: CalendarProps): JSX.Element => { + const currentDate = props.currentDate + const isLoading = props.isLoading + const leaveDates = props.leaveDates + const onDateClick = props.onDateClick + const selectedDates = props.selectedDates + + const monthDates = useMemo( + () => getMonthDates(currentDate.getFullYear(), currentDate.getMonth()), + [currentDate], + ) + + const paddedDates = useMemo(() => { + if (!monthDates.length) { + return [] + } + + const padding = monthDates[0].getDay() + const cells: Array = [] + + for (let i = 0; i < padding; i += 1) { + cells.push(undefined) + } + + cells.push(...monthDates) + + while (cells.length % 7 !== 0) { + cells.push(undefined) + } + + return cells + }, [monthDates]) + + function handleDateClick(event: MouseEvent): void { + const dateKey = event.currentTarget.dataset.dateKey + + if (dateKey) { + onDateClick(dateKey) + } + } + + return ( +
+
+ {dayNames.map(day => ( +
+ {day} +
+ ))} +
+ +
+ {paddedDates.map((date, index) => { + if (!date) { + return ( +
+ ) + } + + const dateKey = getDateKey(date) + const status = getStatusForDate(date, leaveDates) + const isSelected = selectedDates.has(dateKey) + const statusClass = styles[getStatusColor(status)] + + return ( + + ) + })} +
+ + {isLoading && ( +
+ +
+ )} +
+ ) +} + +export default Calendar diff --git a/src/apps/calendar/src/lib/components/Calendar/index.ts b/src/apps/calendar/src/lib/components/Calendar/index.ts new file mode 100644 index 000000000..8c50cf89e --- /dev/null +++ b/src/apps/calendar/src/lib/components/Calendar/index.ts @@ -0,0 +1 @@ +export * from './Calendar' diff --git a/src/apps/calendar/src/lib/components/CalendarLegend/CalendarLegend.module.scss b/src/apps/calendar/src/lib/components/CalendarLegend/CalendarLegend.module.scss new file mode 100644 index 000000000..4447f6d9d --- /dev/null +++ b/src/apps/calendar/src/lib/components/CalendarLegend/CalendarLegend.module.scss @@ -0,0 +1,42 @@ +.legend { + display: flex; + flex-wrap: wrap; + gap: 12px 20px; + align-items: center; + margin: 8px 0 16px; +} + +.item { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: #4b5563; +} + +.color { + width: 14px; + height: 14px; + border-radius: 4px; + border: 1px solid var(--calendar-border); +} + +.status-available { + background: var(--status-available); +} + +.status-leave { + background: var(--status-leave); +} + +.status-holiday { + background: var(--status-holiday); +} + +.status-weekend { + background: var(--status-weekend); +} + +.label { + white-space: nowrap; +} diff --git a/src/apps/calendar/src/lib/components/CalendarLegend/CalendarLegend.tsx b/src/apps/calendar/src/lib/components/CalendarLegend/CalendarLegend.tsx new file mode 100644 index 000000000..375b0a04f --- /dev/null +++ b/src/apps/calendar/src/lib/components/CalendarLegend/CalendarLegend.tsx @@ -0,0 +1,19 @@ +import { FC } from 'react' +import classNames from 'classnames' + +import { legendItems } from '../../utils' + +import styles from './CalendarLegend.module.scss' + +export const CalendarLegend: FC = () => ( +
+ {legendItems.map(item => ( +
+ + {item.label} +
+ ))} +
+) + +export default CalendarLegend diff --git a/src/apps/calendar/src/lib/components/CalendarLegend/index.ts b/src/apps/calendar/src/lib/components/CalendarLegend/index.ts new file mode 100644 index 000000000..99e195112 --- /dev/null +++ b/src/apps/calendar/src/lib/components/CalendarLegend/index.ts @@ -0,0 +1 @@ +export * from './CalendarLegend' diff --git a/src/apps/calendar/src/lib/components/Layout/Layout.module.scss b/src/apps/calendar/src/lib/components/Layout/Layout.module.scss new file mode 100644 index 000000000..25e66ab2f --- /dev/null +++ b/src/apps/calendar/src/lib/components/Layout/Layout.module.scss @@ -0,0 +1,47 @@ +@import '@libs/ui/styles/includes'; + +.layout { + position: relative; + font-family: $font-roboto; + color: var(--Primary); + background: #ffffff; + border: 1px solid var(--BorderColor, #e0e0e0); + border-radius: 10px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.08); + padding: $sp-5; +} + +.header { + display: flex; + align-items: center; + justify-content: flex-end; + gap: $sp-3; + flex-wrap: wrap; + margin-bottom: $sp-4; +} + +.headerActions { + display: flex; + align-items: center; + gap: $sp-2; + + @include ltemd { + width: 100%; + justify-content: flex-start; + } +} + +.main { + @include ltelg { + padding-top: $sp-3; + } +} + +.contentLayoutOuter { + margin: $sp-6 auto !important; +} + +.contentLayoutInner { + box-sizing: border-box; + width: 100%; +} diff --git a/src/apps/calendar/src/lib/components/Layout/Layout.tsx b/src/apps/calendar/src/lib/components/Layout/Layout.tsx new file mode 100644 index 000000000..9e3a87b79 --- /dev/null +++ b/src/apps/calendar/src/lib/components/Layout/Layout.tsx @@ -0,0 +1,61 @@ +import { FC, PropsWithChildren } from 'react' +import { useLocation, useNavigate } from 'react-router-dom' + +import { Button, ContentLayout, IconOutline } from '~/libs/ui' + +import { rootRoute, teamCalendarRouteId } from '../../../config/routes.config' + +import styles from './Layout.module.scss' + +export const NullLayout: FC = props => ( + <>{props.children} +) + +export const Layout: FC = props => { + const location = useLocation() + const navigate = useNavigate() + + const buildPath = (...parts: string[]): string => { + const cleanedParts = parts + .filter(Boolean) + .map(part => part.replace(/^\/+|\/+$/g, '')) + + return `/${cleanedParts.join('/')}` || '/' + } + + const normalizedRootPath = rootRoute || '' + const teamCalendarPath = buildPath(normalizedRootPath, teamCalendarRouteId) + const personalCalendarPath = buildPath(normalizedRootPath) + const normalizedCurrentPath = location.pathname.replace(/\/+$/, '') || '/' + const isTeamCalendar = normalizedCurrentPath === teamCalendarPath + const buttonLabel = isTeamCalendar ? 'View My Calendar' : 'View Team Leave' + const buttonIcon = isTeamCalendar ? IconOutline.UserIcon : IconOutline.UsersIcon + const targetPath = isTeamCalendar ? personalCalendarPath : teamCalendarPath + function handleToggleCalendar(): void { + navigate(targetPath) + } + + return ( + +
+
+
+ +
+
+
{props.children}
+
+
+ ) +} + +export default Layout diff --git a/src/apps/calendar/src/lib/components/Layout/index.ts b/src/apps/calendar/src/lib/components/Layout/index.ts new file mode 100644 index 000000000..19b84975d --- /dev/null +++ b/src/apps/calendar/src/lib/components/Layout/index.ts @@ -0,0 +1 @@ +export * from './Layout' diff --git a/src/apps/calendar/src/lib/components/MonthNavigation/MonthNavigation.module.scss b/src/apps/calendar/src/lib/components/MonthNavigation/MonthNavigation.module.scss new file mode 100644 index 000000000..488adf5d1 --- /dev/null +++ b/src/apps/calendar/src/lib/components/MonthNavigation/MonthNavigation.module.scss @@ -0,0 +1,32 @@ +.navigation { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + margin-bottom: 16px; +} + +.navButton { + min-width: 44px; + height: 44px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.currentMonth { + font-size: 20px; + font-weight: 700; + letter-spacing: 0.01em; + color: #1f2933; +} + +@media (max-width: 480px) { + .navigation { + gap: 8px; + } + + .currentMonth { + font-size: 18px; + } +} diff --git a/src/apps/calendar/src/lib/components/MonthNavigation/MonthNavigation.tsx b/src/apps/calendar/src/lib/components/MonthNavigation/MonthNavigation.tsx new file mode 100644 index 000000000..edf5c2d96 --- /dev/null +++ b/src/apps/calendar/src/lib/components/MonthNavigation/MonthNavigation.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react' + +import { Button, IconOutline } from '~/libs/ui' + +import { formatMonthYear } from '../../utils' + +import styles from './MonthNavigation.module.scss' + +interface MonthNavigationProps { + currentDate: Date + onNextMonth: () => void + onPrevMonth: () => void +} + +export const MonthNavigation: FC = (props: MonthNavigationProps) => ( +
+
+) + +export default MonthNavigation diff --git a/src/apps/calendar/src/lib/components/MonthNavigation/index.ts b/src/apps/calendar/src/lib/components/MonthNavigation/index.ts new file mode 100644 index 000000000..86e180167 --- /dev/null +++ b/src/apps/calendar/src/lib/components/MonthNavigation/index.ts @@ -0,0 +1 @@ +export * from './MonthNavigation' diff --git a/src/apps/calendar/src/lib/components/TeamCalendar/TeamCalendar.module.scss b/src/apps/calendar/src/lib/components/TeamCalendar/TeamCalendar.module.scss new file mode 100644 index 000000000..d0ff3d3b8 --- /dev/null +++ b/src/apps/calendar/src/lib/components/TeamCalendar/TeamCalendar.module.scss @@ -0,0 +1,151 @@ +@import '@libs/ui/styles/includes'; + +.teamCalendar { + position: relative; + background: #ffffff; + border: 1px solid var(--calendar-border); + border-radius: 12px; + padding: 16px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.04); +} + +.dayNames { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 8px; + margin-bottom: 12px; + color: #6c757d; + text-align: center; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.dayName { + padding: 4px 0; +} + +.grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 10px; +} + +.cell { + position: relative; + min-height: 120px; + width: 100%; + border-radius: 10px; + border: 1px solid var(--team-cell-border, #e5e7eb); + background: #f9fafb; + display: flex; + flex-direction: column; + align-items: flex-start; + padding: 12px; + gap: 8px; + transition: background-color 0.12s ease, transform 0.12s ease, box-shadow 0.12s ease; +} + +.cell:hover { + transform: translateY(-1px); + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.05); + background: #f3f4f6; +} + +.empty { + background: transparent; + border: none; + pointer-events: none; +} + +.weekend { + background: #f0f7ff; + border-color: #dbeafe; +} + +.dateNumber { + align-self: flex-end; + font-size: 16px; + font-weight: 800; + color: #111827; + line-height: 1; +} + +.userList { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; +} + +.userItem { + width: 100%; + border-radius: 8px; + padding: 8px 10px; + font-weight: 700; + font-size: 14px; + line-height: 1.2; + border: 1px solid transparent; +} + +.userLeave { + background: var(--user-leave-bg, #fee2e2); + border-color: #fecdd3; + color: #9b1c1c; +} + +.userHoliday { + background: var(--user-holiday-bg, #fef3c7); + border-color: #fde68a; + color: #92400e; +} + +.emptyState { + color: #6b7280; + font-size: 13px; + font-weight: 600; +} + +.overflowIndicator { + color: #374151; + font-size: 13px; + font-weight: 700; +} + +.loading { + pointer-events: none; +} + +.loadingOverlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.7); + border-radius: 12px; +} + +@media (max-width: 768px) { + .teamCalendar { + padding: 12px; + } + + .grid { + gap: 8px; + } + + .cell { + min-height: 96px; + padding: 10px; + } + + .userItem { + font-size: 13px; + } + + .dateNumber { + font-size: 15px; + } +} diff --git a/src/apps/calendar/src/lib/components/TeamCalendar/TeamCalendar.tsx b/src/apps/calendar/src/lib/components/TeamCalendar/TeamCalendar.tsx new file mode 100644 index 000000000..723be6b0c --- /dev/null +++ b/src/apps/calendar/src/lib/components/TeamCalendar/TeamCalendar.tsx @@ -0,0 +1,154 @@ +import { isWeekend } from 'date-fns' +import { FC, useMemo } from 'react' +import classNames from 'classnames' + +import { LoadingSpinner } from '~/libs/ui' + +import { LeaveStatus, TeamLeaveDate } from '../../models' +import { getDateKey, getMonthDates } from '../../utils' + +import styles from './TeamCalendar.module.scss' + +const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] +type TeamLeaveUser = TeamLeaveDate['usersOnLeave'][number] + +const getUserDisplayName = (user: TeamLeaveUser): string => { + const firstName = user.firstName?.trim() + const lastName = user.lastName?.trim() + const fullName = [firstName, lastName] + .filter(Boolean) + .join(' ') + + return fullName || user.handle || user.userId +} + +const compareUsersByName = (userA: TeamLeaveUser, userB: TeamLeaveUser): number => { + const firstNameA = userA.firstName?.trim() || getUserDisplayName(userA) + const firstNameB = userB.firstName?.trim() || getUserDisplayName(userB) + const firstNameCompare = firstNameA.localeCompare(firstNameB, undefined, { sensitivity: 'base' }) + + if (firstNameCompare !== 0) { + return firstNameCompare + } + + const lastNameA = userA.lastName?.trim() ?? '' + const lastNameB = userB.lastName?.trim() ?? '' + const lastNameCompare = lastNameA.localeCompare(lastNameB, undefined, { sensitivity: 'base' }) + + if (lastNameCompare !== 0) { + return lastNameCompare + } + + return (userA.userId ?? '').localeCompare(userB.userId ?? '', undefined, { sensitivity: 'base' }) +} + +interface TeamCalendarProps { + currentDate: Date + teamLeaveDates: TeamLeaveDate[] + isLoading: boolean +} + +export const TeamCalendar: FC = (props: TeamCalendarProps) => { + const currentDate = props.currentDate + const isLoading = props.isLoading + const teamLeaveDates = props.teamLeaveDates + + const monthDates = useMemo( + () => getMonthDates(currentDate.getFullYear(), currentDate.getMonth()), + [currentDate], + ) + + const paddedDates = useMemo(() => { + if (!monthDates.length) { + return [] + } + + const padding = monthDates[0].getDay() + const cells: Array = [] + + for (let i = 0; i < padding; i += 1) { + cells.push(undefined) + } + + cells.push(...monthDates) + + while (cells.length % 7 !== 0) { + cells.push(undefined) + } + + return cells + }, [monthDates]) + + return ( +
+
+ {dayNames.map(day => ( +
+ {day} +
+ ))} +
+ +
+ {paddedDates.map((date, index) => { + if (!date) { + return ( +
+ ) + } + + const dateKey = getDateKey(date) + const leaveEntry = teamLeaveDates.find(item => item.date === dateKey) + const users = leaveEntry?.usersOnLeave ?? [] + const sortedUsers = [...users].sort(compareUsersByName) + const displayedUsers = sortedUsers.slice(0, 10) + const overflowCount = sortedUsers.length - displayedUsers.length + const weekendClass = isWeekend(date) ? styles.weekend : undefined + + return ( +
+ {date.getDate()} +
+ {displayedUsers.length > 0 + && displayedUsers.map((user, userIndex) => ( +
+ {getUserDisplayName(user)} +
+ ))} + {overflowCount > 0 && ( +
+ {`+${overflowCount} more`} +
+ )} +
+
+ ) + })} +
+ + {isLoading && ( +
+ +
+ )} +
+ ) +} + +export default TeamCalendar diff --git a/src/apps/calendar/src/lib/components/TeamCalendar/index.ts b/src/apps/calendar/src/lib/components/TeamCalendar/index.ts new file mode 100644 index 000000000..1aa33e31a --- /dev/null +++ b/src/apps/calendar/src/lib/components/TeamCalendar/index.ts @@ -0,0 +1 @@ +export * from './TeamCalendar' diff --git a/src/apps/calendar/src/lib/components/index.ts b/src/apps/calendar/src/lib/components/index.ts new file mode 100644 index 000000000..705dd2af7 --- /dev/null +++ b/src/apps/calendar/src/lib/components/index.ts @@ -0,0 +1,5 @@ +export * from './Layout' +export * from './Calendar' +export * from './MonthNavigation' +export * from './CalendarLegend' +export * from './TeamCalendar' diff --git a/src/apps/calendar/src/lib/contexts/CalendarContext.ts b/src/apps/calendar/src/lib/contexts/CalendarContext.ts new file mode 100644 index 000000000..d9611437b --- /dev/null +++ b/src/apps/calendar/src/lib/contexts/CalendarContext.ts @@ -0,0 +1,9 @@ +import { createContext } from 'react' + +import type { CalendarContextModel } from '../models' + +export const CalendarContext = createContext({ + loginUserInfo: undefined, +}) + +export default CalendarContext diff --git a/src/apps/calendar/src/lib/contexts/CalendarContextProvider.tsx b/src/apps/calendar/src/lib/contexts/CalendarContextProvider.tsx new file mode 100644 index 000000000..550987623 --- /dev/null +++ b/src/apps/calendar/src/lib/contexts/CalendarContextProvider.tsx @@ -0,0 +1,40 @@ +import { + FC, + PropsWithChildren, + useEffect, + useMemo, + useState, +} from 'react' + +import { tokenGetAsync, TokenModel } from '~/libs/core' + +import type { CalendarContextModel } from '../models' + +import { CalendarContext } from './CalendarContext' + +export const CalendarContextProvider: FC = props => { + const [loginUserInfo, setLoginUserInfo] = useState(undefined) + + const value = useMemo( + () => ({ + loginUserInfo, + }), + [loginUserInfo], + ) + + useEffect(() => { + tokenGetAsync() + .then((token: TokenModel) => setLoginUserInfo(token)) + .catch(() => { + // no-op, consumer can handle missing token + }) + }, []) + + return ( + + {props.children} + + ) +} + +export default CalendarContextProvider diff --git a/src/apps/calendar/src/lib/contexts/SWRConfigProvider.tsx b/src/apps/calendar/src/lib/contexts/SWRConfigProvider.tsx new file mode 100644 index 000000000..c9efac909 --- /dev/null +++ b/src/apps/calendar/src/lib/contexts/SWRConfigProvider.tsx @@ -0,0 +1,19 @@ +import { FC, PropsWithChildren } from 'react' +import { SWRConfig } from 'swr' + +import { xhrGetAsync } from '~/libs/core' + +export const SWRConfigProvider: FC = props => ( + xhrGetAsync(resource), + refreshInterval: 0, + revalidateOnFocus: false, + revalidateOnMount: true, + }} + > + {props.children} + +) + +export default SWRConfigProvider diff --git a/src/apps/calendar/src/lib/contexts/index.ts b/src/apps/calendar/src/lib/contexts/index.ts new file mode 100644 index 000000000..b2bdb3318 --- /dev/null +++ b/src/apps/calendar/src/lib/contexts/index.ts @@ -0,0 +1,3 @@ +export * from './CalendarContext' +export * from './CalendarContextProvider' +export * from './SWRConfigProvider' diff --git a/src/apps/calendar/src/lib/hooks/index.ts b/src/apps/calendar/src/lib/hooks/index.ts new file mode 100644 index 000000000..19778633f --- /dev/null +++ b/src/apps/calendar/src/lib/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useFetchLeaveDates' +export * from './useFetchTeamLeave' diff --git a/src/apps/calendar/src/lib/hooks/useFetchLeaveDates.ts b/src/apps/calendar/src/lib/hooks/useFetchLeaveDates.ts new file mode 100644 index 000000000..7a445f68d --- /dev/null +++ b/src/apps/calendar/src/lib/hooks/useFetchLeaveDates.ts @@ -0,0 +1,105 @@ +import { useCallback, useRef, useState } from 'react' + +import { handleError } from '~/libs/shared' + +import { LeaveDate, LeaveUpdateStatus } from '../models' +import { fetchUserLeaveDates, setLeaveDates as setLeaveDatesService } from '../services' +import { getDateKey } from '../utils' + +export interface UseFetchLeaveDatesResult { + leaveDates: LeaveDate[] + isLoading: boolean + isUpdating: boolean + error: unknown + loadLeaveDates: (startDate?: Date, endDate?: Date) => Promise + updateLeaveDates: (dates: string[], status: LeaveUpdateStatus) => Promise +} + +const buildRequestKey = (...parts: Array): string => ( + parts + .filter(Boolean) + .join('|') +) + +export function useFetchLeaveDates(): UseFetchLeaveDatesResult { + const [leaveDates, setLeaveDates] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [isUpdating, setIsUpdating] = useState(false) + const [error, setError] = useState(undefined) + const latestLoadRequestRef = useRef('') + const latestUpdateRequestRef = useRef('') + + const loadLeaveDates = useCallback( + async (startDate?: Date, endDate?: Date) => { + const startKey = startDate ? getDateKey(startDate) : undefined + const endKey = endDate ? getDateKey(endDate) : undefined + const requestKey = buildRequestKey( + startKey, + endKey, + Date.now() + .toString(), + ) + latestLoadRequestRef.current = requestKey + setIsLoading(true) + setError(undefined) + + try { + const response = await fetchUserLeaveDates(startKey, endKey) + if (latestLoadRequestRef.current !== requestKey) { + return + } + + setLeaveDates(response) + } catch (err) { + if (latestLoadRequestRef.current === requestKey) { + setError(err) + handleError(err) + throw err + } + } finally { + if (latestLoadRequestRef.current === requestKey) { + setIsLoading(false) + } + } + }, + [], + ) + + const updateLeaveDates = useCallback( + async (dates: string[], status: LeaveUpdateStatus) => { + const requestKey = buildRequestKey( + status, + dates.join(','), + Date.now() + .toString(), + ) + latestUpdateRequestRef.current = requestKey + setIsUpdating(true) + setError(undefined) + + try { + await setLeaveDatesService(dates, status) + } catch (err) { + if (latestUpdateRequestRef.current === requestKey) { + setError(err) + handleError(err) + throw err + } + } finally { + if (latestUpdateRequestRef.current === requestKey) { + setIsUpdating(false) + } + } + }, + [], + ) + + return { + error, + isLoading, + isUpdating, + leaveDates, + loadLeaveDates, + updateLeaveDates, + } +} diff --git a/src/apps/calendar/src/lib/hooks/useFetchTeamLeave.ts b/src/apps/calendar/src/lib/hooks/useFetchTeamLeave.ts new file mode 100644 index 000000000..6446241fc --- /dev/null +++ b/src/apps/calendar/src/lib/hooks/useFetchTeamLeave.ts @@ -0,0 +1,71 @@ +import { useCallback, useRef, useState } from 'react' + +import { handleError } from '~/libs/shared' + +import { TeamLeaveDate } from '../models' +import { fetchTeamLeave } from '../services' +import { getDateKey } from '../utils' + +export interface UseFetchTeamLeaveResult { + teamLeaveDates: TeamLeaveDate[] + isLoading: boolean + error: unknown + loadTeamLeave: (startDate?: Date, endDate?: Date) => Promise +} + +const buildRequestKey = (...parts: Array): string => ( + parts + .filter(Boolean) + .join('|') +) + +export function useFetchTeamLeave(): UseFetchTeamLeaveResult { + const [teamLeaveDates, setTeamLeaveDates] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(undefined) + const latestLoadRequestRef = useRef('') + + const loadTeamLeave = useCallback( + async (startDate?: Date, endDate?: Date) => { + const startKey = startDate ? getDateKey(startDate) : undefined + const endKey = endDate ? getDateKey(endDate) : undefined + const requestKey = buildRequestKey( + startKey, + endKey, + Date.now() + .toString(), + ) + latestLoadRequestRef.current = requestKey + setIsLoading(true) + setTeamLeaveDates([]) + setError(undefined) + + try { + const response = await fetchTeamLeave(startKey, endKey) + if (latestLoadRequestRef.current !== requestKey) { + return + } + + setTeamLeaveDates(response) + } catch (err) { + if (latestLoadRequestRef.current === requestKey) { + setError(err) + handleError(err) + throw err + } + } finally { + if (latestLoadRequestRef.current === requestKey) { + setIsLoading(false) + } + } + }, + [], + ) + + return { + error, + isLoading, + loadTeamLeave, + teamLeaveDates, + } +} diff --git a/src/apps/calendar/src/lib/index.ts b/src/apps/calendar/src/lib/index.ts new file mode 100644 index 000000000..d5dc44d56 --- /dev/null +++ b/src/apps/calendar/src/lib/index.ts @@ -0,0 +1,6 @@ +export * from './components' +export * from './contexts' +export * from './services' +export * from './models' +export * from './hooks' +export * from './utils' diff --git a/src/apps/calendar/src/lib/models/index.ts b/src/apps/calendar/src/lib/models/index.ts new file mode 100644 index 000000000..e993d3773 --- /dev/null +++ b/src/apps/calendar/src/lib/models/index.ts @@ -0,0 +1,30 @@ +import { TokenModel } from '~/libs/core' + +export enum LeaveStatus { + AVAILABLE = 'AVAILABLE', + LEAVE = 'LEAVE', + WEEKEND = 'WEEKEND', + WIPRO_HOLIDAY = 'WIPRO_HOLIDAY', +} + +export type LeaveUpdateStatus = LeaveStatus.AVAILABLE | LeaveStatus.LEAVE + +export interface LeaveDate { + date: string + status: LeaveStatus +} + +export interface TeamLeaveDate { + date: string + usersOnLeave: Array<{ + userId: string + handle?: string + firstName?: string + lastName?: string + status: LeaveStatus.LEAVE | LeaveStatus.WIPRO_HOLIDAY + }> +} + +export interface CalendarContextModel { + loginUserInfo?: TokenModel +} diff --git a/src/apps/calendar/src/lib/services/index.ts b/src/apps/calendar/src/lib/services/index.ts new file mode 100644 index 000000000..f5ff03efd --- /dev/null +++ b/src/apps/calendar/src/lib/services/index.ts @@ -0,0 +1 @@ +export * from './leave.service' diff --git a/src/apps/calendar/src/lib/services/leave.service.ts b/src/apps/calendar/src/lib/services/leave.service.ts new file mode 100644 index 000000000..4169e8138 --- /dev/null +++ b/src/apps/calendar/src/lib/services/leave.service.ts @@ -0,0 +1,34 @@ +import qs from 'qs' + +import { EnvironmentConfig } from '~/config' +import { xhrGetAsync, xhrPostAsync } from '~/libs/core' + +import type { LeaveDate, LeaveUpdateStatus, TeamLeaveDate } from '../models' + +const serializeQuery = (params: Record): string => qs.stringify( + params, + { addQueryPrefix: true, skipNulls: true }, +) + +export const fetchUserLeaveDates = async ( + startDate?: string, + endDate?: string, +): Promise => { + const queryString = serializeQuery({ endDate, startDate }) + + return xhrGetAsync(`${EnvironmentConfig.API.V6}/leave/dates${queryString}`) +} + +export const setLeaveDates = async ( + dates: string[], + status: LeaveUpdateStatus, +): Promise => xhrPostAsync(`${EnvironmentConfig.API.V6}/leave/dates`, { dates, status }) + +export const fetchTeamLeave = async ( + startDate?: string, + endDate?: string, +): Promise => { + const queryString = serializeQuery({ endDate, startDate }) + + return xhrGetAsync(`${EnvironmentConfig.API.V6}/leave/team${queryString}`) +} diff --git a/src/apps/calendar/src/lib/styles/index.scss b/src/apps/calendar/src/lib/styles/index.scss new file mode 100644 index 000000000..6c1ffcaa9 --- /dev/null +++ b/src/apps/calendar/src/lib/styles/index.scss @@ -0,0 +1,37 @@ +@import '@libs/ui/styles/includes'; + +:root { + --LeaveColor: #4caf50; + --AvailableColor: #e8f5e9; + --HolidayColor: #ffc107; + --WeekendColor: #f5f5f5; + --BorderColor: #e0e0e0; + --user-leave-bg: #fee2e2; + --user-holiday-bg: #fef3c7; + --team-cell-border: #e5e7eb; +} + +.calendar-app { + --status-available: #f0f0f0; + --status-leave: #ff6b6b; + --status-holiday: #ffd93d; + --status-weekend: #a8dadc; + --status-selected: #4dabf7; + --calendar-border: #dee2e6; + --calendar-hover: #e9ecef; + + // App-specific global styles +} + +.primaryButton { + background-color: var(--LeaveColor); + color: white; + border: none; + border-radius: 4px; + padding: 8px 16px; + cursor: pointer; + + &:hover { + opacity: 0.9; + } +} diff --git a/src/apps/calendar/src/lib/utils/calendar.utils.ts b/src/apps/calendar/src/lib/utils/calendar.utils.ts new file mode 100644 index 000000000..b1e6fd505 --- /dev/null +++ b/src/apps/calendar/src/lib/utils/calendar.utils.ts @@ -0,0 +1,56 @@ +import { eachDayOfInterval, endOfMonth, format, startOfMonth } from 'date-fns' + +import { LeaveDate, LeaveStatus } from '../models' + +const statusColorMap: Record = { + [LeaveStatus.LEAVE]: 'status-leave', + [LeaveStatus.WIPRO_HOLIDAY]: 'status-holiday', + [LeaveStatus.WEEKEND]: 'status-weekend', + [LeaveStatus.AVAILABLE]: 'status-available', +} + +const statusLabelMap: Record = { + [LeaveStatus.LEAVE]: 'Leave', + [LeaveStatus.WIPRO_HOLIDAY]: 'Wipro Holiday', + [LeaveStatus.WEEKEND]: 'Weekend', + [LeaveStatus.AVAILABLE]: 'Available', +} + +export const legendStatusOrder: LeaveStatus[] = [ + LeaveStatus.AVAILABLE, + LeaveStatus.LEAVE, + LeaveStatus.WIPRO_HOLIDAY, + LeaveStatus.WEEKEND, +] + +export const getMonthDates = (year: number, month: number): Date[] => { + const monthDate = new Date(year, month, 1) + + return eachDayOfInterval({ + end: endOfMonth(monthDate), + start: startOfMonth(monthDate), + }) +} + +export const formatMonthYear = (date: Date): string => format(date, 'LLLL yyyy') + +export const getDateKey = (date: Date): string => format(date, 'yyyy-MM-dd') + +export const getStatusForDate = (date: Date, leaveDates: LeaveDate[]): LeaveStatus => { + const dateKey = getDateKey(date) + const match = leaveDates.find(item => item.date === dateKey) + + return match?.status ?? LeaveStatus.AVAILABLE +} + +export const getStatusColor = (status: LeaveStatus): string => ( + statusColorMap[status] ?? statusColorMap[LeaveStatus.AVAILABLE] +) + +export const getStatusLabel = (status: LeaveStatus): string => statusLabelMap[status] + +export const legendItems = legendStatusOrder.map(status => ({ + label: getStatusLabel(status), + status, + statusClass: getStatusColor(status), +})) diff --git a/src/apps/calendar/src/lib/utils/index.ts b/src/apps/calendar/src/lib/utils/index.ts new file mode 100644 index 000000000..91a1467b7 --- /dev/null +++ b/src/apps/calendar/src/lib/utils/index.ts @@ -0,0 +1 @@ +export * from './calendar.utils' diff --git a/src/apps/calendar/src/pages/personal-calendar/PersonalCalendarPage.module.scss b/src/apps/calendar/src/pages/personal-calendar/PersonalCalendarPage.module.scss new file mode 100644 index 000000000..e4a32af5d --- /dev/null +++ b/src/apps/calendar/src/pages/personal-calendar/PersonalCalendarPage.module.scss @@ -0,0 +1,77 @@ +.page { + max-width: 1080px; + margin: 0 auto; + padding: 24px 16px 48px; +} + +.header { + margin-bottom: 12px; +} + +.subtitle { + margin: 0; + font-size: 14px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: #6b7280; +} + +.title { + margin: 2px 0 0; + font-size: 28px; + font-weight: 800; + color: #111827; +} + +.navigation { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + margin: 12px 0 8px; +} + +.calendarSection { + margin: 0 auto; +} + +.actions { + margin-top: 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + +.actionButtons { + display: inline-flex; + gap: 12px; + flex-wrap: wrap; +} + +.selectionInfo { + font-weight: 700; + color: #374151; +} + +.error { + margin-top: 12px; + padding: 12px 16px; + background: #fff5f5; + border: 1px solid #fda4af; + border-radius: 8px; + color: #b91c1c; + font-weight: 600; +} + +@media (max-width: 720px) { + .actions { + flex-direction: column; + align-items: flex-start; + } + + .selectionInfo { + order: 2; + } +} diff --git a/src/apps/calendar/src/pages/personal-calendar/PersonalCalendarPage.tsx b/src/apps/calendar/src/pages/personal-calendar/PersonalCalendarPage.tsx new file mode 100644 index 000000000..f0a70c87b --- /dev/null +++ b/src/apps/calendar/src/pages/personal-calendar/PersonalCalendarPage.tsx @@ -0,0 +1,154 @@ +import { addMonths, endOfMonth, startOfMonth, subMonths } from 'date-fns' +import { FC, useCallback, useContext, useEffect, useMemo, useState } from 'react' + +import { Button } from '~/libs/ui' + +import { Calendar, CalendarLegend, MonthNavigation } from '../../lib/components' +import { CalendarContext } from '../../lib/contexts/CalendarContext' +import { useFetchLeaveDates } from '../../lib/hooks' +import { LeaveStatus } from '../../lib/models' + +import styles from './PersonalCalendarPage.module.scss' + +const PersonalCalendarPage: FC = () => { + const calendarContext = useContext(CalendarContext) + const [currentDate, setCurrentDate] = useState(new Date()) + const [selectedDates, setSelectedDates] = useState>(new Set()) + const leaveDatesState = useFetchLeaveDates() + const error = leaveDatesState.error + const isLoading = leaveDatesState.isLoading + const isUpdating = leaveDatesState.isUpdating + const leaveDates = leaveDatesState.leaveDates + const loadLeaveDates = leaveDatesState.loadLeaveDates + const updateLeaveDates = leaveDatesState.updateLeaveDates + const [actionError, setActionError] = useState('') + + const loadCurrentMonth = useCallback(async () => { + setActionError('') + try { + await loadLeaveDates( + startOfMonth(currentDate), + endOfMonth(currentDate), + ) + } catch { + setActionError('Unable to load leave dates. Please try again.') + } + }, [currentDate, loadLeaveDates]) + + useEffect(() => { + loadCurrentMonth() + }, [loadCurrentMonth]) + + const handlePrevMonth = useCallback(() => { + setSelectedDates(new Set()) + setCurrentDate(prev => subMonths(prev, 1)) + }, []) + + const handleNextMonth = useCallback(() => { + setSelectedDates(new Set()) + setCurrentDate(prev => addMonths(prev, 1)) + }, []) + + const handleDateClick = useCallback((dateKey: string) => { + setSelectedDates(prev => { + const next = new Set(prev) + if (next.has(dateKey)) { + next.delete(dateKey) + } else { + next.add(dateKey) + } + + return next + }) + }, []) + + const handleSetAsLeave = useCallback(async () => { + if (!selectedDates.size) return + + setActionError('') + try { + await updateLeaveDates(Array.from(selectedDates), LeaveStatus.LEAVE) + setSelectedDates(new Set()) + await loadCurrentMonth() + } catch { + setActionError('Unable to update leave dates. Please try again.') + } + }, [loadCurrentMonth, selectedDates, updateLeaveDates]) + + const handleSetAsAvailable = useCallback(async () => { + if (!selectedDates.size) return + + setActionError('') + try { + await updateLeaveDates(Array.from(selectedDates), LeaveStatus.AVAILABLE) + setSelectedDates(new Set()) + await loadCurrentMonth() + } catch { + setActionError('Unable to update leave dates. Please try again.') + } + }, [loadCurrentMonth, selectedDates, updateLeaveDates]) + + const selectionLabel = useMemo(() => { + const count = selectedDates.size + if (!count) return 'No dates selected' + return `${count} date${count > 1 ? 's' : ''} selected` + }, [selectedDates]) + + const errorMessage = actionError || (error ? 'Something went wrong. Please try again.' : '') + + return ( +
+
+

Welcome back

+

+ {calendarContext.loginUserInfo?.handle ?? 'Your calendar'} +

+
+ +
+ + +
+ +
+ +
+ +
+
+ + +
+
{selectionLabel}
+
+ + {errorMessage && ( +
{errorMessage}
+ )} +
+ ) +} + +export default PersonalCalendarPage diff --git a/src/apps/calendar/src/pages/personal-calendar/index.ts b/src/apps/calendar/src/pages/personal-calendar/index.ts new file mode 100644 index 000000000..5141e08f7 --- /dev/null +++ b/src/apps/calendar/src/pages/personal-calendar/index.ts @@ -0,0 +1 @@ +export { default as PersonalCalendarPage } from './PersonalCalendarPage' diff --git a/src/apps/calendar/src/pages/team-calendar/TeamCalendarPage.module.scss b/src/apps/calendar/src/pages/team-calendar/TeamCalendarPage.module.scss new file mode 100644 index 000000000..4735ddd7f --- /dev/null +++ b/src/apps/calendar/src/pages/team-calendar/TeamCalendarPage.module.scss @@ -0,0 +1,46 @@ +.page { + max-width: 1200px; + margin: 0 auto; + padding: 24px 16px 48px; +} + +.header { + margin-bottom: 8px; +} + +.title { + margin: 0; + font-size: 28px; + font-weight: 800; + color: #111827; +} + +.navigation { + display: flex; + justify-content: center; + margin: 14px 0 10px; +} + +.calendarSection { + margin: 0 auto; +} + +.error { + margin-top: 12px; + padding: 12px 16px; + background: #fff5f5; + border: 1px solid #fda4af; + border-radius: 8px; + color: #b91c1c; + font-weight: 600; +} + +@media (max-width: 720px) { + .page { + padding: 20px 12px 32px; + } + + .title { + font-size: 24px; + } +} diff --git a/src/apps/calendar/src/pages/team-calendar/TeamCalendarPage.tsx b/src/apps/calendar/src/pages/team-calendar/TeamCalendarPage.tsx new file mode 100644 index 000000000..ab50c6d28 --- /dev/null +++ b/src/apps/calendar/src/pages/team-calendar/TeamCalendarPage.tsx @@ -0,0 +1,77 @@ +import { addMonths, endOfMonth, startOfMonth, subMonths } from 'date-fns' +import { FC, useCallback, useEffect, useMemo, useState } from 'react' + +import { MonthNavigation, TeamCalendar } from '../../lib/components' +import { useFetchTeamLeave } from '../../lib/hooks' + +import styles from './TeamCalendarPage.module.scss' + +const TeamCalendarPage: FC = () => { + const [currentDate, setCurrentDate] = useState(new Date()) + const [actionError, setActionError] = useState('') + const teamLeaveState = useFetchTeamLeave() + const error = teamLeaveState.error + const isLoading = teamLeaveState.isLoading + const loadTeamLeave = teamLeaveState.loadTeamLeave + const teamLeaveDates = teamLeaveState.teamLeaveDates + + const loadCurrentMonth = useCallback(async () => { + setActionError('') + try { + await loadTeamLeave( + startOfMonth(currentDate), + endOfMonth(currentDate), + ) + } catch { + setActionError('Unable to load team leave. Please try again.') + } + }, [currentDate, loadTeamLeave]) + + useEffect(() => { + loadCurrentMonth() + }, [loadCurrentMonth]) + + const handlePrevMonth = useCallback(() => { + setCurrentDate(prev => subMonths(prev, 1)) + }, []) + + const handleNextMonth = useCallback(() => { + setCurrentDate(prev => addMonths(prev, 1)) + }, []) + + const errorMessage = useMemo(() => { + if (actionError) return actionError + if (error) return 'Something went wrong. Please try again.' + return '' + }, [actionError, error]) + + return ( +
+
+

Team Leave Calendar

+
+ +
+ +
+ +
+ +
+ + {errorMessage && ( +
{errorMessage}
+ )} +
+ ) +} + +export default TeamCalendarPage diff --git a/src/apps/calendar/src/pages/team-calendar/index.ts b/src/apps/calendar/src/pages/team-calendar/index.ts new file mode 100644 index 000000000..be8c78227 --- /dev/null +++ b/src/apps/calendar/src/pages/team-calendar/index.ts @@ -0,0 +1 @@ +export { default as TeamCalendarPage } from './TeamCalendarPage' diff --git a/src/apps/onboarding/src/pages/onboarding/index.tsx b/src/apps/onboarding/src/pages/onboarding/index.tsx index 55447227d..525fd7485 100644 --- a/src/apps/onboarding/src/pages/onboarding/index.tsx +++ b/src/apps/onboarding/src/pages/onboarding/index.tsx @@ -1,12 +1,10 @@ import { FC, useContext, useEffect } from 'react' import { Outlet, Routes, useLocation } from 'react-router-dom' -import { Provider, useDispatch, useSelector } from 'react-redux' +import { Provider, useDispatch } from 'react-redux' import classNames from 'classnames' import { routerContext, RouterContextData } from '~/libs/core' -import { Member } from '~/apps/talent-search/src/lib/models' import { SharedSwrConfig } from '~/libs/shared' -import { EnvironmentConfig } from '~/config' import { onboardRouteId } from '../../onboarding.routes' import { fetchMemberInfo, fetchMemberTraits } from '../../redux/actions/member' @@ -20,7 +18,6 @@ const OnboardingContent: FC<{ const { getChildRoutes }: RouterContextData = useContext(routerContext) const location = useLocation() const dispatch = useDispatch() - const reduxMemberInfo: Member = useSelector((state: any) => state.member.memberInfo) useEffect(() => { dispatch(fetchMemberInfo()) @@ -43,11 +40,6 @@ const OnboardingContent: FC<{ {getChildRoutes(onboardRouteId)}
- - I will complete this onboarding later, - skip for now - . - ) } diff --git a/src/apps/onboarding/src/pages/onboarding/styles.module.scss b/src/apps/onboarding/src/pages/onboarding/styles.module.scss index 6b05aae3e..585282491 100644 --- a/src/apps/onboarding/src/pages/onboarding/styles.module.scss +++ b/src/apps/onboarding/src/pages/onboarding/styles.module.scss @@ -68,19 +68,3 @@ text-transform: none; } } - -.textFooter { - color: $black-80; - margin-top: 64px; - text-align: center; - - @include ltemd { - margin-top: 32px; - max-width: 284px; - } - - a { - color: $turq-160; - font-weight: 500; - } -} \ No newline at end of file diff --git a/src/apps/onboarding/src/pages/skills/index.tsx b/src/apps/onboarding/src/pages/skills/index.tsx index 064e02cc7..9ac5a1606 100644 --- a/src/apps/onboarding/src/pages/skills/index.tsx +++ b/src/apps/onboarding/src/pages/skills/index.tsx @@ -1,5 +1,5 @@ import { useNavigate } from 'react-router-dom' -import { FC, useState } from 'react' +import { FC, useEffect, useState } from 'react' import { connect } from 'react-redux' import classNames from 'classnames' @@ -17,8 +17,15 @@ export const PageSkillsContent: FC<{ const navigate: any = useNavigate() const [loading, setLoading] = useState(false) const editor: MemberSkillEditor = useMemberSkillEditor() + const [showValidationError, setShowValidationError] = useState(false) async function saveSkills(): Promise { + if (!editor.hasValidSkills()) { + setShowValidationError(true) + return + } + + setShowValidationError(false) setLoading(true) try { await editor.saveSkills() @@ -29,6 +36,12 @@ export const PageSkillsContent: FC<{ navigate('../open-to-work') } + useEffect(() => { + if (editor.hasValidSkills()) { + setShowValidationError(false) + } + }, [editor]) + return (

@@ -56,6 +69,11 @@ export const PageSkillsContent: FC<{ progress={1} maxStep={5} /> + {showValidationError && ( + + * Please select at least one skill in both Principal and Additional Skills. + + )}