Update files
This commit is contained in:
parent
e4d76ba705
commit
daed73c30f
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
@ -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
|
||||
};
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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%);
|
||||
}
|
||||
|
||||
|
|
@ -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>Комменты <10 символов = 0 баллов</p>
|
||||
<p>Ограничение на баллы по входящим реакциям, чтобы боты не устроили ферму</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -110,6 +110,11 @@
|
|||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Темная тема: кнопка подписки имеет белый фон, текст должен быть черным */
|
||||
[data-theme="dark"] .follow-btn {
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
.user-posts {
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
Loading…
Reference in New Issue