nakama/frontend/src/pages/MonthlyLadder.jsx

320 lines
12 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { ChevronLeft, Info, Gift, Trophy, Star } from 'lucide-react'
import { getLadderTop } from '../utils/api'
import { hapticFeedback } from '../utils/telegram'
import './MonthlyLadder.css'
export default function MonthlyLadder({ user }) {
const navigate = useNavigate()
const [topUsers, setTopUsers] = useState([])
const [currentUser, setCurrentUser] = useState(null)
const [currentUserRank, setCurrentUserRank] = useState(null)
const [loading, setLoading] = useState(true)
const [showInfo, setShowInfo] = useState(false)
const [timeLeft, setTimeLeft] = useState({ days: 0, hours: 0, minutes: 0, seconds: 0 })
useEffect(() => {
loadLadder()
updateCountdown()
const interval = setInterval(updateCountdown, 1000)
return () => clearInterval(interval)
}, [])
const updateCountdown = () => {
// Получить текущее московское время
const getMoscowTime = () => {
const now = new Date()
// Москва = UTC+3
const moscowOffset = 3 * 60 * 60 * 1000 // 3 часа в миллисекундах
const utcTime = now.getTime() + (now.getTimezoneOffset() * 60 * 1000)
return new Date(utcTime + moscowOffset)
}
// Получить новогоднюю дату по московскому времени (1 января следующего года, 00:00 MSK)
const getNewYearMoscow = () => {
const moscowNow = getMoscowTime()
const year = moscowNow.getFullYear() + 1
// Создаем дату 1 января следующего года в UTC, затем вычитаем смещение
const moscowNewYear = new Date(Date.UTC(year, 0, 1, 0, 0, 0, 0))
const moscowOffset = 3 * 60 * 60 * 1000
return new Date(moscowNewYear.getTime() - moscowOffset)
}
const now = getMoscowTime()
const newYear = getNewYearMoscow()
const diff = newYear.getTime() - now.getTime()
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
const seconds = Math.floor((diff % (1000 * 60)) / 1000)
setTimeLeft({ days, hours, minutes, seconds })
}
const loadLadder = async () => {
try {
setLoading(true)
const data = await getLadderTop(5)
setTopUsers(data.topUsers || [])
setCurrentUser(data.currentUser)
setCurrentUserRank(data.currentUserRank)
} catch (error) {
console.error('Ошибка загрузки ладдера:', error)
} finally {
setLoading(false)
}
}
const getRankIcon = (rank) => {
switch (rank) {
case 1:
return <Trophy size={24} className="rank-icon gold" />
case 2:
return <Trophy size={24} className="rank-icon silver" />
case 3:
return <Trophy size={24} className="rank-icon bronze" />
default:
return <span className="rank-number">{rank}</span>
}
}
const getPrize = (rank) => {
switch (rank) {
case 1:
return '$50'
case 2:
return '$30'
case 3:
return '$15'
case 4:
return '$5'
case 5:
return '$5'
default:
return null
}
}
const formatTickets = (tickets) => {
return tickets?.toLocaleString('ru-RU') || '0'
}
const getTicketsWord = (tickets) => {
const num = tickets || 0
const lastDigit = num % 10
const lastTwoDigits = num % 100
// Исключения для 11-14
if (lastTwoDigits >= 11 && lastTwoDigits <= 14) {
return 'билетов'
}
// 1, 21, 31, 41... - билет
if (lastDigit === 1) {
return 'билет'
}
// 2, 3, 4, 22, 23, 24... - билета
if (lastDigit >= 2 && lastDigit <= 4) {
return 'билета'
}
// Остальные - билетов
return 'билетов'
}
return (
<div className="ladder-page">
{/* Хедер */}
<div className="ladder-header">
<button className="back-btn" onClick={() => navigate(-1)}>
<ChevronLeft size={24} />
</button>
<h1>Monthly Ladder</h1>
<div style={{ width: 44 }} />
</div>
{/* Новогодний декор */}
<div className="new-year-decorations">
<div className="snowflake"></div>
<div className="snowflake"></div>
<div className="snowflake"></div>
<div className="snowflake"></div>
<div className="snowflake"></div>
<div className="snowflake"></div>
</div>
{/* Отсчет до нового года */}
<div className="countdown-card">
<div className="countdown-title">
<Gift size={24} className="gift-icon" />
<h2>До Нового Года</h2>
</div>
<div className="countdown-timer">
<div className="countdown-item">
<span className="countdown-value">{timeLeft.days}</span>
<span className="countdown-label">дней</span>
</div>
<div className="countdown-separator">:</div>
<div className="countdown-item">
<span className="countdown-value">{String(timeLeft.hours).padStart(2, '0')}</span>
<span className="countdown-label">часов</span>
</div>
<div className="countdown-separator">:</div>
<div className="countdown-item">
<span className="countdown-value">{String(timeLeft.minutes).padStart(2, '0')}</span>
<span className="countdown-label">минут</span>
</div>
<div className="countdown-separator">:</div>
<div className="countdown-item">
<span className="countdown-value">{String(timeLeft.seconds).padStart(2, '0')}</span>
<span className="countdown-label">секунд</span>
</div>
</div>
<p className="countdown-slogan">Ваши посты, ваши арты, ваша слава. Остальное потом.</p>
</div>
{/* Топ 5 пользователей */}
<div className="ladder-top">
<div className="ladder-top-header">
<h2>Топ 5</h2>
<button
className="info-btn"
onClick={() => {
setShowInfo(true)
hapticFeedback('light')
}}
>
<Info size={20} />
<span>За что начисляются билеты</span>
</button>
</div>
{loading ? (
<div className="loading-state">
<div className="spinner" />
</div>
) : (
<div className="top-users-list">
{topUsers.map((topUser, index) => {
const isCurrentUser = user && (topUser._id === user.id || topUser._id?.toString() === user.id?.toString())
const prize = getPrize(topUser.rank)
return (
<div
key={topUser._id}
className={`top-user-item ${isCurrentUser ? 'current-user' : ''}`}
>
<div className="user-rank">
{getRankIcon(topUser.rank)}
</div>
<img
src={topUser.photoUrl || '/default-avatar.png'}
alt={topUser.username}
className="user-avatar"
/>
<div className="user-info">
<div className="user-name">
{topUser.firstName || topUser.username}
{isCurrentUser && <Star size={16} className="current-badge" />}
</div>
</div>
<div className="user-stats">
<span className="user-tickets">{formatTickets(topUser.tickets)} {getTicketsWord(topUser.tickets)}</span>
{prize && <span className="user-prize">{prize}</span>}
</div>
</div>
)
})}
</div>
)}
</div>
{/* Текущий пользователь (если не в топе) */}
{currentUser && currentUserRank > 5 && (
<div className="current-user-card">
<h3>Ваша позиция</h3>
<div className="current-user-item">
<div className="user-rank">
<span className="rank-number">{currentUserRank}</span>
</div>
<img
src={currentUser.photoUrl || '/default-avatar.png'}
alt={currentUser.username}
className="user-avatar"
/>
<div className="user-info">
<div className="user-name">
{currentUser.firstName || currentUser.username}
<Star size={16} className="current-badge" />
</div>
</div>
<div className="user-stats">
<span className="user-tickets">{formatTickets(currentUser.tickets)} {getTicketsWord(currentUser.tickets)}</span>
</div>
</div>
</div>
)}
{/* Модальное окно с информацией */}
{showInfo && (
<div className="info-modal-overlay" onClick={() => setShowInfo(false)}>
<div className="info-modal" onClick={(e) => e.stopPropagation()}>
<div className="info-modal-header">
<h2>За что начисляются билеты</h2>
<button className="close-btn" onClick={() => setShowInfo(false)}>×</button>
</div>
<div className="info-modal-content">
<div className="info-section">
<h3>1. Посты</h3>
<p>+15 билетов за создание поста</p>
<p className="info-limit">Лимит: 5 постов в день</p>
</div>
<div className="info-section">
<h3>2. Лайки</h3>
<p><strong>Ставишь лайки:</strong> +1 билет за лайк</p>
<p className="info-limit">Лимит: 50 в день</p>
<p><strong>Получаешь лайки:</strong> +2 билета за лайк под твоей записью</p>
<p className="info-limit">Лимит учёта: 100 лайков в день</p>
</div>
<div className="info-section">
<h3>3. Комментарии</h3>
<p><strong>Пишешь комментарии:</strong> +4 билета за комментарий длиной 10+ символов</p>
<p className="info-limit">Лимит: 20 комментариев в день</p>
<p><strong>Получаешь комментарии:</strong> +6 билетов за комментарий под твоим постом</p>
</div>
<div className="info-section">
<h3>4. Рефералы</h3>
<p>+100 билетов за одного валидного реферала</p>
<p className="info-limit">Лимит: 3 реферала в день</p>
</div>
<div className="info-section">
<h3>5. Ваше творчество (арты)</h3>
<p><strong>Публикация:</strong> +40 билетов за арт, прошедший модерацию</p>
<p className="info-limit">Лимит: 1 арт в день / 5 в неделю</p>
<p><strong>Реакции на арт:</strong></p>
<p>+8 билетов за лайк под артом</p>
<p>+12 билетов за комментарий под артом (1 комментарий от одного человека в сутки)</p>
<p className="info-limit">Лимит: до 100 билетов в сутки с реакций на один арт</p>
</div>
<div className="info-section anti-fraud">
<h3>Немного правил</h3>
<p>Лайки/комменты от аккаунтов младше 24 часов не считаем</p>
<p>Комменты &lt;10 символов = 0 билетов</p>
<p>Ограничение на билеты по входящим реакциям, чтобы боты не устроили ферму</p>
</div>
</div>
</div>
</div>
)}
</div>
)
}