Update files

This commit is contained in:
glpshchn 2025-12-07 05:20:45 +03:00
parent e4d76ba705
commit daed73c30f
18 changed files with 1449 additions and 26 deletions

View File

@ -202,25 +202,20 @@ const authenticate = async (req, res, next) => {
// (см. routes/posts.js)
} else {
// Для существующих пользователей тоже можно установить referredBy,
// если они еще не создали пост и пришли по реферальной ссылке
if (startParam && !user.referredBy) {
// если они еще не были засчитаны как реферал и пришли по реферальной ссылке
if (startParam && !user.referredBy && !user.referralCounted) {
const normalizedStartParam = startParam.toLowerCase();
if (normalizedStartParam.startsWith('ref_')) {
const referrer = await User.findOne({
referralCode: { $regex: new RegExp(`^${startParam}$`, 'i') }
});
if (referrer) {
// Проверяем, создал ли пользователь уже посты
const Post = require('../models/Post');
const userPostsCount = await Post.countDocuments({ author: user._id });
if (userPostsCount === 0) {
// Пользователь еще не создал посты, можно установить referredBy
// Пользователь еще не был засчитан как реферал, можно установить referredBy
user.referredBy = referrer._id;
await user.save();
}
}
}
}
// Обновлять только если есть новые данные, не перезаписывать существующие пустыми значениями
if (normalizedUser.username) {
user.username = normalizedUser.username;
@ -254,6 +249,54 @@ 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 } = require('../utils/moscowTime');
const uniqueDates = new Set();
user.loginDates.forEach(date => {
const dateObj = getMoscowStartOfDay(new Date(date));
uniqueDates.add(dateObj.getTime());
});
// Если уже есть 2 уникальные даты, сразу засчитать реферал без добавления новой даты
if (uniqueDates.size >= 2) {
const User = require('../models/User');
await User.findByIdAndUpdate(user.referredBy, {
$inc: { referralsCount: 1 }
});
// Начислить баллы за реферала
const { awardReferral } = require('../utils/tickets');
await awardReferral(user.referredBy);
user.referralCounted = true;
// Очистить loginDates после засчета, чтобы не хранить лишние данные
user.loginDates = [];
await user.save();
} else {
// Если еще нет 2 уникальных дат, добавить сегодняшнюю дату по московскому времени (если её нет)
const { getMoscowDate } = require('../utils/moscowTime');
const today = getMoscowDate();
const todayTime = today.getTime();
// Проверить, есть ли уже сегодняшняя дата
const todayExists = uniqueDates.has(todayTime);
// Если сегодняшней даты нет, добавить её
if (!todayExists) {
user.loginDates.push(today);
await user.save();
}
}
}
req.user = user;
req.telegramUser = normalizedUser;
next();

View File

@ -1,5 +1,7 @@
const fs = require('fs');
const path = require('path');
const { formatMoscowTime, getMoscowDate } = require('../utils/moscowTime');
// Создать директорию для логов если её нет
const logsDir = path.join(__dirname, '../logs');
if (!fs.existsSync(logsDir)) {
@ -7,7 +9,9 @@ if (!fs.existsSync(logsDir)) {
}
const getDatePrefix = () => {
return new Date().toISOString().slice(0, 10);
// Используем московское время для имен файлов логов
const moscowDate = getMoscowDate();
return moscowDate.toISOString().slice(0, 10);
};
const appendLog = (fileName, message) => {
@ -45,7 +49,7 @@ const levelEmojis = {
// Функция для логирования
const log = (level, message, data = {}) => {
const timestamp = new Date().toISOString();
const timestamp = formatMoscowTime(); // Используем московское время
const emoji = levelEmojis[level] || '📋';
const logMessage = `[${timestamp}] ${emoji} [${level.toUpperCase()}] ${message}`;
const serializedData = Object.keys(data).length ? ` ${JSON.stringify(data, null, 2)}` : '';
@ -154,7 +158,7 @@ const logSecurityEvent = (type, req, details = {}) => {
log('warn', 'Security event', securityData);
// В production можно отправить уведомление
const securityMessage = `[${new Date().toISOString()}] [SECURITY] ${type}: ${JSON.stringify(securityData)}`;
const securityMessage = `[${formatMoscowTime()}] [SECURITY] ${type}: ${JSON.stringify(securityData)}`;
appendLog(`security-${getDatePrefix()}.log`, securityMessage);
};

View File

@ -0,0 +1,76 @@
const mongoose = require('mongoose');
const TicketActivitySchema = new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
index: true
},
date: {
type: Date,
required: true,
index: true
},
// Счетчики для разных типов активности
postsCreated: {
type: Number,
default: 0
},
likesGiven: {
type: Number,
default: 0
},
likesReceived: {
type: Number,
default: 0
},
commentsWritten: {
type: Number,
default: 0
},
commentsReceived: {
type: Number,
default: 0
},
referralsCounted: {
type: Number,
default: 0
},
// Для отслеживания баллов с реакций на арты (по постам)
// Используем объект вместо Map для совместимости с Mongoose
artReactionsPoints: {
type: mongoose.Schema.Types.Mixed,
default: {}
},
createdAt: {
type: Date,
default: Date.now
}
}, {
timestamps: true
});
// Индекс для быстрого поиска по пользователю и дате
TicketActivitySchema.index({ user: 1, date: 1 }, { unique: true });
// Метод для получения или создания активности за сегодня (по московскому времени)
TicketActivitySchema.statics.getOrCreateToday = async function(userId) {
const { getMoscowDate } = require('../utils/moscowTime');
const today = getMoscowDate();
let activity = await this.findOne({ user: userId, date: today });
if (!activity) {
activity = new this({
user: userId,
date: today
});
await activity.save();
}
return activity;
};
module.exports = mongoose.model('TicketActivity', TicketActivitySchema);

View File

@ -69,6 +69,20 @@ const UserSchema = new mongoose.Schema({
type: Number,
default: 0
},
// Массив дат входа (для реферальной системы)
loginDates: [{
type: Date
}],
// Флаг, что реферал уже засчитан
referralCounted: {
type: Boolean,
default: false
},
// Билеты для Monthly Ladder
tickets: {
type: Number,
default: 0
},
createdAt: {
type: Date,
default: Date.now

View File

@ -59,6 +59,7 @@ const respondWithUser = async (user, res) => {
following: populatedUser.following,
referralCode: populatedUser.referralCode,
referralsCount: populatedUser.referralsCount || 0,
tickets: populatedUser.tickets || 0,
settings,
banned: populatedUser.banned
}
@ -238,6 +239,7 @@ router.post('/oauth', strictAuthLimiter, async (req, res) => {
followingCount: populatedUser.following.length,
referralCode: populatedUser.referralCode,
referralsCount: populatedUser.referralsCount || 0,
tickets: populatedUser.tickets || 0,
settings,
banned: populatedUser.banned
}
@ -300,6 +302,7 @@ router.post('/session', async (req, res) => {
followingCount: populatedUser.following.length,
referralCode: populatedUser.referralCode,
referralsCount: populatedUser.referralsCount || 0,
tickets: populatedUser.tickets || 0,
settings,
banned: populatedUser.banned
}

View File

@ -153,16 +153,9 @@ router.post('/', authenticate, strictPostLimiter, postCreationLimiter, fileUploa
await post.save();
await post.populate('author', 'username firstName lastName photoUrl');
// Проверка первого поста для реферальной системы
// Счетчик рефералов увеличивается только когда приглашенный пользователь создал первый пост
const userPostsCount = await Post.countDocuments({ author: req.user._id });
if (userPostsCount === 1 && req.user.referredBy) {
// Это первый пост пользователя, который был приглашен по реферальной ссылке
const User = require('../models/User');
await User.findByIdAndUpdate(req.user.referredBy, {
$inc: { referralsCount: 1 }
});
}
// Начислить баллы за создание поста
const { awardPostCreation } = require('../utils/tickets');
await awardPostCreation(req.user._id);
// Создать уведомления для упомянутых пользователей
if (post.mentionedUsers.length > 0) {
@ -213,8 +206,17 @@ router.post('/:id/like', authenticate, interactionLimiter, async (req, res) => {
// Добавить лайк
post.likes.push(req.user._id);
// Создать уведомление
// Начислить баллы
const { awardLikeGiven, awardLikeReceived } = require('../utils/tickets');
// Баллы тому, кто ставит лайк
await awardLikeGiven(req.user._id);
// Баллы автору поста (если это не свой пост)
if (!post.author.equals(req.user._id)) {
await awardLikeReceived(post.author, req.user._id);
// Создать уведомление
const notification = new Notification({
recipient: post.author,
sender: req.user._id,
@ -256,8 +258,20 @@ router.post('/:id/comment', authenticate, interactionLimiter, async (req, res) =
await post.save();
await post.populate('comments.author', 'username firstName lastName photoUrl');
// Создать уведомление
// Начислить баллы за комментарий
const { awardCommentWritten, awardCommentReceived } = require('../utils/tickets');
const commentLength = content.trim().length;
// Баллы тому, кто пишет комментарий (только если >= 10 символов)
if (commentLength >= 10) {
await awardCommentWritten(req.user._id, commentLength);
}
// Баллы автору поста (если это не свой пост)
if (!post.author.equals(req.user._id)) {
await awardCommentReceived(post.author, req.user._id);
// Создать уведомление
const notification = new Notification({
recipient: post.author,
sender: req.user._id,

View File

@ -204,5 +204,73 @@ 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;

View File

@ -0,0 +1,79 @@
/**
* Утилиты для работы с московским временем (MSK, UTC+3)
*/
/**
* Получить текущее московское время
*/
function getMoscowTime() {
const now = new Date();
// Москва = UTC+3
// Получаем UTC время и добавляем 3 часа
const utcTime = now.getTime() + (now.getTimezoneOffset() * 60 * 1000);
const moscowOffset = 3 * 60 * 60 * 1000; // 3 часа в миллисекундах
return new Date(utcTime + moscowOffset);
}
/**
* Получить начало дня по московскому времени
*/
function getMoscowStartOfDay(date = null) {
const moscowTime = date ? new Date(date.getTime()) : getMoscowTime();
moscowTime.setHours(0, 0, 0, 0);
return moscowTime;
}
/**
* Получить текущую дату по московскому времени (начало дня)
*/
function getMoscowDate() {
return getMoscowStartOfDay();
}
/**
* Форматировать дату в московское время для логов
*/
function formatMoscowTime(date = null) {
const moscowTime = date ? new Date(date.getTime()) : getMoscowTime();
const year = moscowTime.getFullYear();
const month = String(moscowTime.getMonth() + 1).padStart(2, '0');
const day = String(moscowTime.getDate()).padStart(2, '0');
const hours = String(moscowTime.getHours()).padStart(2, '0');
const minutes = String(moscowTime.getMinutes()).padStart(2, '0');
const seconds = String(moscowTime.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} MSK`;
}
/**
* Проверить, является ли дата сегодняшней по московскому времени
*/
function isMoscowToday(date) {
const today = getMoscowStartOfDay();
const checkDate = getMoscowStartOfDay(date);
return today.getTime() === checkDate.getTime();
}
/**
* Получить новогоднюю дату по московскому времени (1 января следующего года, 00:00 MSK)
*/
function getNewYearMoscow() {
const now = getMoscowTime();
const year = now.getFullYear() + 1;
// Создаем дату 1 января следующего года в московском времени
// Используем UTC и вычитаем смещение для получения правильного UTC времени
const moscowNewYear = new Date(Date.UTC(year, 0, 1, 0, 0, 0, 0));
// Вычитаем 3 часа, чтобы получить UTC время, которое при добавлении 3 часов даст 00:00 MSK
const moscowOffset = 3 * 60 * 60 * 1000;
return new Date(moscowNewYear.getTime() - moscowOffset);
}
module.exports = {
getMoscowTime,
getMoscowStartOfDay,
getMoscowDate,
formatMoscowTime,
isMoscowToday,
getNewYearMoscow
};

221
backend/utils/tickets.js Normal file
View File

@ -0,0 +1,221 @@
const User = require('../models/User');
const TicketActivity = require('../models/TicketActivity');
const Post = require('../models/Post');
const { getMoscowDate, formatMoscowTime } = require('./moscowTime');
/**
* Проверяет, можно ли начислить баллы с учетом лимитов и антифрод правил
*/
async function canAwardTickets(userId, actionType, options = {}) {
// Используем московское время для определения дня
const today = getMoscowDate();
const activity = await TicketActivity.getOrCreateToday(userId);
// Проверка лимитов в зависимости от типа действия
switch (actionType) {
case 'post_created':
return activity.postsCreated < 5;
case 'like_given':
return activity.likesGiven < 50;
case 'like_received':
return activity.likesReceived < 100;
case 'comment_written':
// Проверка длины комментария (антифрод)
if (options.commentLength && options.commentLength < 10) {
return false;
}
return activity.commentsWritten < 20;
case 'comment_received':
return true; // Нет лимита на получение комментариев
case 'referral':
return activity.referralsCounted < 3;
case 'art_reaction':
// Проверка лимита на реакцию на арт (100 баллов в сутки с одного арта)
const postId = options.postId;
if (!postId) return false;
const postIdStr = postId.toString();
const currentPoints = (activity.artReactionsPoints && activity.artReactionsPoints[postIdStr]) || 0;
const newPoints = currentPoints + (options.points || 0);
return newPoints <= 100;
default:
return false;
}
}
/**
* Проверяет возраст аккаунта (антифрод - аккаунты младше 24 часов не считаются)
*/
async function isAccountOldEnough(userId) {
const user = await User.findById(userId);
if (!user) return false;
const accountAge = Date.now() - new Date(user.createdAt).getTime();
const hours24 = 24 * 60 * 60 * 1000;
return accountAge >= hours24;
}
/**
* Начисляет баллы пользователю с проверкой лимитов и антифрод
*/
async function awardTickets(userId, points, actionType, options = {}) {
try {
// Антифрод: проверка возраста аккаунта для входящих реакций
if (actionType === 'like_received' || actionType === 'comment_received') {
const senderId = options.senderId;
if (senderId) {
const senderOldEnough = await isAccountOldEnough(senderId);
if (!senderOldEnough) {
console.log(`[Tickets ${formatMoscowTime()}] Пропуск начисления: аккаунт отправителя младше 24 часов (${senderId})`);
return { success: false, reason: 'account_too_new' };
}
}
}
// Проверка возможности начисления
const canAward = await canAwardTickets(userId, actionType, options);
if (!canAward) {
console.log(`[Tickets ${formatMoscowTime()}] Лимит достигнут для ${actionType} пользователя ${userId}`);
return { success: false, reason: 'limit_reached' };
}
// Получить активность за сегодня
const activity = await TicketActivity.getOrCreateToday(userId);
// Обновить счетчики активности
switch (actionType) {
case 'post_created':
activity.postsCreated += 1;
break;
case 'like_given':
activity.likesGiven += 1;
break;
case 'like_received':
activity.likesReceived += 1;
break;
case 'comment_written':
activity.commentsWritten += 1;
break;
case 'comment_received':
activity.commentsReceived += 1;
break;
case 'referral':
activity.referralsCounted += 1;
break;
case 'art_reaction':
const postId = options.postId;
if (postId) {
if (!activity.artReactionsPoints) {
activity.artReactionsPoints = {};
}
const postIdStr = postId.toString();
const currentPoints = activity.artReactionsPoints[postIdStr] || 0;
activity.artReactionsPoints[postIdStr] = currentPoints + points;
activity.markModified('artReactionsPoints');
}
break;
}
await activity.save();
// Начислить баллы пользователю
await User.findByIdAndUpdate(userId, {
$inc: { tickets: points }
});
console.log(`[Tickets ${formatMoscowTime()}] Начислено ${points} баллов пользователю ${userId} за ${actionType}`);
return { success: true, points };
} catch (error) {
console.error(`[Tickets] Ошибка начисления баллов:`, error);
return { success: false, reason: 'error', error: error.message };
}
}
/**
* Начисляет баллы за создание поста
*/
async function awardPostCreation(userId) {
return await awardTickets(userId, 15, 'post_created');
}
/**
* Начисляет баллы за лайк (тому, кто ставит)
*/
async function awardLikeGiven(userId) {
return await awardTickets(userId, 1, 'like_given');
}
/**
* Начисляет баллы за полученный лайк (автору поста)
*/
async function awardLikeReceived(authorId, likerId) {
return await awardTickets(authorId, 2, 'like_received', { senderId: likerId });
}
/**
* Начисляет баллы за написанный комментарий
*/
async function awardCommentWritten(userId, commentLength) {
return await awardTickets(userId, 4, 'comment_written', { commentLength });
}
/**
* Начисляет баллы за полученный комментарий (автору поста)
*/
async function awardCommentReceived(authorId, commenterId) {
return await awardTickets(authorId, 6, 'comment_received', { senderId: commenterId });
}
/**
* Начисляет баллы за реферала
*/
async function awardReferral(userId) {
return await awardTickets(userId, 100, 'referral');
}
/**
* Начисляет баллы за реакцию на арт (лайк)
*/
async function awardArtLike(authorId, likerId, postId) {
return await awardTickets(authorId, 8, 'art_reaction', {
senderId: likerId,
postId,
points: 8
});
}
/**
* Начисляет баллы за комментарий под артом
*/
async function awardArtComment(authorId, commenterId, postId) {
return await awardTickets(authorId, 12, 'art_reaction', {
senderId: commenterId,
postId,
points: 12
});
}
module.exports = {
awardTickets,
awardPostCreation,
awardLikeGiven,
awardLikeReceived,
awardCommentWritten,
awardCommentReceived,
awardReferral,
awardArtLike,
awardArtComment,
canAwardTickets,
isAccountOldEnough
};

View File

@ -12,6 +12,7 @@ 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 './styles/index.css'
function AppContent() {
@ -201,6 +202,7 @@ 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>
)

View File

@ -0,0 +1,63 @@
.ladder-button {
position: fixed;
bottom: 100px;
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);
}

View File

@ -0,0 +1,26 @@
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
if (location.pathname === '/ladder') {
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>
)
}

View File

@ -1,5 +1,6 @@
import { Outlet } from 'react-router-dom'
import Navigation from './Navigation'
import LadderButton from './LadderButton'
import './Layout.css'
export default function Layout({ user }) {
@ -9,6 +10,7 @@ export default function Layout({ user }) {
<Outlet />
</main>
<Navigation />
<LadderButton />
</div>
)
}

View File

@ -163,7 +163,7 @@ export default function PostMenu({ post, currentUser, onClose, onDelete, onUpdat
if (!buttonPosition) return {}
const menuWidth = 160 // Примерная ширина меню
const padding = 8 // Отступ от края экрана
const padding = 16 // Отступ от края экрана
const buttonCenterX = buttonPosition.left + (buttonPosition.right - buttonPosition.left) / 2
const windowWidth = window.innerWidth

View File

@ -0,0 +1,521 @@
.ladder-page {
min-height: 100vh;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
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;
}
.snowflake {
position: absolute;
color: rgba(255, 255, 255, 0.8);
font-size: 20px;
animation: fall linear infinite;
animation-duration: 10s;
}
.snowflake:nth-child(1) {
left: 10%;
animation-delay: 0s;
animation-duration: 8s;
}
.snowflake:nth-child(2) {
left: 30%;
animation-delay: 2s;
animation-duration: 12s;
}
.snowflake:nth-child(3) {
left: 50%;
animation-delay: 4s;
animation-duration: 10s;
}
.snowflake:nth-child(4) {
left: 70%;
animation-delay: 1s;
animation-duration: 9s;
}
.snowflake:nth-child(5) {
left: 85%;
animation-delay: 3s;
animation-duration: 11s;
}
.snowflake:nth-child(6) {
left: 20%;
animation-delay: 5s;
animation-duration: 13s;
}
@keyframes fall {
0% {
transform: translateY(-100vh) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(100vh) rotate(360deg);
opacity: 0;
}
}
.ladder-header {
position: sticky;
top: 0;
background: rgba(26, 26, 46, 0.95);
backdrop-filter: blur(10px);
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
z-index: 10;
}
.ladder-header h1 {
font-size: 20px;
font-weight: 700;
background: linear-gradient(135deg, #FFD700, #FFA500, #FF6347);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.back-btn {
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
}
/* Карточка отсчета */
.countdown-card {
margin: 16px;
background: linear-gradient(135deg, rgba(255, 215, 0, 0.15), rgba(255, 165, 0, 0.15));
border: 2px solid rgba(255, 215, 0, 0.3);
backdrop-filter: blur(10px);
position: relative;
overflow: hidden;
}
.countdown-card::before {
content: '';
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(255, 215, 0, 0.1) 0%, transparent 70%);
animation: shimmer 3s ease-in-out infinite;
}
@keyframes shimmer {
0%, 100% {
transform: rotate(0deg);
}
50% {
transform: rotate(180deg);
}
}
.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: #FFD700;
text-shadow: 0 0 10px rgba(255, 215, 0, 0.5);
}
.gift-icon {
color: #FF6347;
animation: bounce 2s ease-in-out infinite;
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
.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;
text-shadow: 0 0 15px rgba(255, 215, 0, 0.8);
min-width: 60px;
text-align: center;
}
.countdown-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
text-transform: uppercase;
}
.countdown-separator {
font-size: 24px;
color: #FFD700;
font-weight: 700;
margin: 0 4px;
}
.countdown-slogan {
text-align: center;
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
font-style: italic;
margin-top: 16px;
position: relative;
z-index: 1;
}
/* Топ пользователей */
.ladder-top {
margin: 16px;
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.ladder-top-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.ladder-top-header h2 {
font-size: 20px;
font-weight: 700;
background: linear-gradient(135deg, #FFD700, #FFA500);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.info-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: rgba(255, 215, 0, 0.2);
border: 1px solid rgba(255, 215, 0, 0.3);
border-radius: 20px;
color: #FFD700;
font-size: 13px;
cursor: pointer;
transition: all 0.3s;
}
.info-btn:active {
transform: scale(0.95);
background: rgba(255, 215, 0, 0.3);
}
.top-users-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.top-user-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
transition: all 0.3s;
}
.top-user-item.current-user {
background: rgba(255, 215, 0, 0.15);
border-color: rgba(255, 215, 0, 0.4);
box-shadow: 0 0 20px rgba(255, 215, 0, 0.3);
}
.user-rank {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
flex-shrink: 0;
}
.rank-icon {
filter: drop-shadow(0 0 8px currentColor);
}
.rank-icon.gold {
color: #FFD700;
}
.rank-icon.silver {
color: #C0C0C0;
}
.rank-icon.bronze {
color: #CD7F32;
}
.rank-number {
font-size: 20px;
font-weight: 700;
color: rgba(255, 255, 255, 0.8);
}
.user-avatar {
width: 50px;
height: 50px;
border-radius: 50%;
object-fit: cover;
border: 2px solid rgba(255, 215, 0, 0.3);
flex-shrink: 0;
}
.top-user-item.current-user .user-avatar {
border-color: #FFD700;
box-shadow: 0 0 15px rgba(255, 215, 0, 0.5);
}
.user-info {
flex: 1;
min-width: 0;
}
.user-name {
font-size: 16px;
font-weight: 600;
color: #fff;
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.user-tickets {
font-size: 14px;
color: #FFD700;
font-weight: 500;
}
.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: rgba(255, 215, 0, 0.1);
border: 1px solid rgba(255, 215, 0, 0.3);
}
.current-user-card h3 {
font-size: 18px;
font-weight: 600;
color: #FFD700;
margin-bottom: 12px;
}
.current-user-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
}
/* Модальное окно с информацией */
.info-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
animation: fadeIn 0.3s;
}
.info-modal {
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
background: linear-gradient(135deg, #1a1a2e, #16213e);
border: 2px solid rgba(255, 215, 0, 0.3);
position: relative;
}
.info-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
position: sticky;
top: 0;
background: rgba(26, 26, 46, 0.95);
backdrop-filter: blur(10px);
z-index: 10;
}
.info-modal-header h2 {
font-size: 20px;
font-weight: 700;
color: #FFD700;
}
.close-btn {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
color: #fff;
border: none;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
line-height: 1;
}
.info-modal-content {
padding: 16px;
}
.info-section {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.info-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.info-section h3 {
font-size: 16px;
font-weight: 600;
color: #FFD700;
margin-bottom: 8px;
}
.info-section p {
font-size: 14px;
color: rgba(255, 255, 255, 0.9);
line-height: 1.6;
margin-bottom: 4px;
}
.info-limit {
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
font-style: italic;
}
.info-section.anti-fraud {
background: rgba(255, 69, 0, 0.1);
padding: 12px;
border-radius: 8px;
border: 1px solid rgba(255, 69, 0, 0.3);
}
.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: 4px solid rgba(255, 215, 0, 0.2);
border-top-color: #FFD700;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Темная тема - дополнительные стили */
[data-theme="dark"] .ladder-page {
background: linear-gradient(135deg, #000000 0%, #1a1a2e 50%, #16213e 100%);
}

View File

@ -0,0 +1,276 @@
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 formatTickets = (tickets) => {
return tickets?.toLocaleString('ru-RU') || '0'
}
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 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 card">
<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())
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 className="user-tickets">
{formatTickets(topUser.tickets)} билетов
</div>
</div>
</div>
)
})}
</div>
)}
</div>
{/* Текущий пользователь (если не в топе) */}
{currentUser && currentUserRank > 5 && (
<div className="current-user-card 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 className="user-tickets">
{formatTickets(currentUser.tickets)} билетов
</div>
</div>
</div>
</div>
)}
{/* Модальное окно с информацией */}
{showInfo && (
<div className="info-modal-overlay" onClick={() => setShowInfo(false)}>
<div className="info-modal card" 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>
)
}

View File

@ -110,6 +110,11 @@
border: 1px solid var(--border-color);
}
/* Темная тема: кнопка подписки имеет белый фон, текст должен быть черным */
[data-theme="dark"] .follow-btn {
color: #000000;
}
.user-posts {
padding: 16px;
display: flex;

View File

@ -186,6 +186,12 @@ 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 })