Реферальная система: БД, API, UI

**База данных:**
- Создана таблица referrals в MariaDB (193.168.175.128)
- Поля: username, referrer_username, referral_count, bonus_days_earned
- Foreign keys к users таблице с CASCADE/SET NULL

**API Endpoints:**
- POST /api/referral/track - отслеживание новых рефералов
  - Автоматический расчёт бонусов: +7 дней за каждого
  - Milestone bonus: +30 дней за каждые 5 рефералов
  - Обновление expire даты реферера в users таблице

- GET /api/referral/stats?username=xxx - статистика
  - Возвращает количество рефералов, бонусные дни
  - Список приглашённых пользователей со статусами

**Интеграция:**
- POST /api/create-user принимает referrerId параметр
- Автоматический вызов /api/referral/track после создания юзера
- Параметр ref из URL при активации trial

**UI:**
- /app/referral/page.tsx - страница статистики
  - 3 KPI карточки: рефералов, бонусных дней, milestone
  - Реферальная ссылка с кнопкой копирования
  - Список приглашённых юзеров с иконками статуса
  - Инфоблок о механике начисления бонусов

**ReferralModal обновлён:**
- Добавлена кнопка «Моя статистика» → /referral
- Перенос Share/Copy кнопок на второй/третий план

**Зависимости:**
- mysql2@3.16.3 - для подключения к MariaDB

**Логика бонусов:**
- +7 дней за каждого успешного реферала
- +30 дней бонус за каждые 5 рефералов (milestone)
- Автоматическое обновление expire поля в users таблице
- Сохранение всех бонусов в bonus_days_earned
This commit is contained in:
Umbrix Dev
2026-02-06 20:51:40 +03:00
parent 00bfda8748
commit b43eb3c724
8 changed files with 684 additions and 5 deletions

View File

@@ -19,9 +19,9 @@ if (!ADMIN_USERNAME || !ADMIN_PASSWORD) {
export async function POST(request: NextRequest) {
try {
const { planType, telegramId, telegramUsername, firstName, lastName } = await request.json();
const { planType, telegramId, telegramUsername, firstName, lastName, referrerId } = await request.json();
logger.debug('📥 Received data:', { planType, telegramId, telegramUsername, firstName, lastName });
logger.debug('📥 Received data:', { planType, telegramId, telegramUsername, firstName, lastName, referrerId });
// 1. Получаем токен админа
const tokenResponse = await fetch(`${MARZBAN_API}/api/admin/token`, {
@@ -101,6 +101,24 @@ export async function POST(request: NextRequest) {
// 5. Получаем subscription token
const subscriptionToken = userData.subscription_url?.split('/sub/')[1]?.replace(/\/$/, '') || username;
// 6. Track referral if referrerId provided
if (referrerId) {
try {
await fetch(`${request.nextUrl.origin}/api/referral/track`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: username,
referrer_username: referrerId,
}),
});
logger.debug('✅ Referral tracked:', { username, referrer: referrerId });
} catch (refError) {
logger.error('❌ Referral tracking failed:', refError);
// Don't fail user creation if referral tracking fails
}
}
return NextResponse.json({
success: true,
token: subscriptionToken,

View File

@@ -0,0 +1,90 @@
// API endpoint для получения статистики реферальной программы
// GET /api/referral/stats?username=xxx - возвращает статистику пользователя
import { NextRequest, NextResponse } from 'next/server';
import mysql from 'mysql2/promise';
// Database connection config
const dbConfig = {
host: '193.168.175.128',
user: 'marzban_user',
password: '2CuopqFd0Y5V5n/qBM+eygOQb6aC8B8pACcdHjeVJsE=',
database: 'marzban_prod',
};
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const username = searchParams.get('username');
if (!username) {
return NextResponse.json(
{ success: false, error: 'Username is required' },
{ status: 400 }
);
}
// Connect to database
const connection = await mysql.createConnection(dbConfig);
try {
// Get user's referral stats
const [statsRows] = await connection.query(
'SELECT * FROM referrals WHERE username = ?',
[username]
);
if ((statsRows as any[]).length === 0) {
return NextResponse.json({
success: true,
hasReferrals: false,
stats: {
referral_count: 0,
bonus_days_earned: 0,
total_referrals_used: 0,
},
});
}
const stats = (statsRows as any[])[0];
// Get list of referred users
const [referredRows] = await connection.query(
`SELECT u.username, u.created_at, u.status
FROM referrals r
JOIN users u ON r.username = u.username
WHERE r.referrer_username = ?
ORDER BY r.created_at DESC`,
[username]
);
return NextResponse.json({
success: true,
hasReferrals: true,
stats: {
referral_count: stats.referral_count || 0,
bonus_days_earned: stats.bonus_days_earned || 0,
total_referrals_used: stats.total_referrals_used || 0,
created_at: stats.created_at,
updated_at: stats.updated_at,
},
referred_users: (referredRows as any[]).map((user: any) => ({
username: user.username,
created_at: user.created_at,
status: user.status,
})),
});
} finally {
await connection.end();
}
} catch (error) {
console.error('Referral stats error:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,135 @@
// API endpoint для отслеживания реферальной регистрации
// POST /api/referral/track - записывает нового пользователя с реферером
import { NextRequest, NextResponse } from 'next/server';
import mysql from 'mysql2/promise';
// Database connection config
const dbConfig = {
host: '193.168.175.128',
user: 'marzban_user',
password: '2CuopqFd0Y5V5n/qBM+eygOQb6aC8B8pACcdHjeVJsE=',
database: 'marzban_prod',
};
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { username, referrer_username } = body;
if (!username) {
return NextResponse.json(
{ success: false, error: 'Username is required' },
{ status: 400 }
);
}
// Connect to database
const connection = await mysql.createConnection(dbConfig);
try {
// Check if user already exists in referrals table
const [existingRows] = await connection.query(
'SELECT id FROM referrals WHERE username = ?',
[username]
);
if ((existingRows as any[]).length > 0) {
return NextResponse.json({
success: false,
error: 'User already tracked',
});
}
// Insert new referral record
await connection.query(
'INSERT INTO referrals (username, referrer_username) VALUES (?, ?)',
[username, referrer_username || null]
);
// If referrer exists, increment their referral_count
if (referrer_username) {
// Check if referrer exists in users table
const [referrerRows] = await connection.query(
'SELECT username FROM users WHERE username = ?',
[referrer_username]
);
if ((referrerRows as any[]).length > 0) {
// Update or insert referrer's stats
await connection.query(
`INSERT INTO referrals (username, referral_count)
VALUES (?, 1)
ON DUPLICATE KEY UPDATE
referral_count = referral_count + 1,
updated_at = CURRENT_TIMESTAMP`,
[referrer_username]
);
// Calculate bonus days
const [statsRows] = await connection.query(
'SELECT referral_count FROM referrals WHERE username = ?',
[referrer_username]
);
const referralCount = (statsRows as any[])[0]?.referral_count || 0;
let bonusDays = 0;
// +7 days for each referral
bonusDays = referralCount * 7;
// +30 days milestone bonus for every 5 referrals
if (referralCount >= 5) {
const milestones = Math.floor(referralCount / 5);
bonusDays += milestones * 30;
}
// Update bonus_days_earned
await connection.query(
'UPDATE referrals SET bonus_days_earned = ? WHERE username = ?',
[bonusDays, referrer_username]
);
// Add bonus days to referrer's expire date in users table
await connection.query(
`UPDATE users
SET expire = CASE
WHEN expire IS NULL OR expire < UNIX_TIMESTAMP()
THEN UNIX_TIMESTAMP() + (? * 86400)
ELSE expire + (7 * 86400)
END
WHERE username = ?`,
[bonusDays, referrer_username]
);
return NextResponse.json({
success: true,
message: 'Referral tracked successfully',
referrer_bonus: {
username: referrer_username,
new_referral_count: referralCount,
bonus_days_added: 7,
total_bonus_days: bonusDays,
},
});
}
}
return NextResponse.json({
success: true,
message: 'User tracked without referrer',
});
} finally {
await connection.end();
}
} catch (error) {
console.error('Referral track error:', error);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}

View File

@@ -175,6 +175,10 @@ export default function Home() {
const handleActivateTrial = async () => {
setIsLoading(true);
try {
// Получаем referrerId из URL (если есть)
const urlParams = new URLSearchParams(window.location.search);
const referrerId = urlParams.get('ref');
// Создаем trial подписку через API
const response = await fetch('/api/create-user', {
method: 'POST',
@@ -184,6 +188,7 @@ export default function Home() {
telegramId: Date.now(), // Временно, пока нет настоящего Telegram ID
telegramUsername: 'demo_user',
firstName: 'Demo',
referrerId: referrerId || undefined,
}),
});

296
app/referral/page.tsx Normal file
View File

@@ -0,0 +1,296 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import {
Shield,
ArrowLeft,
Users,
Gift,
TrendingUp,
Calendar,
CheckCircle,
XCircle,
Clock,
Copy,
Share2
} from 'lucide-react';
interface ReferralStats {
referral_count: number;
bonus_days_earned: number;
total_referrals_used: number;
created_at: string;
updated_at: string;
}
interface ReferredUser {
username: string;
created_at: string;
status: string;
}
export default function ReferralPage() {
const router = useRouter();
const [stats, setStats] = useState<ReferralStats | null>(null);
const [referredUsers, setReferredUsers] = useState<ReferredUser[]>([]);
const [loading, setLoading] = useState(true);
const [username, setUsername] = useState<string>('');
const [referralUrl, setReferralUrl] = useState<string>('');
const [showToast, setShowToast] = useState(false);
const [toastMessage, setToastMessage] = useState('');
useEffect(() => {
const loadReferralStats = async () => {
// Get username from localStorage
const token = localStorage.getItem('subscriptionToken');
if (!token) {
router.push('/');
return;
}
const userId = token.split('_')[0];
setUsername(userId);
// Generate referral URL
const botUsername = process.env.NEXT_PUBLIC_TELEGRAM_BOT_USERNAME || 'Dorod_vps_bot';
const url = `https://t.me/${botUsername}?start=ref_${userId}`;
setReferralUrl(url);
// Fetch stats from API
try {
const response = await fetch(`/api/referral/stats?username=${userId}`);
const data = await response.json();
if (data.success && data.hasReferrals) {
setStats(data.stats);
setReferredUsers(data.referred_users || []);
}
} catch (error) {
console.error('Failed to load referral stats:', error);
} finally {
setLoading(false);
}
};
loadReferralStats();
}, [router]);
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
showToastNotification('✅ Скопировано в буфер обмена!');
} catch (err) {
console.error('Failed to copy:', err);
showToastNotification('❌ Ошибка копирования');
}
};
const showToastNotification = (message: string) => {
setToastMessage(message);
setShowToast(true);
setTimeout(() => setShowToast(false), 3000);
};
const shareReferralLink = async () => {
const shareText = `🚀 Попробуй Umbrix VPN - быстрый и безопасный VPN!\n\n✨ Получи 7 дней бесплатно по моей ссылке:\n${referralUrl}`;
if (navigator.share) {
try {
await navigator.share({
title: 'Umbrix VPN - Пригласи друга!',
text: shareText,
url: referralUrl,
});
} catch (err) {
if ((err as Error).name !== 'AbortError') {
console.error('Share failed:', err);
copyToClipboard(shareText);
}
}
} else {
copyToClipboard(shareText);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'active':
return <CheckCircle className="w-4 h-4 text-green-500" />;
case 'expired':
return <Clock className="w-4 h-4 text-yellow-500" />;
case 'disabled':
return <XCircle className="w-4 h-4 text-red-500" />;
default:
return <Clock className="w-4 h-4 text-slate-400" />;
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'active':
return 'Активен';
case 'expired':
return 'Истёк';
case 'disabled':
return 'Отключён';
default:
return status;
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center" style={{ background: 'var(--bg-app)' }}>
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 mx-auto" style={{ borderColor: 'var(--primary)' }}></div>
<p className="mt-4 text-slate-400">Загрузка...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen" style={{ background: 'var(--bg-app)', color: 'var(--text-white)' }}>
{/* Header */}
<header className="flex items-center justify-between p-4 border-b sticky top-0 z-40 backdrop-blur-sm" style={{ borderColor: 'var(--border)', background: 'var(--bg-card)' }}>
<button onClick={() => router.back()} className="p-2 rounded-lg hover:opacity-70 transition-opacity" style={{ background: 'var(--bg-elevated)' }}>
<ArrowLeft className="w-5 h-5" style={{ color: 'var(--text-primary)' }} />
</button>
<div className="flex items-center gap-2">
<Shield className="w-6 h-6" style={{ color: 'var(--primary)' }} />
<span className="text-xl font-bold">Реферальная программа</span>
</div>
<div className="w-9" /> {/* Spacer */}
</header>
{/* Main Content */}
<main className="px-6 py-8 max-w-2xl mx-auto">
{/* Stats Cards */}
<div className="grid grid-cols-3 gap-3 mb-6">
<div className="rounded-xl p-4 text-center" style={{ background: 'var(--bg-card)' }}>
<Users className="w-6 h-6 mx-auto mb-2" style={{ color: 'var(--primary)' }} />
<div className="text-2xl font-bold mb-1">{stats?.referral_count || 0}</div>
<div className="text-xs text-slate-400">Рефералов</div>
</div>
<div className="rounded-xl p-4 text-center" style={{ background: 'var(--bg-card)' }}>
<Gift className="w-6 h-6 mx-auto mb-2" style={{ color: 'var(--primary)' }} />
<div className="text-2xl font-bold mb-1">{stats?.bonus_days_earned || 0}</div>
<div className="text-xs text-slate-400">Дней бонуса</div>
</div>
<div className="rounded-xl p-4 text-center" style={{ background: 'var(--bg-card)' }}>
<TrendingUp className="w-6 h-6 mx-auto mb-2" style={{ color: 'var(--primary)' }} />
<div className="text-2xl font-bold mb-1">+{(stats?.referral_count || 0) >= 5 ? 30 : 0}</div>
<div className="text-xs text-slate-400">Milestone</div>
</div>
</div>
{/* Referral Link */}
<div className="rounded-xl p-4 mb-6" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Gift className="w-4 h-4" style={{ color: 'var(--primary)' }} />
Ваша реферальная ссылка
</h3>
<div className="flex gap-2 mb-3">
<input
type="text"
value={referralUrl}
readOnly
className="flex-1 px-3 py-2 rounded-lg text-sm"
style={{ background: 'var(--bg-elevated)', color: 'var(--text-white)', border: '1px solid var(--border)' }}
/>
<button
onClick={() => copyToClipboard(referralUrl)}
className="p-2 rounded-lg hover:opacity-80 transition-opacity"
style={{ background: 'var(--bg-elevated)' }}
>
<Copy className="w-5 h-5" style={{ color: 'var(--primary)' }} />
</button>
</div>
<button
onClick={shareReferralLink}
className="w-full flex items-center justify-center gap-2 py-3 rounded-lg font-medium hover:opacity-80 transition-opacity"
style={{ background: 'var(--primary)', color: 'white' }}
>
<Share2 className="w-5 h-5" />
Поделиться ссылкой
</button>
</div>
{/* Bonus Info */}
<div className="rounded-xl p-4 mb-6" style={{ background: 'var(--bg-card)', border: '1px solid var(--border)' }}>
<h3 className="text-sm font-semibold mb-3">💰 Как работают бонусы</h3>
<ul className="space-y-2 text-sm text-slate-300">
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 mt-0.5 flex-shrink-0" style={{ color: 'var(--primary)' }} />
<span>+7 дней за каждого приглашённого друга</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 mt-0.5 flex-shrink-0" style={{ color: 'var(--primary)' }} />
<span>+30 дней бонус за каждые 5 рефералов</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="w-4 h-4 mt-0.5 flex-shrink-0" style={{ color: 'var(--primary)' }} />
<span>Бонусы начисляются автоматически</span>
</li>
</ul>
</div>
{/* Referred Users */}
{referredUsers.length > 0 && (
<div className="rounded-xl p-4" style={{ background: 'var(--bg-card)' }}>
<h3 className="text-sm font-semibold mb-3 flex items-center gap-2">
<Users className="w-4 h-4" style={{ color: 'var(--primary)' }} />
Ваши рефералы ({referredUsers.length})
</h3>
<div className="space-y-2">
{referredUsers.map((user, index) => (
<div
key={index}
className="flex items-center justify-between p-3 rounded-lg"
style={{ background: 'var(--bg-elevated)' }}
>
<div className="flex items-center gap-3">
{getStatusIcon(user.status)}
<div>
<div className="text-sm font-medium">{user.username}</div>
<div className="text-xs text-slate-400">
{new Date(user.created_at).toLocaleDateString('ru-RU')}
</div>
</div>
</div>
<div className="text-xs px-2 py-1 rounded" style={{ background: 'var(--bg-card)' }}>
{getStatusText(user.status)}
</div>
</div>
))}
</div>
</div>
)}
{referredUsers.length === 0 && (
<div className="text-center py-12 rounded-xl" style={{ background: 'var(--bg-card)' }}>
<Users className="w-16 h-16 mx-auto mb-4 opacity-50" style={{ color: 'var(--primary)' }} />
<p className="text-slate-400 mb-2">Пока нет рефералов</p>
<p className="text-sm text-slate-500">Поделитесь ссылкой с друзьями!</p>
</div>
)}
</main>
{/* Toast Notification */}
{showToast && (
<div className="fixed bottom-24 left-1/2 transform -translate-x-1/2 z-50 animate-fade-in">
<div className="px-6 py-3 rounded-full shadow-lg border flex items-center gap-2" style={{ background: 'var(--bg-card)', borderColor: 'var(--border)' }}>
<span>{toastMessage}</span>
</div>
</div>
)}
</div>
);
}