Update files

This commit is contained in:
glpshchn 2026-01-01 21:52:37 +03:00
parent 73fa4a6ded
commit 4f39cb38fe
18 changed files with 30 additions and 1401 deletions

View File

@ -172,96 +172,16 @@ const authenticate = async (req, res, next) => {
let user = await User.findOne({ telegramId: normalizedUser.id.toString() });
if (!user) {
// Обработка реферального кода из start_param
let referredBy = null;
if (startParam) {
const trimmedParam = startParam.trim();
// Проверяем регистронезависимо (может быть ref_ или REF_)
const normalizedStartParam = trimmedParam.toLowerCase();
if (normalizedStartParam.startsWith('ref_')) {
// Убираем все префиксы ref_/REF_ (может быть двойной префикс ref_REF_)
let codeToSearch = trimmedParam;
while (codeToSearch.toLowerCase().startsWith('ref_')) {
codeToSearch = codeToSearch.substring(4); // Убираем "ref_" или "REF_"
}
// referralCode в базе хранится с префиксом "REF_" (в верхнем регистре)
// Ищем по коду с префиксом REF_
const escapedCode = codeToSearch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const referrer = await User.findOne({
referralCode: { $regex: new RegExp(`^REF_${escapedCode}$`, 'i') }
});
if (referrer) {
referredBy = referrer._id;
console.log(`🔗 Реферальная ссылка: пользователь ${normalizedUser.username || normalizedUser.id} зарегистрирован по ссылке от ${referrer.username} (${referrer._id}), код: ${trimmedParam} -> REF_${codeToSearch}`);
} else {
// Дополнительная проверка: посмотрим все referralCode в базе для отладки
const allCodes = await User.find({ referralCode: { $exists: true } }, { referralCode: 1, username: 1 }).limit(5);
console.warn(`⚠️ Реферальный код не найден: ${trimmedParam} (искали: REF_${codeToSearch})`);
console.warn(` Примеры кодов в базе: ${allCodes.map(u => u.referralCode).join(', ')}`);
}
} else {
console.log(` startParam не содержит ref_: ${trimmedParam}`);
}
}
user = new User({
telegramId: normalizedUser.id.toString(),
username: normalizedUser.username || normalizedUser.firstName || 'user',
firstName: normalizedUser.firstName,
lastName: normalizedUser.lastName,
photoUrl: normalizedUser.photoUrl,
referredBy: referredBy
photoUrl: normalizedUser.photoUrl
});
await user.save();
if (referredBy) {
console.log(`✅ Создан новый пользователь ${user.username} (${user._id}) с referredBy: ${referredBy}`);
console.log(`✅ Создан новый пользователь ${user.username} (${user._id})`);
} else {
console.log(`✅ Создан новый пользователь ${user.username} (${user._id}) без реферала`);
}
// Счетчик рефералов увеличивается только когда пользователь создаст первый пост
// (см. routes/posts.js)
} else {
// Для существующих пользователей тоже можно установить referredBy,
// если они еще не были засчитаны как реферал и пришли по реферальной ссылке
if (startParam && !user.referredBy && !user.referralCounted) {
const trimmedParam = startParam.trim();
const normalizedStartParam = trimmedParam.toLowerCase();
if (normalizedStartParam.startsWith('ref_')) {
// Ищем по полному коду (с префиксом ref_)
const referrer = await User.findOne({
referralCode: { $regex: new RegExp(`^${trimmedParam.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') }
});
if (referrer) {
user.referredBy = referrer._id;
await user.save();
console.log(`🔗 Установлен referredBy для существующего пользователя ${user.username} от ${referrer.username} (код: ${trimmedParam})`);
} else {
// Попробуем альтернативный поиск
const codeWithoutPrefix = trimmedParam.substring(4);
const alternativeSearch = await User.findOne({
$or: [
{ referralCode: { $regex: new RegExp(`^ref_${codeWithoutPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') } },
{ referralCode: { $regex: new RegExp(`^REF_${codeWithoutPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'i') } }
]
});
if (alternativeSearch) {
user.referredBy = alternativeSearch._id;
await user.save();
console.log(`🔗 Установлен referredBy (альтернативный поиск) для существующего пользователя ${user.username} от ${alternativeSearch.username}`);
} else {
console.warn(`⚠️ Реферальный код не найден для существующего пользователя ${user.username}: ${trimmedParam}`);
}
}
}
}
// Обновлять только если есть новые данные, не перезаписывать существующие пустыми значениями
if (normalizedUser.username) {
user.username = normalizedUser.username;
@ -295,81 +215,6 @@ const authenticate = async (req, res, next) => {
await ensureUserSettings(user);
await touchUserActivity(user);
// Реферальная система: отслеживание входов в разные дни
// Останавливаем отслеживание после засчета реферала, чтобы не заполнять БД
if (user.referredBy && !user.referralCounted) {
// Инициализировать loginDates если его нет
if (!user.loginDates) {
user.loginDates = [];
}
const { getMoscowStartOfDay, getMoscowDate } = require('../utils/moscowTime');
const today = getMoscowDate();
const todayNormalized = getMoscowStartOfDay(today);
const todayTime = todayNormalized.getTime();
// Получить уникальные даты из существующего массива (только даты, без времени по московскому времени)
const uniqueDates = new Set();
if (user.loginDates && Array.isArray(user.loginDates)) {
user.loginDates.forEach(date => {
if (!date) return;
try {
// Нормализуем дату: получаем начало дня по московскому времени
const dateObj = new Date(date);
const normalizedDate = getMoscowStartOfDay(dateObj);
const normalizedTime = normalizedDate.getTime();
uniqueDates.add(normalizedTime);
} catch (error) {
console.error(`Ошибка обработки даты ${date}:`, error);
}
});
}
// Добавить сегодняшнюю дату, если её еще нет
const todayExists = uniqueDates.has(todayTime);
if (!todayExists) {
if (!user.loginDates) {
user.loginDates = [];
}
user.loginDates.push(today);
uniqueDates.add(todayTime);
console.log(`📅 Реферал ${user.username} (${user._id}): добавлена дата входа. Уникальных дат: ${uniqueDates.size}/2`);
}
// Если уже есть 2 или более уникальные даты, засчитать реферала
if (uniqueDates.size >= 2) {
const User = require('../models/User');
const referrer = await User.findById(user.referredBy);
if (!referrer) {
console.error(`❌ Реферер не найден для пользователя ${user.username} (${user._id}), referredBy: ${user.referredBy}`);
} else {
// Увеличить счетчик рефералов
const oldCount = referrer.referralsCount || 0;
referrer.referralsCount = oldCount + 1;
await referrer.save();
console.log(`✅ Реферал засчитан: пользователь ${user.username} (${user._id}) засчитан для ${referrer.username} (${referrer._id}). Счетчик: ${oldCount} -> ${referrer.referralsCount}`);
// Начислить баллы за реферала
try {
const { awardReferral } = require('../utils/tickets');
await awardReferral(user.referredBy);
console.log(` ✅ Баллы начислены рефереру ${referrer.username}`);
} catch (error) {
console.error(` ⚠️ Ошибка начисления баллов:`, error.message);
}
}
user.referralCounted = true;
// Очистить loginDates после засчета, чтобы не хранить лишние данные
user.loginDates = [];
await user.save();
} else {
// Если еще нет 2 уникальных дат, сохранить обновленный массив loginDates
await user.save();
}
}
req.user = user;
req.telegramUser = normalizedUser;

View File

@ -33,10 +33,6 @@ const TicketActivitySchema = new mongoose.Schema({
type: Number,
default: 0
},
referralsCounted: {
type: Number,
default: 0
},
// Для отслеживания баллов с реакций на арты (по постам)
// Используем объект вместо Map для совместимости с Mongoose
artReactionsPoints: {

View File

@ -84,29 +84,6 @@ const UserSchema = new mongoose.Schema({
default: false
},
bannedUntil: Date,
// Реферальная система
referralCode: {
type: String,
unique: true,
sparse: true
},
referredBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
referralsCount: {
type: Number,
default: 0
},
// Массив дат входа (для реферальной системы)
loginDates: [{
type: Date
}],
// Флаг, что реферал уже засчитан
referralCounted: {
type: Boolean,
default: false
},
// Билеты для Monthly Ladder
tickets: {
type: Number,
@ -137,15 +114,5 @@ const UserSchema = new mongoose.Schema({
}
});
// Генерировать реферальный код перед сохранением
UserSchema.pre('save', async function(next) {
if (!this.referralCode) {
// Генерировать уникальный код на основе telegramId
const code = `ref_${this.telegramId.slice(-8)}${Math.random().toString(36).substring(2, 6)}`.toUpperCase();
this.referralCode = code;
}
next();
});
module.exports = mongoose.model('User', UserSchema);

View File

@ -57,8 +57,6 @@ const respondWithUser = async (user, res) => {
followingCount: populatedUser.following.length,
followers: populatedUser.followers,
following: populatedUser.following,
referralCode: populatedUser.referralCode,
referralsCount: populatedUser.referralsCount || 0,
tickets: populatedUser.tickets || 0,
settings,
banned: populatedUser.banned

View File

@ -82,7 +82,6 @@ const serializeUser = (user) => {
bannedUntil: user.bannedUntil,
lastActiveAt: user.lastActiveAt,
createdAt: user.createdAt,
referralsCount: user.referralsCount || 0,
// passwordHash никогда не возвращается (уже select: false в модели)
// email не возвращается для безопасности
};

View File

@ -204,73 +204,5 @@ router.get('/search/:query', authenticate, async (req, res) => {
}
});
// Получить топ пользователей по билетам (Monthly Ladder)
router.get('/ladder/top', authenticate, async (req, res) => {
try {
const { limit = 5 } = req.query;
const limitNum = parseInt(limit);
// Получить топ пользователей по билетам (исключаем glpshchn00)
// Берем немного больше, чтобы гарантировать топ 5 после фильтрации
const topUsers = await User.find({
banned: { $ne: true },
username: { $ne: 'glpshchn00' }
})
.select('username firstName lastName photoUrl tickets')
.sort({ tickets: -1 })
.limit(limitNum)
.lean();
// Найти позицию текущего пользователя (исключаем glpshchn00)
const userTickets = req.user.tickets || 0;
const userRank = await User.countDocuments({
tickets: { $gt: userTickets },
banned: { $ne: true },
username: { $ne: 'glpshchn00' }
}) + 1;
// Исключить glpshchn00 из отображения
const isExcludedUser = req.user.username === 'glpshchn00';
// Проверить, есть ли текущий пользователь в топе
const currentUserInTop = !isExcludedUser && topUsers.some(u => u._id.toString() === req.user._id.toString());
// Если пользователь не в топе и не исключен, добавить его отдельно
let currentUserData = null;
if (!isExcludedUser && !currentUserInTop) {
currentUserData = {
_id: req.user._id,
username: req.user.username,
firstName: req.user.firstName,
lastName: req.user.lastName,
photoUrl: req.user.photoUrl,
tickets: req.user.tickets || 0,
rank: userRank
};
} else if (!isExcludedUser && currentUserInTop) {
// Если в топе, добавить rank к существующему пользователю
topUsers.forEach((user, index) => {
if (user._id.toString() === req.user._id.toString()) {
user.rank = index + 1;
}
});
}
// Добавить rank к топ пользователям
topUsers.forEach((user, index) => {
user.rank = index + 1;
});
res.json({
topUsers,
currentUser: currentUserData,
currentUserRank: isExcludedUser ? null : (currentUserInTop ? topUsers.find(u => u._id.toString() === req.user._id.toString())?.rank : userRank)
});
} catch (error) {
console.error('Ошибка получения топа:', error);
res.status(500).json({ error: 'Ошибка сервера' });
}
});
module.exports = router;

View File

@ -33,9 +33,6 @@ async function canAwardTickets(userId, actionType, options = {}) {
case 'comment_received':
return true; // Нет лимита на получение комментариев
case 'referral':
return activity.referralsCounted < 3;
case 'art_reaction':
// Проверка лимита на реакцию на арт (100 баллов в сутки с одного арта)
const postId = options.postId;
@ -108,9 +105,6 @@ async function awardTickets(userId, points, actionType, options = {}) {
case 'comment_received':
activity.commentsReceived += 1;
break;
case 'referral':
activity.referralsCounted += 1;
break;
case 'art_reaction':
const postId = options.postId;
if (postId) {
@ -176,13 +170,6 @@ async function awardCommentReceived(authorId, commenterId) {
return await awardTickets(authorId, 6, 'comment_received', { senderId: commenterId });
}
/**
* Начисляет баллы за реферала
*/
async function awardReferral(userId) {
return await awardTickets(userId, 100, 'referral');
}
/**
* Начисляет баллы за реакцию на арт (лайк)
*/
@ -469,7 +456,6 @@ module.exports = {
awardLikeReceived,
awardCommentWritten,
awardCommentReceived,
awardReferral,
awardArtLike,
awardArtComment,
awardArtModeration,

View File

@ -16,7 +16,6 @@ import Profile from './pages/Profile'
import UserProfile from './pages/UserProfile'
import CommentsPage from './pages/CommentsPage'
import PostMenuPage from './pages/PostMenuPage'
import MonthlyLadder from './pages/MonthlyLadder'
import MiniPlayer from './components/MiniPlayer'
import FullPlayer from './components/FullPlayer'
import './styles/index.css'
@ -233,7 +232,6 @@ function AppContent() {
<Route path="user/:id" element={<UserProfile currentUser={user} />} />
<Route path="post/:postId/comments" element={<CommentsPage user={user} />} />
<Route path="post/:postId/menu" element={<PostMenuPage user={user} />} />
<Route path="ladder" element={<MonthlyLadder user={user} />} />
</Route>
</Routes>
)

View File

@ -1,65 +0,0 @@
.ladder-button {
position: fixed;
bottom: 140px;
right: 16px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #FFD700, #FFA500, #FF6347);
border: 3px solid rgba(255, 255, 255, 0.3);
box-shadow: 0 4px 20px rgba(255, 215, 0, 0.4),
0 0 30px rgba(255, 165, 0, 0.3);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 100;
transition: all 0.3s ease;
animation: float 3s ease-in-out infinite;
}
.ladder-button:active {
transform: scale(0.9);
}
.ladder-button:hover {
box-shadow: 0 6px 30px rgba(255, 215, 0, 0.6),
0 0 40px rgba(255, 165, 0, 0.5);
transform: translateY(-2px);
}
@keyframes float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
.gift-icon {
color: #fff;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
animation: rotate 4s ease-in-out infinite;
}
@keyframes rotate {
0%, 100% {
transform: rotate(0deg);
}
25% {
transform: rotate(-10deg);
}
75% {
transform: rotate(10deg);
}
}
/* Адаптация для темной темы */
[data-theme="dark"] .ladder-button {
box-shadow: 0 4px 20px rgba(255, 215, 0, 0.5),
0 0 30px rgba(255, 165, 0, 0.4);
}

View File

@ -1,26 +0,0 @@
import { useNavigate, useLocation } from 'react-router-dom'
import { Gift } from 'lucide-react'
import { hapticFeedback } from '../utils/telegram'
import './LadderButton.css'
export default function LadderButton() {
const navigate = useNavigate()
const location = useLocation()
// Скрыть кнопку на странице ladder и profile
if (location.pathname === '/ladder' || location.pathname === '/profile') {
return null
}
const handleClick = () => {
hapticFeedback('light')
navigate('/ladder')
}
return (
<button className="ladder-button" onClick={handleClick} aria-label="Monthly Ladder">
<Gift size={24} className="gift-icon" />
</button>
)
}

View File

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

View File

@ -1,571 +0,0 @@
.ladder-page {
min-height: 100vh;
background: var(--bg-primary);
position: relative;
overflow-x: hidden;
padding-bottom: 80px;
}
/* Новогодние снежинки - тонкие, не навязчивые */
.new-year-decorations {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 1;
opacity: 0.3;
}
.snowflake {
position: absolute;
color: var(--text-secondary);
font-size: 16px;
animation: fall linear infinite;
animation-duration: 15s;
}
.snowflake:nth-child(1) {
left: 10%;
animation-delay: 0s;
animation-duration: 12s;
}
.snowflake:nth-child(2) {
left: 30%;
animation-delay: 2s;
animation-duration: 18s;
}
.snowflake:nth-child(3) {
left: 50%;
animation-delay: 4s;
animation-duration: 14s;
}
.snowflake:nth-child(4) {
left: 70%;
animation-delay: 1s;
animation-duration: 16s;
}
.snowflake:nth-child(5) {
left: 85%;
animation-delay: 3s;
animation-duration: 17s;
}
.snowflake:nth-child(6) {
left: 20%;
animation-delay: 5s;
animation-duration: 20s;
}
@keyframes fall {
0% {
transform: translateY(-100vh) rotate(0deg);
opacity: 0.3;
}
100% {
transform: translateY(100vh) rotate(360deg);
opacity: 0;
}
}
.ladder-header {
position: sticky;
top: 0;
background: var(--bg-secondary);
padding: 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid var(--divider-color);
z-index: 10;
}
.ladder-header h1 {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.back-btn {
width: 44px;
height: 44px;
border-radius: 50%;
background: transparent;
color: var(--text-primary);
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
}
/* Карточка отсчета - в стиле Nakama с новогодними акцентами */
.countdown-card {
margin: 16px;
background: var(--bg-secondary);
border-radius: 16px;
padding: 20px;
box-shadow: 0 2px 8px var(--shadow-md);
position: relative;
overflow: hidden;
border: 2px solid transparent;
background-image:
linear-gradient(var(--bg-secondary), var(--bg-secondary)),
linear-gradient(135deg, #FFD700, #FFA500);
background-origin: border-box;
background-clip: padding-box, border-box;
}
.countdown-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
position: relative;
z-index: 1;
}
.countdown-title h2 {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.gift-icon {
color: #FF6347;
animation: bounce 2s ease-in-out infinite;
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-5px);
}
}
.countdown-timer {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
margin: 20px 0;
position: relative;
z-index: 1;
}
.countdown-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.countdown-value {
font-size: 32px;
font-weight: 700;
color: #FFD700;
min-width: 60px;
text-align: center;
}
.countdown-label {
font-size: 12px;
color: var(--text-secondary);
text-transform: uppercase;
}
.countdown-separator {
font-size: 24px;
color: #FFD700;
font-weight: 700;
margin: 0 4px;
}
.countdown-slogan {
text-align: center;
color: var(--text-secondary);
font-size: 14px;
font-style: italic;
margin-top: 16px;
position: relative;
z-index: 1;
}
/* Топ пользователей - в стиле Nakama */
.ladder-top {
margin: 16px;
background: var(--bg-secondary);
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 8px var(--shadow-md);
}
.ladder-top-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
gap: 12px;
}
.ladder-top-header h2 {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.info-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 20px;
color: var(--text-primary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.info-btn:active {
transform: scale(0.95);
background: var(--divider-color);
}
.info-btn svg {
color: #FFD700;
}
.top-users-list {
display: flex;
flex-direction: column;
padding: 8px 0;
}
.top-user-item {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 12px;
background: transparent;
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
transition: all 0.2s;
min-height: 54px;
width: 100%;
box-sizing: border-box;
max-width: 100%;
overflow: hidden;
}
[data-theme="dark"] .top-user-item {
border-bottom-color: rgba(255, 255, 255, 0.03);
}
.top-user-item:last-child {
border-bottom: none;
}
.top-user-item.current-user {
background: rgba(255, 215, 0, 0.05);
}
.user-rank {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
min-width: 24px;
flex-shrink: 0;
}
.rank-icon {
filter: drop-shadow(0 0 4px currentColor);
width: 20px;
height: 20px;
}
.rank-icon.gold {
color: #FFD700;
}
.rank-icon.silver {
color: #C0C0C0;
}
.rank-icon.bronze {
color: #CD7F32;
}
.rank-number {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.user-avatar {
width: 44px;
height: 44px;
min-width: 44px;
border-radius: 50%;
object-fit: cover;
border: none;
flex-shrink: 0;
}
.user-info {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
overflow: hidden;
margin-right: 6px;
}
.user-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.user-stats {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
flex-shrink: 0;
text-align: right;
max-width: 80px;
}
.user-tickets {
font-size: 11px;
color: var(--text-primary);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
.user-prize {
font-size: 11px;
color: var(--text-primary);
font-weight: 600;
white-space: nowrap;
}
.current-badge {
color: #FFD700;
filter: drop-shadow(0 0 4px #FFD700);
animation: twinkle 2s ease-in-out infinite;
}
@keyframes twinkle {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Карточка текущего пользователя */
.current-user-card {
margin: 16px;
background: var(--bg-secondary);
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 8px var(--shadow-md);
}
.current-user-card h3 {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 12px;
}
.current-user-item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
background: transparent;
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
min-height: 54px;
}
[data-theme="dark"] .current-user-item {
border-bottom-color: rgba(255, 255, 255, 0.03);
}
/* Модальное окно с информацией */
.info-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
padding: 20px;
animation: fadeIn 0.3s;
}
.info-modal {
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
background: var(--bg-secondary);
border-radius: 16px;
box-shadow: 0 4px 20px var(--shadow-lg);
position: relative;
z-index: 10001;
}
.info-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid var(--divider-color);
position: sticky;
top: 0;
background: var(--bg-secondary);
z-index: 10002;
}
.info-modal-header h2 {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.close-btn {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--bg-primary);
color: var(--text-primary);
border: none;
font-size: 24px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
line-height: 1;
}
.info-modal-content {
padding: 16px;
position: relative;
z-index: 1;
}
.info-section {
margin-bottom: 20px;
padding-bottom: 20px;
border-bottom: 1px solid var(--divider-color);
}
.info-section:last-child {
border-bottom: none;
margin-bottom: 0;
padding-bottom: 0;
}
.info-section h3 {
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.info-section p {
font-size: 14px;
color: var(--text-primary);
line-height: 1.6;
margin-bottom: 4px;
}
.info-limit {
color: var(--text-secondary);
font-size: 13px;
font-style: italic;
}
.info-section.anti-fraud {
background: var(--bg-primary);
padding: 12px;
border-radius: 8px;
border: 1px solid var(--border-color);
}
.info-section.anti-fraud h3 {
color: #FF6347;
}
.loading-state {
display: flex;
justify-content: center;
align-items: center;
padding: 40px;
}
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--divider-color);
border-top-color: #FFD700;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Темная тема */
[data-theme="dark"] .countdown-card {
background-image:
linear-gradient(var(--bg-secondary), var(--bg-secondary)),
linear-gradient(135deg, #FFD700, #FFA500);
}
[data-theme="dark"] .snowflake {
color: var(--text-secondary);
opacity: 0.2;
}
[data-theme="dark"] .countdown-value {
color: #FFD700;
}
[data-theme="dark"] .countdown-separator {
color: #FFD700;
}
[data-theme="dark"] .user-tickets {
color: var(--text-primary);
}

View File

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

View File

@ -184,96 +184,6 @@
opacity: 0.85;
}
/* Реферальная карточка */
.referral-card {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 12px;
}
.referral-content {
display: flex;
align-items: flex-start;
gap: 12px;
}
.referral-icon {
width: 36px;
height: 36px;
border-radius: 12px;
background: rgba(52, 199, 89, 0.12);
color: #34C759;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.referral-text h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--text-primary);
}
.referral-text p {
margin: 4px 0 8px;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.4;
}
.referral-stats {
font-size: 13px;
color: var(--text-secondary);
}
.referral-stats strong {
color: var(--text-primary);
font-weight: 600;
}
.referral-link-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.referral-link {
padding: 12px;
background: var(--bg-primary);
border-radius: 12px;
border: 1px solid var(--divider-color);
word-break: break-all;
}
.referral-link code {
font-size: 12px;
color: var(--text-primary);
font-family: 'SF Mono', 'Monaco', 'Courier New', monospace;
}
.referral-copy-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 18px;
border-radius: 12px;
background: var(--button-accent);
color: white;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s ease;
}
.referral-copy-btn:active {
opacity: 0.85;
}
.search-switch {
display: flex;
background: var(--bg-primary);

View File

@ -318,18 +318,6 @@ export default function Profile({ user, setUser }) {
setSettings(updatedSettings)
}
const handleCopyReferral = () => {
const botUsername = import.meta.env.VITE_BOT_USERNAME || 'NakamaSpaceBot'
const referralLink = `https://t.me/${botUsername}?startapp=ref_${user.referralCode || user.id}`
navigator.clipboard.writeText(referralLink).then(() => {
hapticFeedback('success')
alert('Реферальная ссылка скопирована!')
}).catch(() => {
hapticFeedback('error')
alert('Не удалось скопировать ссылку')
})
}
return (
<div className="profile-page">
@ -405,33 +393,6 @@ export default function Profile({ user, setUser }) {
</div>
</div>
{/* Реферальная карточка */}
{user.referralCode && (
<div className="referral-card card">
<div className="referral-content">
<div className="referral-icon">
<UserPlus size={20} />
</div>
<div className="referral-text">
<h3>Реферальная программа</h3>
<p>Приглашайте друзей и получайте бонусы за каждого приглашенного пользователя!</p>
<div className="referral-stats">
Приглашено: <strong>{user.referralsCount || 0}</strong>
</div>
</div>
</div>
<div className="referral-link-section">
<div className="referral-link">
<code>{`https://t.me/${import.meta.env.VITE_BOT_USERNAME || 'NakamaSpaceBot'}?startapp=ref_${user.referralCode}`}</code>
</div>
<button className="referral-copy-btn" onClick={handleCopyReferral}>
<Copy size={16} />
Скопировать ссылку
</button>
</div>
</div>
)}
{/* Привязка email (только для Telegram пользователей без email) */}
{user.telegramId && !user.email && (
<div className="link-email-card card">

View File

@ -113,6 +113,34 @@ export const verifyAuth = async () => {
}
// Авторизация через Telegram OAuth (Login Widget)
// Magic-link авторизация
export const sendMagicLink = async (email) => {
const response = await api.post('/auth/magic-link/send', { email })
return response.data
}
export const verifyMagicLink = async (token) => {
const response = await api.get('/auth/magic-link/verify', { params: { token } })
return response.data
}
export const setPassword = async (token, password, username) => {
const response = await api.post('/auth/magic-link/set-password', { token, password, username })
return response.data
}
// Привязка аккаунтов
export const linkTelegram = async (initData) => {
const response = await api.post('/auth/link-telegram', { initData })
return response.data
}
export const linkEmail = async (email, password) => {
const response = await api.post('/auth/link-email', { email, password })
return response.data
}
// Posts API
export const getPosts = async (params = {}) => {
// Поддержка старого формата для обратной совместимости
@ -205,12 +233,6 @@ export const searchUsers = async (query) => {
return response.data.users
}
// Ladder API
export const getLadderTop = async (limit = 5) => {
const response = await api.get('/users/ladder/top', { params: { limit } })
return response.data
}
// Notifications API
export const getNotifications = async (params = {}) => {
const response = await api.get('/notifications', { params })

View File

@ -114,7 +114,6 @@ async def get_users(
'bannedUntil': banned_until,
'createdAt': created_at,
'lastActiveAt': last_active_at,
'referralsCount': int(u.get('referralsCount', 0)),
'isAdmin': is_admin
})

View File

@ -1009,7 +1009,6 @@ export default function App() {
<div className="list-item-meta">
<span>Роль: {u.role}</span>
<span>Активность: {formatDate(u.lastActiveAt)}</span>
{u.referralsCount > 0 && <span className="badge badge-info">Рефералов: {u.referralsCount}</span>}
{u.banned && <span className="badge badge-danger">Бан до {formatDate(u.bannedUntil)}</span>}
</div>
</div>