From daed73c30f7fa1f552b697e85bfd25c9d8bdd236 Mon Sep 17 00:00:00 2001
From: glpshchn <464976@niuitmo.ru>
Date: Sun, 7 Dec 2025 05:20:45 +0300
Subject: [PATCH] Update files
---
backend/middleware/auth.js | 63 ++-
backend/middleware/logger.js | 10 +-
backend/models/TicketActivity.js | 76 ++++
backend/models/User.js | 14 +
backend/routes/auth.js | 3 +
backend/routes/posts.js | 38 +-
backend/routes/users.js | 68 +++
backend/utils/moscowTime.js | 79 ++++
backend/utils/tickets.js | 221 ++++++++++
frontend/src/App.jsx | 2 +
frontend/src/components/LadderButton.css | 63 +++
frontend/src/components/LadderButton.jsx | 26 ++
frontend/src/components/Layout.jsx | 2 +
frontend/src/components/PostMenu.jsx | 2 +-
frontend/src/pages/MonthlyLadder.css | 521 +++++++++++++++++++++++
frontend/src/pages/MonthlyLadder.jsx | 276 ++++++++++++
frontend/src/pages/UserProfile.css | 5 +
frontend/src/utils/api.js | 6 +
18 files changed, 1449 insertions(+), 26 deletions(-)
create mode 100644 backend/models/TicketActivity.js
create mode 100644 backend/utils/moscowTime.js
create mode 100644 backend/utils/tickets.js
create mode 100644 frontend/src/components/LadderButton.css
create mode 100644 frontend/src/components/LadderButton.jsx
create mode 100644 frontend/src/pages/MonthlyLadder.css
create mode 100644 frontend/src/pages/MonthlyLadder.jsx
diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js
index cb06616..1d20791 100644
--- a/backend/middleware/auth.js
+++ b/backend/middleware/auth.js
@@ -202,22 +202,17 @@ 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
- user.referredBy = referrer._id;
- await user.save();
- }
+ // Пользователь еще не был засчитан как реферал, можно установить referredBy
+ user.referredBy = referrer._id;
+ await user.save();
}
}
}
@@ -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();
diff --git a/backend/middleware/logger.js b/backend/middleware/logger.js
index 83784a1..f606081 100644
--- a/backend/middleware/logger.js
+++ b/backend/middleware/logger.js
@@ -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);
};
diff --git a/backend/models/TicketActivity.js b/backend/models/TicketActivity.js
new file mode 100644
index 0000000..99c1340
--- /dev/null
+++ b/backend/models/TicketActivity.js
@@ -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);
+
diff --git a/backend/models/User.js b/backend/models/User.js
index fbbdba5..649db7d 100644
--- a/backend/models/User.js
+++ b/backend/models/User.js
@@ -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
diff --git a/backend/routes/auth.js b/backend/routes/auth.js
index 4256095..25ff301 100644
--- a/backend/routes/auth.js
+++ b/backend/routes/auth.js
@@ -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
}
diff --git a/backend/routes/posts.js b/backend/routes/posts.js
index 16abb0b..62679c1 100644
--- a/backend/routes/posts.js
+++ b/backend/routes/posts.js
@@ -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,
diff --git a/backend/routes/users.js b/backend/routes/users.js
index 6e19187..a057887 100644
--- a/backend/routes/users.js
+++ b/backend/routes/users.js
@@ -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;
diff --git a/backend/utils/moscowTime.js b/backend/utils/moscowTime.js
new file mode 100644
index 0000000..bc94de3
--- /dev/null
+++ b/backend/utils/moscowTime.js
@@ -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
+};
+
diff --git a/backend/utils/tickets.js b/backend/utils/tickets.js
new file mode 100644
index 0000000..f956e06
--- /dev/null
+++ b/backend/utils/tickets.js
@@ -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
+};
+
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 0793ccc..8e1047b 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -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() {
Ваши посты, ваши арты, ваша слава. Остальное потом.
++15 баллов за создание поста
+Лимит: 5 постов в день
+Ставишь лайки: +1 балл за лайк
+Лимит: 50 в день
+Получаешь лайки: +2 балла за лайк под твоей записью
+Лимит учёта: 100 лайков в день
+Пишешь комментарии: +4 балла за комментарий длиной 10+ символов
+Лимит: 20 комментариев в день
+Получаешь комментарии: +6 баллов за комментарий под твоим постом
++100 баллов за одного валидного реферала
+Лимит: 3 реферала в день
+Публикация: +40 баллов за арт, прошедший модерацию
+Лимит: 1 арт в день / 5 в неделю
+Реакции на арт:
++8 баллов за лайк под артом
++12 баллов за комментарий под артом (1 комментарий от одного человека в сутки)
+Лимит: до 100 баллов в сутки с реакций на один арт
+Лайки/комменты от аккаунтов младше 24 часов не считаем
+Комменты <10 символов = 0 баллов
+Ограничение на баллы по входящим реакциям, чтобы боты не устроили ферму
+