Update files
This commit is contained in:
parent
73fa4a6ded
commit
4f39cb38fe
|
|
@ -172,96 +172,16 @@ const authenticate = async (req, res, next) => {
|
|||
let user = await User.findOne({ telegramId: normalizedUser.id.toString() });
|
||||
|
||||
if (!user) {
|
||||
// Обработка реферального кода из start_param
|
||||
let referredBy = null;
|
||||
if (startParam) {
|
||||
const trimmedParam = startParam.trim();
|
||||
// Проверяем регистронезависимо (может быть ref_ или REF_)
|
||||
const normalizedStartParam = trimmedParam.toLowerCase();
|
||||
|
||||
if (normalizedStartParam.startsWith('ref_')) {
|
||||
// Убираем все префиксы ref_/REF_ (может быть двойной префикс ref_REF_)
|
||||
let codeToSearch = trimmedParam;
|
||||
while (codeToSearch.toLowerCase().startsWith('ref_')) {
|
||||
codeToSearch = codeToSearch.substring(4); // Убираем "ref_" или "REF_"
|
||||
}
|
||||
|
||||
// referralCode в базе хранится с префиксом "REF_" (в верхнем регистре)
|
||||
// Ищем по коду с префиксом REF_
|
||||
const escapedCode = codeToSearch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const referrer = await User.findOne({
|
||||
referralCode: { $regex: new RegExp(`^REF_${escapedCode}$`, 'i') }
|
||||
});
|
||||
|
||||
if (referrer) {
|
||||
referredBy = referrer._id;
|
||||
console.log(`🔗 Реферальная ссылка: пользователь ${normalizedUser.username || normalizedUser.id} зарегистрирован по ссылке от ${referrer.username} (${referrer._id}), код: ${trimmedParam} -> REF_${codeToSearch}`);
|
||||
} else {
|
||||
// Дополнительная проверка: посмотрим все referralCode в базе для отладки
|
||||
const allCodes = await User.find({ referralCode: { $exists: true } }, { referralCode: 1, username: 1 }).limit(5);
|
||||
console.warn(`⚠️ Реферальный код не найден: ${trimmedParam} (искали: REF_${codeToSearch})`);
|
||||
console.warn(` Примеры кодов в базе: ${allCodes.map(u => u.referralCode).join(', ')}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`ℹ️ startParam не содержит ref_: ${trimmedParam}`);
|
||||
}
|
||||
}
|
||||
|
||||
user = new User({
|
||||
telegramId: normalizedUser.id.toString(),
|
||||
username: normalizedUser.username || normalizedUser.firstName || 'user',
|
||||
firstName: normalizedUser.firstName,
|
||||
lastName: normalizedUser.lastName,
|
||||
photoUrl: normalizedUser.photoUrl,
|
||||
referredBy: referredBy
|
||||
photoUrl: normalizedUser.photoUrl
|
||||
});
|
||||
await user.save();
|
||||
|
||||
if (referredBy) {
|
||||
console.log(`✅ Создан новый пользователь ${user.username} (${user._id}) с referredBy: ${referredBy}`);
|
||||
console.log(`✅ Создан новый пользователь ${user.username} (${user._id})`);
|
||||
} else {
|
||||
console.log(`✅ Создан новый пользователь ${user.username} (${user._id}) без реферала`);
|
||||
}
|
||||
|
||||
// Счетчик рефералов увеличивается только когда пользователь создаст первый пост
|
||||
// (см. routes/posts.js)
|
||||
} else {
|
||||
// Для существующих пользователей тоже можно установить referredBy,
|
||||
// если они еще не были засчитаны как реферал и пришли по реферальной ссылке
|
||||
if (startParam && !user.referredBy && !user.referralCounted) {
|
||||
const trimmedParam = startParam.trim();
|
||||
const normalizedStartParam = trimmedParam.toLowerCase();
|
||||
|
||||
if (normalizedStartParam.startsWith('ref_')) {
|
||||
// Ищем по полному коду (с префиксом ref_)
|
||||
const referrer = await User.findOne({
|
||||
referralCode: { $regex: new RegExp(`^${trimmedParam.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') }
|
||||
});
|
||||
|
||||
if (referrer) {
|
||||
user.referredBy = referrer._id;
|
||||
await user.save();
|
||||
console.log(`🔗 Установлен referredBy для существующего пользователя ${user.username} от ${referrer.username} (код: ${trimmedParam})`);
|
||||
} else {
|
||||
// Попробуем альтернативный поиск
|
||||
const codeWithoutPrefix = trimmedParam.substring(4);
|
||||
const alternativeSearch = await User.findOne({
|
||||
$or: [
|
||||
{ referralCode: { $regex: new RegExp(`^ref_${codeWithoutPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') } },
|
||||
{ referralCode: { $regex: new RegExp(`^REF_${codeWithoutPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') } }
|
||||
]
|
||||
});
|
||||
|
||||
if (alternativeSearch) {
|
||||
user.referredBy = alternativeSearch._id;
|
||||
await user.save();
|
||||
console.log(`🔗 Установлен referredBy (альтернативный поиск) для существующего пользователя ${user.username} от ${alternativeSearch.username}`);
|
||||
} else {
|
||||
console.warn(`⚠️ Реферальный код не найден для существующего пользователя ${user.username}: ${trimmedParam}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Обновлять только если есть новые данные, не перезаписывать существующие пустыми значениями
|
||||
if (normalizedUser.username) {
|
||||
user.username = normalizedUser.username;
|
||||
|
|
@ -295,81 +215,6 @@ const authenticate = async (req, res, next) => {
|
|||
await ensureUserSettings(user);
|
||||
await touchUserActivity(user);
|
||||
|
||||
// Реферальная система: отслеживание входов в разные дни
|
||||
// Останавливаем отслеживание после засчета реферала, чтобы не заполнять БД
|
||||
if (user.referredBy && !user.referralCounted) {
|
||||
// Инициализировать loginDates если его нет
|
||||
if (!user.loginDates) {
|
||||
user.loginDates = [];
|
||||
}
|
||||
|
||||
const { getMoscowStartOfDay, getMoscowDate } = require('../utils/moscowTime');
|
||||
const today = getMoscowDate();
|
||||
const todayNormalized = getMoscowStartOfDay(today);
|
||||
const todayTime = todayNormalized.getTime();
|
||||
|
||||
// Получить уникальные даты из существующего массива (только даты, без времени по московскому времени)
|
||||
const uniqueDates = new Set();
|
||||
if (user.loginDates && Array.isArray(user.loginDates)) {
|
||||
user.loginDates.forEach(date => {
|
||||
if (!date) return;
|
||||
try {
|
||||
// Нормализуем дату: получаем начало дня по московскому времени
|
||||
const dateObj = new Date(date);
|
||||
const normalizedDate = getMoscowStartOfDay(dateObj);
|
||||
const normalizedTime = normalizedDate.getTime();
|
||||
uniqueDates.add(normalizedTime);
|
||||
} catch (error) {
|
||||
console.error(`Ошибка обработки даты ${date}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Добавить сегодняшнюю дату, если её еще нет
|
||||
const todayExists = uniqueDates.has(todayTime);
|
||||
if (!todayExists) {
|
||||
if (!user.loginDates) {
|
||||
user.loginDates = [];
|
||||
}
|
||||
user.loginDates.push(today);
|
||||
uniqueDates.add(todayTime);
|
||||
console.log(`📅 Реферал ${user.username} (${user._id}): добавлена дата входа. Уникальных дат: ${uniqueDates.size}/2`);
|
||||
}
|
||||
|
||||
// Если уже есть 2 или более уникальные даты, засчитать реферала
|
||||
if (uniqueDates.size >= 2) {
|
||||
const User = require('../models/User');
|
||||
const referrer = await User.findById(user.referredBy);
|
||||
|
||||
if (!referrer) {
|
||||
console.error(`❌ Реферер не найден для пользователя ${user.username} (${user._id}), referredBy: ${user.referredBy}`);
|
||||
} else {
|
||||
// Увеличить счетчик рефералов
|
||||
const oldCount = referrer.referralsCount || 0;
|
||||
referrer.referralsCount = oldCount + 1;
|
||||
await referrer.save();
|
||||
|
||||
console.log(`✅ Реферал засчитан: пользователь ${user.username} (${user._id}) засчитан для ${referrer.username} (${referrer._id}). Счетчик: ${oldCount} -> ${referrer.referralsCount}`);
|
||||
|
||||
// Начислить баллы за реферала
|
||||
try {
|
||||
const { awardReferral } = require('../utils/tickets');
|
||||
await awardReferral(user.referredBy);
|
||||
console.log(` ✅ Баллы начислены рефереру ${referrer.username}`);
|
||||
} catch (error) {
|
||||
console.error(` ⚠️ Ошибка начисления баллов:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
user.referralCounted = true;
|
||||
// Очистить loginDates после засчета, чтобы не хранить лишние данные
|
||||
user.loginDates = [];
|
||||
await user.save();
|
||||
} else {
|
||||
// Если еще нет 2 уникальных дат, сохранить обновленный массив loginDates
|
||||
await user.save();
|
||||
}
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
req.telegramUser = normalizedUser;
|
||||
|
|
|
|||
|
|
@ -33,10 +33,6 @@ const TicketActivitySchema = new mongoose.Schema({
|
|||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
referralsCounted: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// Для отслеживания баллов с реакций на арты (по постам)
|
||||
// Используем объект вместо Map для совместимости с Mongoose
|
||||
artReactionsPoints: {
|
||||
|
|
|
|||
|
|
@ -84,29 +84,6 @@ const UserSchema = new mongoose.Schema({
|
|||
default: false
|
||||
},
|
||||
bannedUntil: Date,
|
||||
// Реферальная система
|
||||
referralCode: {
|
||||
type: String,
|
||||
unique: true,
|
||||
sparse: true
|
||||
},
|
||||
referredBy: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User'
|
||||
},
|
||||
referralsCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
// Массив дат входа (для реферальной системы)
|
||||
loginDates: [{
|
||||
type: Date
|
||||
}],
|
||||
// Флаг, что реферал уже засчитан
|
||||
referralCounted: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// Билеты для Monthly Ladder
|
||||
tickets: {
|
||||
type: Number,
|
||||
|
|
@ -137,15 +114,5 @@ const UserSchema = new mongoose.Schema({
|
|||
}
|
||||
});
|
||||
|
||||
// Генерировать реферальный код перед сохранением
|
||||
UserSchema.pre('save', async function(next) {
|
||||
if (!this.referralCode) {
|
||||
// Генерировать уникальный код на основе telegramId
|
||||
const code = `ref_${this.telegramId.slice(-8)}${Math.random().toString(36).substring(2, 6)}`.toUpperCase();
|
||||
this.referralCode = code;
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
module.exports = mongoose.model('User', UserSchema);
|
||||
|
||||
|
|
|
|||
|
|
@ -57,8 +57,6 @@ const respondWithUser = async (user, res) => {
|
|||
followingCount: populatedUser.following.length,
|
||||
followers: populatedUser.followers,
|
||||
following: populatedUser.following,
|
||||
referralCode: populatedUser.referralCode,
|
||||
referralsCount: populatedUser.referralsCount || 0,
|
||||
tickets: populatedUser.tickets || 0,
|
||||
settings,
|
||||
banned: populatedUser.banned
|
||||
|
|
|
|||
|
|
@ -82,7 +82,6 @@ const serializeUser = (user) => {
|
|||
bannedUntil: user.bannedUntil,
|
||||
lastActiveAt: user.lastActiveAt,
|
||||
createdAt: user.createdAt,
|
||||
referralsCount: user.referralsCount || 0,
|
||||
// passwordHash никогда не возвращается (уже select: false в модели)
|
||||
// email не возвращается для безопасности
|
||||
};
|
||||
|
|
|
|||
|
|
@ -204,73 +204,5 @@ router.get('/search/:query', authenticate, async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Получить топ пользователей по билетам (Monthly Ladder)
|
||||
router.get('/ladder/top', authenticate, async (req, res) => {
|
||||
try {
|
||||
const { limit = 5 } = req.query;
|
||||
const limitNum = parseInt(limit);
|
||||
|
||||
// Получить топ пользователей по билетам (исключаем glpshchn00)
|
||||
// Берем немного больше, чтобы гарантировать топ 5 после фильтрации
|
||||
const topUsers = await User.find({
|
||||
banned: { $ne: true },
|
||||
username: { $ne: 'glpshchn00' }
|
||||
})
|
||||
.select('username firstName lastName photoUrl tickets')
|
||||
.sort({ tickets: -1 })
|
||||
.limit(limitNum)
|
||||
.lean();
|
||||
|
||||
// Найти позицию текущего пользователя (исключаем glpshchn00)
|
||||
const userTickets = req.user.tickets || 0;
|
||||
const userRank = await User.countDocuments({
|
||||
tickets: { $gt: userTickets },
|
||||
banned: { $ne: true },
|
||||
username: { $ne: 'glpshchn00' }
|
||||
}) + 1;
|
||||
|
||||
// Исключить glpshchn00 из отображения
|
||||
const isExcludedUser = req.user.username === 'glpshchn00';
|
||||
|
||||
// Проверить, есть ли текущий пользователь в топе
|
||||
const currentUserInTop = !isExcludedUser && topUsers.some(u => u._id.toString() === req.user._id.toString());
|
||||
|
||||
// Если пользователь не в топе и не исключен, добавить его отдельно
|
||||
let currentUserData = null;
|
||||
if (!isExcludedUser && !currentUserInTop) {
|
||||
currentUserData = {
|
||||
_id: req.user._id,
|
||||
username: req.user.username,
|
||||
firstName: req.user.firstName,
|
||||
lastName: req.user.lastName,
|
||||
photoUrl: req.user.photoUrl,
|
||||
tickets: req.user.tickets || 0,
|
||||
rank: userRank
|
||||
};
|
||||
} else if (!isExcludedUser && currentUserInTop) {
|
||||
// Если в топе, добавить rank к существующему пользователю
|
||||
topUsers.forEach((user, index) => {
|
||||
if (user._id.toString() === req.user._id.toString()) {
|
||||
user.rank = index + 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Добавить rank к топ пользователям
|
||||
topUsers.forEach((user, index) => {
|
||||
user.rank = index + 1;
|
||||
});
|
||||
|
||||
res.json({
|
||||
topUsers,
|
||||
currentUser: currentUserData,
|
||||
currentUserRank: isExcludedUser ? null : (currentUserInTop ? topUsers.find(u => u._id.toString() === req.user._id.toString())?.rank : userRank)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Ошибка получения топа:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
|
|
|
|||
|
|
@ -33,9 +33,6 @@ async function canAwardTickets(userId, actionType, options = {}) {
|
|||
case 'comment_received':
|
||||
return true; // Нет лимита на получение комментариев
|
||||
|
||||
case 'referral':
|
||||
return activity.referralsCounted < 3;
|
||||
|
||||
case 'art_reaction':
|
||||
// Проверка лимита на реакцию на арт (100 баллов в сутки с одного арта)
|
||||
const postId = options.postId;
|
||||
|
|
@ -108,9 +105,6 @@ async function awardTickets(userId, points, actionType, options = {}) {
|
|||
case 'comment_received':
|
||||
activity.commentsReceived += 1;
|
||||
break;
|
||||
case 'referral':
|
||||
activity.referralsCounted += 1;
|
||||
break;
|
||||
case 'art_reaction':
|
||||
const postId = options.postId;
|
||||
if (postId) {
|
||||
|
|
@ -176,13 +170,6 @@ async function awardCommentReceived(authorId, commenterId) {
|
|||
return await awardTickets(authorId, 6, 'comment_received', { senderId: commenterId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Начисляет баллы за реферала
|
||||
*/
|
||||
async function awardReferral(userId) {
|
||||
return await awardTickets(userId, 100, 'referral');
|
||||
}
|
||||
|
||||
/**
|
||||
* Начисляет баллы за реакцию на арт (лайк)
|
||||
*/
|
||||
|
|
@ -469,7 +456,6 @@ module.exports = {
|
|||
awardLikeReceived,
|
||||
awardCommentWritten,
|
||||
awardCommentReceived,
|
||||
awardReferral,
|
||||
awardArtLike,
|
||||
awardArtComment,
|
||||
awardArtModeration,
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import Profile from './pages/Profile'
|
|||
import UserProfile from './pages/UserProfile'
|
||||
import CommentsPage from './pages/CommentsPage'
|
||||
import PostMenuPage from './pages/PostMenuPage'
|
||||
import MonthlyLadder from './pages/MonthlyLadder'
|
||||
import MiniPlayer from './components/MiniPlayer'
|
||||
import FullPlayer from './components/FullPlayer'
|
||||
import './styles/index.css'
|
||||
|
|
@ -233,7 +232,6 @@ function AppContent() {
|
|||
<Route path="user/:id" element={<UserProfile currentUser={user} />} />
|
||||
<Route path="post/:postId/comments" element={<CommentsPage user={user} />} />
|
||||
<Route path="post/:postId/menu" element={<PostMenuPage user={user} />} />
|
||||
<Route path="ladder" element={<MonthlyLadder user={user} />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
.ladder-button {
|
||||
position: fixed;
|
||||
bottom: 140px;
|
||||
right: 16px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #FFD700, #FFA500, #FF6347);
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
box-shadow: 0 4px 20px rgba(255, 215, 0, 0.4),
|
||||
0 0 30px rgba(255, 165, 0, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
z-index: 100;
|
||||
transition: all 0.3s ease;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.ladder-button:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.ladder-button:hover {
|
||||
box-shadow: 0 6px 30px rgba(255, 215, 0, 0.6),
|
||||
0 0 40px rgba(255, 165, 0, 0.5);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.gift-icon {
|
||||
color: #fff;
|
||||
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
|
||||
animation: rotate 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
0%, 100% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: rotate(-10deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate(10deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Адаптация для темной темы */
|
||||
[data-theme="dark"] .ladder-button {
|
||||
box-shadow: 0 4px 20px rgba(255, 215, 0, 0.5),
|
||||
0 0 30px rgba(255, 165, 0, 0.4);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import { useNavigate, useLocation } from 'react-router-dom'
|
||||
import { Gift } from 'lucide-react'
|
||||
import { hapticFeedback } from '../utils/telegram'
|
||||
import './LadderButton.css'
|
||||
|
||||
export default function LadderButton() {
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
|
||||
// Скрыть кнопку на странице ladder и profile
|
||||
if (location.pathname === '/ladder' || location.pathname === '/profile') {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
hapticFeedback('light')
|
||||
navigate('/ladder')
|
||||
}
|
||||
|
||||
return (
|
||||
<button className="ladder-button" onClick={handleClick} aria-label="Monthly Ladder">
|
||||
<Gift size={24} className="gift-icon" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1,6 +1,5 @@
|
|||
import { Outlet } from 'react-router-dom'
|
||||
import Navigation from './Navigation'
|
||||
import LadderButton from './LadderButton'
|
||||
import './Layout.css'
|
||||
|
||||
export default function Layout({ user }) {
|
||||
|
|
@ -10,7 +9,6 @@ export default function Layout({ user }) {
|
|||
<Outlet />
|
||||
</main>
|
||||
<Navigation />
|
||||
<LadderButton />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,571 +0,0 @@
|
|||
.ladder-page {
|
||||
min-height: 100vh;
|
||||
background: var(--bg-primary);
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
/* Новогодние снежинки - тонкие, не навязчивые */
|
||||
.new-year-decorations {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.snowflake {
|
||||
position: absolute;
|
||||
color: var(--text-secondary);
|
||||
font-size: 16px;
|
||||
animation: fall linear infinite;
|
||||
animation-duration: 15s;
|
||||
}
|
||||
|
||||
.snowflake:nth-child(1) {
|
||||
left: 10%;
|
||||
animation-delay: 0s;
|
||||
animation-duration: 12s;
|
||||
}
|
||||
|
||||
.snowflake:nth-child(2) {
|
||||
left: 30%;
|
||||
animation-delay: 2s;
|
||||
animation-duration: 18s;
|
||||
}
|
||||
|
||||
.snowflake:nth-child(3) {
|
||||
left: 50%;
|
||||
animation-delay: 4s;
|
||||
animation-duration: 14s;
|
||||
}
|
||||
|
||||
.snowflake:nth-child(4) {
|
||||
left: 70%;
|
||||
animation-delay: 1s;
|
||||
animation-duration: 16s;
|
||||
}
|
||||
|
||||
.snowflake:nth-child(5) {
|
||||
left: 85%;
|
||||
animation-delay: 3s;
|
||||
animation-duration: 17s;
|
||||
}
|
||||
|
||||
.snowflake:nth-child(6) {
|
||||
left: 20%;
|
||||
animation-delay: 5s;
|
||||
animation-duration: 20s;
|
||||
}
|
||||
|
||||
@keyframes fall {
|
||||
0% {
|
||||
transform: translateY(-100vh) rotate(0deg);
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(100vh) rotate(360deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ladder-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-secondary);
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.ladder-header h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Карточка отсчета - в стиле Nakama с новогодними акцентами */
|
||||
.countdown-card {
|
||||
margin: 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px var(--shadow-md);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
border: 2px solid transparent;
|
||||
background-image:
|
||||
linear-gradient(var(--bg-secondary), var(--bg-secondary)),
|
||||
linear-gradient(135deg, #FFD700, #FFA500);
|
||||
background-origin: border-box;
|
||||
background-clip: padding-box, border-box;
|
||||
}
|
||||
|
||||
.countdown-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.countdown-title h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.gift-icon {
|
||||
color: #FF6347;
|
||||
animation: bounce 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
.countdown-timer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 20px 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.countdown-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.countdown-value {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #FFD700;
|
||||
min-width: 60px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.countdown-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.countdown-separator {
|
||||
font-size: 24px;
|
||||
color: #FFD700;
|
||||
font-weight: 700;
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.countdown-slogan {
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
margin-top: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Топ пользователей - в стиле Nakama */
|
||||
.ladder-top {
|
||||
margin: 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px var(--shadow-md);
|
||||
}
|
||||
|
||||
.ladder-top-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ladder-top-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.info-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 20px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.info-btn:active {
|
||||
transform: scale(0.95);
|
||||
background: var(--divider-color);
|
||||
}
|
||||
|
||||
.info-btn svg {
|
||||
color: #FFD700;
|
||||
}
|
||||
|
||||
.top-users-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.top-user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 12px;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
|
||||
transition: all 0.2s;
|
||||
min-height: 54px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .top-user-item {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.top-user-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.top-user-item.current-user {
|
||||
background: rgba(255, 215, 0, 0.05);
|
||||
}
|
||||
|
||||
.user-rank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
min-width: 24px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rank-icon {
|
||||
filter: drop-shadow(0 0 4px currentColor);
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.rank-icon.gold {
|
||||
color: #FFD700;
|
||||
}
|
||||
|
||||
.rank-icon.silver {
|
||||
color: #C0C0C0;
|
||||
}
|
||||
|
||||
.rank-icon.bronze {
|
||||
color: #CD7F32;
|
||||
}
|
||||
|
||||
.rank-number {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
min-width: 44px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.user-stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
.user-tickets {
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.user-prize {
|
||||
font-size: 11px;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.current-badge {
|
||||
color: #FFD700;
|
||||
filter: drop-shadow(0 0 4px #FFD700);
|
||||
animation: twinkle 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes twinkle {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Карточка текущего пользователя */
|
||||
.current-user-card {
|
||||
margin: 16px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 8px var(--shadow-md);
|
||||
}
|
||||
|
||||
.current-user-card h3 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.current-user-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
background: transparent;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
|
||||
min-height: 54px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .current-user-item {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
/* Модальное окно с информацией */
|
||||
.info-modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
padding: 20px;
|
||||
animation: fadeIn 0.3s;
|
||||
}
|
||||
|
||||
.info-modal {
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px var(--shadow-lg);
|
||||
position: relative;
|
||||
z-index: 10001;
|
||||
}
|
||||
|
||||
.info-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--bg-secondary);
|
||||
z-index: 10002;
|
||||
}
|
||||
|
||||
.info-modal-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
font-size: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.info-modal-content {
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.info-section {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--divider-color);
|
||||
}
|
||||
|
||||
.info-section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.info-section h3 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.info-section p {
|
||||
font-size: 14px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-limit {
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.info-section.anti-fraud {
|
||||
background: var(--bg-primary);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.info-section.anti-fraud h3 {
|
||||
color: #FF6347;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--divider-color);
|
||||
border-top-color: #FFD700;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Темная тема */
|
||||
[data-theme="dark"] .countdown-card {
|
||||
background-image:
|
||||
linear-gradient(var(--bg-secondary), var(--bg-secondary)),
|
||||
linear-gradient(135deg, #FFD700, #FFA500);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .snowflake {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .countdown-value {
|
||||
color: #FFD700;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .countdown-separator {
|
||||
color: #FFD700;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .user-tickets {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
|
@ -1,319 +0,0 @@
|
|||
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>Комменты <10 символов = 0 билетов</p>
|
||||
<p>Ограничение на билеты по входящим реакциям, чтобы боты не устроили ферму</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -184,96 +184,6 @@
|
|||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* Реферальная карточка */
|
||||
.referral-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.referral-content {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.referral-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 12px;
|
||||
background: rgba(52, 199, 89, 0.12);
|
||||
color: #34C759;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.referral-text h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.referral-text p {
|
||||
margin: 4px 0 8px;
|
||||
font-size: 14px;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.referral-stats {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.referral-stats strong {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.referral-link-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.referral-link {
|
||||
padding: 12px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--divider-color);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.referral-link code {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
font-family: 'SF Mono', 'Monaco', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.referral-copy-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 18px;
|
||||
border-radius: 12px;
|
||||
background: var(--button-accent);
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.referral-copy-btn:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.search-switch {
|
||||
display: flex;
|
||||
background: var(--bg-primary);
|
||||
|
|
|
|||
|
|
@ -318,18 +318,6 @@ export default function Profile({ user, setUser }) {
|
|||
setSettings(updatedSettings)
|
||||
}
|
||||
|
||||
const handleCopyReferral = () => {
|
||||
const botUsername = import.meta.env.VITE_BOT_USERNAME || 'NakamaSpaceBot'
|
||||
const referralLink = `https://t.me/${botUsername}?startapp=ref_${user.referralCode || user.id}`
|
||||
|
||||
navigator.clipboard.writeText(referralLink).then(() => {
|
||||
hapticFeedback('success')
|
||||
alert('Реферальная ссылка скопирована!')
|
||||
}).catch(() => {
|
||||
hapticFeedback('error')
|
||||
alert('Не удалось скопировать ссылку')
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="profile-page">
|
||||
|
|
@ -405,33 +393,6 @@ export default function Profile({ user, setUser }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Реферальная карточка */}
|
||||
{user.referralCode && (
|
||||
<div className="referral-card card">
|
||||
<div className="referral-content">
|
||||
<div className="referral-icon">
|
||||
<UserPlus size={20} />
|
||||
</div>
|
||||
<div className="referral-text">
|
||||
<h3>Реферальная программа</h3>
|
||||
<p>Приглашайте друзей и получайте бонусы за каждого приглашенного пользователя!</p>
|
||||
<div className="referral-stats">
|
||||
Приглашено: <strong>{user.referralsCount || 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="referral-link-section">
|
||||
<div className="referral-link">
|
||||
<code>{`https://t.me/${import.meta.env.VITE_BOT_USERNAME || 'NakamaSpaceBot'}?startapp=ref_${user.referralCode}`}</code>
|
||||
</div>
|
||||
<button className="referral-copy-btn" onClick={handleCopyReferral}>
|
||||
<Copy size={16} />
|
||||
Скопировать ссылку
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Привязка email (только для Telegram пользователей без email) */}
|
||||
{user.telegramId && !user.email && (
|
||||
<div className="link-email-card card">
|
||||
|
|
|
|||
|
|
@ -113,6 +113,34 @@ export const verifyAuth = async () => {
|
|||
}
|
||||
|
||||
// Авторизация через Telegram OAuth (Login Widget)
|
||||
|
||||
// Magic-link авторизация
|
||||
export const sendMagicLink = async (email) => {
|
||||
const response = await api.post('/auth/magic-link/send', { email })
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const verifyMagicLink = async (token) => {
|
||||
const response = await api.get('/auth/magic-link/verify', { params: { token } })
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const setPassword = async (token, password, username) => {
|
||||
const response = await api.post('/auth/magic-link/set-password', { token, password, username })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Привязка аккаунтов
|
||||
export const linkTelegram = async (initData) => {
|
||||
const response = await api.post('/auth/link-telegram', { initData })
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const linkEmail = async (email, password) => {
|
||||
const response = await api.post('/auth/link-email', { email, password })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Posts API
|
||||
export const getPosts = async (params = {}) => {
|
||||
// Поддержка старого формата для обратной совместимости
|
||||
|
|
@ -205,12 +233,6 @@ export const searchUsers = async (query) => {
|
|||
return response.data.users
|
||||
}
|
||||
|
||||
// Ladder API
|
||||
export const getLadderTop = async (limit = 5) => {
|
||||
const response = await api.get('/users/ladder/top', { params: { limit } })
|
||||
return response.data
|
||||
}
|
||||
|
||||
// Notifications API
|
||||
export const getNotifications = async (params = {}) => {
|
||||
const response = await api.get('/notifications', { params })
|
||||
|
|
|
|||
|
|
@ -114,7 +114,6 @@ async def get_users(
|
|||
'bannedUntil': banned_until,
|
||||
'createdAt': created_at,
|
||||
'lastActiveAt': last_active_at,
|
||||
'referralsCount': int(u.get('referralsCount', 0)),
|
||||
'isAdmin': is_admin
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1009,7 +1009,6 @@ export default function App() {
|
|||
<div className="list-item-meta">
|
||||
<span>Роль: {u.role}</span>
|
||||
<span>Активность: {formatDate(u.lastActiveAt)}</span>
|
||||
{u.referralsCount > 0 && <span className="badge badge-info">Рефералов: {u.referralsCount}</span>}
|
||||
{u.banned && <span className="badge badge-danger">Бан до {formatDate(u.bannedUntil)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue