Замена DePay на FreeKassa и удаление системы проверки чеков
- Создан модуль FreeKassa с обработкой платежей, webhook, IP whitelist, MD5 подписью - Переписан frontend billing.tsx для формы оплаты FreeKassa - Удалены файлы и зависимости DePay (depay.routes.ts, @depay/widgets) - Полностью удалена система проверки чеков операторами: * Удален backend модуль /modules/check/ * Удалена frontend страница checkverification.tsx * Очищены импорты, маршруты, WebSocket события * Удалено поле checkId из Notification схемы * Удалены переводы для чеков - Добавлена поддержка спецсимволов в секретных словах FreeKassa - Добавлена документация PAYMENT_MIGRATION.md
This commit is contained in:
@@ -6,6 +6,3 @@ VITE_TURNSTILE_SITE_KEY=0x4AAAAAAB7306voAK0Pjx8O
|
||||
# API URLs
|
||||
VITE_API_URL=https://api.ospab.host
|
||||
VITE_SOCKET_URL=wss://api.ospab.host
|
||||
|
||||
# DePay Crypto Payment
|
||||
VITE_DEPAY_INTEGRATION_ID=60f35b39-15e6-4900-9c6d-eb4e4213d5b9
|
||||
|
||||
3794
ospabhost/frontend/package-lock.json
generated
3794
ospabhost/frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@depay/widgets": "^13.0.40",
|
||||
"@marsidev/react-turnstile": "^1.3.1",
|
||||
"axios": "^1.12.2",
|
||||
"qrcode.react": "^4.2.0",
|
||||
|
||||
@@ -23,7 +23,6 @@ type ServerToClientEvent =
|
||||
| { type: 'ticket:response'; ticketId: number; response: AnyObject }
|
||||
| { type: 'ticket:status'; ticketId: number; status: string }
|
||||
| { type: 'balance:updated'; newBalance: number }
|
||||
| { type: 'check:status'; checkId: number; status: string }
|
||||
| { type: 'pong' }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
|
||||
@@ -298,10 +298,6 @@ export const en: TranslationKeys = {
|
||||
description: 'Description',
|
||||
amount: 'Amount',
|
||||
noTransactions: 'No transactions',
|
||||
uploadCheck: 'Upload Receipt',
|
||||
checkPending: 'Pending Review',
|
||||
checkApproved: 'Approved',
|
||||
checkRejected: 'Rejected',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
|
||||
@@ -296,10 +296,7 @@ export const ru = {
|
||||
description: 'Описание',
|
||||
amount: 'Сумма',
|
||||
noTransactions: 'Нет транзакций',
|
||||
uploadCheck: 'Загрузить чек',
|
||||
checkPending: 'На проверке',
|
||||
checkApproved: 'Одобрен',
|
||||
checkRejected: 'Отклонён',
|
||||
|
||||
},
|
||||
settings: {
|
||||
title: 'Настройки',
|
||||
|
||||
@@ -5,18 +5,7 @@ import { API_URL } from '../../config/api';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import AuthContext from '../../context/authcontext';
|
||||
|
||||
const DEPAY_INTEGRATION_ID = import.meta.env.VITE_DEPAY_INTEGRATION_ID;
|
||||
|
||||
// Declare DePayWidgets on window
|
||||
declare global {
|
||||
interface Window {
|
||||
DePayWidgets?: {
|
||||
Payment: (config: any) => void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface CryptoPayment {
|
||||
interface Payment {
|
||||
id: number;
|
||||
amount: number;
|
||||
cryptoAmount: number | null;
|
||||
@@ -35,29 +24,25 @@ const Billing = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const [balance, setBalance] = useState<number>(0);
|
||||
const [payments, setPayments] = useState<CryptoPayment[]>([]);
|
||||
const [exchangeRate, setExchangeRate] = useState<number>(95);
|
||||
const [payments, setPayments] = useState<Payment[]>([]);
|
||||
const [amount, setAmount] = useState<string>('500'); // Default 500 RUB
|
||||
const [message, setMessage] = useState('');
|
||||
const [messageType, setMessageType] = useState<'success' | 'error'>('success');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Load DePay script
|
||||
loadDePayScript();
|
||||
|
||||
fetchBalance();
|
||||
fetchPayments();
|
||||
fetchExchangeRate();
|
||||
|
||||
// Check for payment success/error from redirect
|
||||
const paymentStatus = searchParams.get('payment');
|
||||
const txHash = searchParams.get('tx');
|
||||
const orderId = searchParams.get('order');
|
||||
|
||||
if (paymentStatus === 'success') {
|
||||
showMessage(
|
||||
isEn
|
||||
? `✅ Payment successful! Transaction: ${txHash}`
|
||||
: `✅ Оплата успешна! Транзакция: ${txHash}`,
|
||||
? `✅ Payment successful! Order: ${orderId}`
|
||||
: `✅ Оплата успешна! Заказ: ${orderId}`,
|
||||
'success'
|
||||
);
|
||||
// Refresh data
|
||||
@@ -75,25 +60,6 @@ const Billing = () => {
|
||||
}
|
||||
}, [searchParams, isEn]);
|
||||
|
||||
const loadDePayScript = () => {
|
||||
if (window.DePayWidgets) return;
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://integrate.depay.com/widgets/v12.js';
|
||||
script.async = true;
|
||||
script.onload = () => {
|
||||
console.log('[DePay] Widget script loaded');
|
||||
};
|
||||
script.onerror = () => {
|
||||
console.error('[DePay] Failed to load widget script');
|
||||
showMessage(
|
||||
isEn ? 'Failed to load payment widget' : 'Не удалось загрузить платёжный виджет',
|
||||
'error'
|
||||
);
|
||||
};
|
||||
document.body.appendChild(script);
|
||||
};
|
||||
|
||||
const fetchBalance = async () => {
|
||||
try {
|
||||
console.log('[Billing] Загрузка баланса...');
|
||||
@@ -109,7 +75,7 @@ const Billing = () => {
|
||||
const fetchPayments = async () => {
|
||||
try {
|
||||
console.log('[Billing] Загрузка истории платежей...');
|
||||
const res = await apiClient.get(`${API_URL}/api/payment/depay/history`);
|
||||
const res = await apiClient.get(`${API_URL}/payment/freekassa/history`);
|
||||
const paymentsData = res.data.payments || [];
|
||||
setPayments(paymentsData);
|
||||
console.log('[Billing] История загружена:', paymentsData.length, 'платежей');
|
||||
@@ -118,31 +84,14 @@ const Billing = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchExchangeRate = async () => {
|
||||
try {
|
||||
const res = await apiClient.get(`${API_URL}/api/payment/depay/rate`);
|
||||
const rate = res.data.rate || 95;
|
||||
setExchangeRate(rate);
|
||||
console.log('[Billing] Курс USDT/RUB:', rate);
|
||||
} catch (error) {
|
||||
console.error('[Billing] Ошибка получения курса:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const showMessage = (msg: string, type: 'success' | 'error' = 'success') => {
|
||||
setMessage(msg);
|
||||
setMessageType(type);
|
||||
setTimeout(() => setMessage(''), 8000);
|
||||
};
|
||||
|
||||
const handleOpenPaymentWidget = () => {
|
||||
if (!window.DePayWidgets) {
|
||||
showMessage(
|
||||
isEn ? 'Payment widget not loaded yet. Please wait...' : 'Виджет оплаты ещё загружается. Подождите...',
|
||||
'error'
|
||||
);
|
||||
return;
|
||||
}
|
||||
const handleSubmitPayment = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!userData?.user?.id) {
|
||||
showMessage(
|
||||
@@ -161,87 +110,65 @@ const Billing = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const amountInUSDT = (amountInRub / exchangeRate).toFixed(2);
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Open DePay payment widget
|
||||
window.DePayWidgets.Payment({
|
||||
integration: DEPAY_INTEGRATION_ID,
|
||||
|
||||
// Amount to pay in USDT
|
||||
amount: {
|
||||
token: 'USDT',
|
||||
blockchain: 'polygon',
|
||||
amount: amountInUSDT,
|
||||
},
|
||||
|
||||
// User identifier for callback
|
||||
user: {
|
||||
id: String(userData.user.id),
|
||||
},
|
||||
|
||||
// Callback URLs
|
||||
callback: {
|
||||
url: `${API_URL}/api/payment/depay/callback`,
|
||||
},
|
||||
|
||||
// Success redirect
|
||||
success: {
|
||||
url: `${API_URL}/payment/depay/success`,
|
||||
},
|
||||
|
||||
// Styling
|
||||
style: {
|
||||
colors: {
|
||||
primary: '#6366f1', // ospab primary color
|
||||
},
|
||||
},
|
||||
|
||||
// Event handlers
|
||||
sent: (transaction: any) => {
|
||||
console.log('[DePay] Payment sent:', transaction);
|
||||
showMessage(
|
||||
isEn
|
||||
? 'Payment sent! Waiting for confirmation...'
|
||||
: 'Оплата отправлена! Ожидаем подтверждение...',
|
||||
'success'
|
||||
);
|
||||
},
|
||||
|
||||
confirmed: (transaction: any) => {
|
||||
console.log('[DePay] Payment confirmed:', transaction);
|
||||
showMessage(
|
||||
isEn
|
||||
? '✅ Payment confirmed! Your balance will be updated shortly.'
|
||||
: '✅ Оплата подтверждена! Баланс будет обновлён.',
|
||||
'success'
|
||||
);
|
||||
|
||||
// Refresh balance and payments after 3 seconds
|
||||
setTimeout(() => {
|
||||
fetchBalance();
|
||||
fetchPayments();
|
||||
}, 3000);
|
||||
},
|
||||
|
||||
failed: (error: any) => {
|
||||
console.error('[DePay] Payment failed:', error);
|
||||
showMessage(
|
||||
isEn
|
||||
? '❌ Payment failed. Please try again.'
|
||||
: '❌ Ошибка оплаты. Попробуйте снова.',
|
||||
'error'
|
||||
);
|
||||
},
|
||||
// Create payment order
|
||||
const res = await apiClient.post(`${API_URL}/payment/freekassa/create-payment`, {
|
||||
amount: amountInRub
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[DePay] Error opening widget:', error);
|
||||
|
||||
const { merchantId, amount: orderAmount, orderId, signature, currency, email, userId, paymentUrl } = res.data;
|
||||
|
||||
console.log('[FreeKassa] Payment data received:', res.data);
|
||||
|
||||
// Create form and submit to FreeKassa
|
||||
const form = document.createElement('form');
|
||||
form.method = 'GET';
|
||||
form.action = paymentUrl;
|
||||
form.target = '_blank'; // Open in new tab
|
||||
|
||||
// Add hidden fields
|
||||
const fields = {
|
||||
m: merchantId,
|
||||
oa: orderAmount,
|
||||
o: orderId,
|
||||
s: signature,
|
||||
currency: currency,
|
||||
em: email,
|
||||
us_user_id: userId.toString(),
|
||||
lang: isEn ? 'en' : 'ru'
|
||||
};
|
||||
|
||||
Object.entries(fields).forEach(([key, value]) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = key;
|
||||
input.value = value;
|
||||
form.appendChild(input);
|
||||
});
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
document.body.removeChild(form);
|
||||
|
||||
showMessage(
|
||||
isEn
|
||||
? 'Failed to open payment widget'
|
||||
: 'Не удалось открыть виджет оплаты',
|
||||
? 'Redirecting to payment page...'
|
||||
: 'Перенаправление на страницу оплаты...',
|
||||
'success'
|
||||
);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[FreeKassa] Payment creation error:', error);
|
||||
showMessage(
|
||||
isEn
|
||||
? 'Failed to create payment. Please try again.'
|
||||
: 'Не удалось создать платёж. Попробуйте снова.',
|
||||
'error'
|
||||
);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -298,183 +225,98 @@ const Billing = () => {
|
||||
<div className="bg-gradient-to-br from-indigo-500 to-purple-600 dark:from-indigo-600 dark:to-purple-700 p-6 lg:p-8 rounded-xl lg:rounded-2xl mb-6 text-white shadow-lg">
|
||||
<p className="text-sm opacity-90 mb-2">{isEn ? 'Current Balance' : 'Текущий баланс'}</p>
|
||||
<p className="text-4xl lg:text-5xl font-extrabold">{balance.toFixed(2)} ₽</p>
|
||||
<p className="text-xs opacity-75 mt-2">
|
||||
≈ ${(balance / exchangeRate).toFixed(2)} USDT
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Курс обмена */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-xl p-4 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-blue-800 dark:text-blue-300">
|
||||
{isEn ? 'Exchange Rate' : 'Курс обмена'}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-blue-900 dark:text-blue-200">
|
||||
1 USDT = {exchangeRate.toFixed(2)} ₽
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400">
|
||||
{isEn ? 'Updated every minute' : 'Обновляется каждую минуту'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Форма ввода суммы */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{isEn ? 'Top-up amount (RUB)' : 'Сумма пополнения (₽)'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
{/* Форма пополнения */}
|
||||
<form onSubmit={handleSubmitPayment} className="mb-8">
|
||||
<div className="bg-gray-50 dark:bg-gray-700 p-6 rounded-xl border border-gray-200 dark:border-gray-600 mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
{isEn ? 'Amount to top up (RUB)' : 'Сумма пополнения (₽)'}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="50"
|
||||
step="0.01"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
min="50"
|
||||
step="50"
|
||||
className="w-full px-4 py-3 pr-20 rounded-xl border-2 border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200 dark:focus:ring-indigo-800 transition-all text-lg font-semibold"
|
||||
placeholder={isEn ? 'Enter amount' : 'Введите сумму'}
|
||||
className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
placeholder="500"
|
||||
required
|
||||
/>
|
||||
<span className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 font-semibold">
|
||||
₽
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{isEn ? 'Minimum: 50 RUB' : 'Минимум: 50 ₽'}
|
||||
</p>
|
||||
<p className="text-sm font-semibold text-indigo-600 dark:text-indigo-400">
|
||||
≈ {(parseFloat(amount || '0') / exchangeRate).toFixed(4)} USDT
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{isEn ? 'Minimum amount: 50 RUB' : 'Минимальная сумма: 50 ₽'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопка оплаты через DePay */}
|
||||
<div className="mb-8">
|
||||
<button
|
||||
onClick={handleOpenPaymentWidget}
|
||||
className="w-full px-6 py-4 rounded-xl text-white font-bold text-lg transition-all duration-200 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 shadow-lg hover:shadow-xl transform hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-3">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{isEn ? 'Top Up with Crypto (USDT)' : 'Пополнить криптовалютой (USDT)'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 text-center mt-3">
|
||||
{isEn
|
||||
? 'Pay with USDT on Polygon network. Fast and secure.'
|
||||
: 'Оплата USDT в сети Polygon. Быстро и безопасно.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Преимущества */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8 pb-8 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-green-100 dark:bg-green-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-800 dark:text-white text-sm">{isEn ? 'Instant' : 'Мгновенно'}</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{isEn ? 'Balance updates in seconds' : 'Баланс обновляется за секунды'}
|
||||
</p>
|
||||
</div>
|
||||
{/* Кнопка оплаты через FreeKassa */}
|
||||
<div className="mb-8">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full px-6 py-4 rounded-xl text-white font-bold text-lg transition-all duration-200 bg-gradient-to-r from-indigo-600 to-purple-600 hover:from-indigo-700 hover:to-purple-700 shadow-lg hover:shadow-xl transform hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="flex items-center justify-center gap-3">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{isSubmitting
|
||||
? (isEn ? 'Processing...' : 'Обработка...')
|
||||
: (isEn ? 'Top Up via FreeKassa' : 'Пополнить через FreeKassa')
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
<p className="mt-3 text-xs text-center text-gray-500 dark:text-gray-400">
|
||||
{isEn
|
||||
? 'Secure payment via FreeKassa. Multiple payment methods available.'
|
||||
: 'Безопасная оплата через FreeKassa. Доступны различные способы оплаты.'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-800 dark:text-white text-sm">{isEn ? 'Secure' : 'Безопасно'}</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{isEn ? 'Blockchain verified' : 'Проверено блокчейном'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-800 dark:text-white text-sm">{isEn ? 'Low Fees' : 'Низкие комиссии'}</p>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{isEn ? 'Minimal network fees' : 'Минимальные комиссии сети'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* История платежей */}
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-gray-800 dark:text-white mb-4">
|
||||
{isEn ? 'Payment History' : 'История платежей'}
|
||||
</h3>
|
||||
{payments.length > 0 ? (
|
||||
|
||||
{payments.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<svg className="w-16 h-16 text-gray-300 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{isEn ? 'No payment history yet' : 'История платежей пуста'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{payments.map((payment) => (
|
||||
<div
|
||||
key={payment.id}
|
||||
className="bg-gray-50 dark:bg-gray-700/50 p-4 rounded-xl flex flex-col md:flex-row md:items-center justify-between gap-3 transition-colors duration-200"
|
||||
className="bg-gray-50 dark:bg-gray-700 p-4 rounded-xl border border-gray-200 dark:border-gray-600 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<p className="font-bold text-gray-800 dark:text-white text-lg">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<p className="font-semibold text-lg text-gray-900 dark:text-white">
|
||||
+{payment.amount.toFixed(2)} ₽
|
||||
</p>
|
||||
<span className={`text-xs font-semibold ${getStatusColor(payment.status)}`}>
|
||||
{getStatusText(payment.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{payment.cryptoAmount?.toFixed(4)} {payment.token}
|
||||
{payment.exchangeRate && (
|
||||
<span className="ml-2 text-xs">
|
||||
(@ {payment.exchangeRate.toFixed(2)} ₽)
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-1">
|
||||
{formatDate(payment.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
{payment.transactionHash && (
|
||||
<a
|
||||
href={`https://polygonscan.com/tx/${payment.transactionHash}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-indigo-600 dark:text-indigo-400 hover:underline flex items-center gap-1"
|
||||
>
|
||||
{isEn ? 'View on Explorer' : 'Посмотреть в Explorer'}
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400 mt-2">
|
||||
{isEn ? 'Transaction ID:' : 'ID транзакции:'} {payment.transactionHash}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 bg-gray-50 dark:bg-gray-700/30 rounded-xl">
|
||||
<svg className="w-16 h-16 mx-auto text-gray-400 dark:text-gray-600 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{isEn ? 'No payment history yet' : 'История платежей пуста'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import apiClient from '../../utils/apiClient';
|
||||
import { API_URL } from '../../config/api';
|
||||
|
||||
interface IUser {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface ICheck {
|
||||
id: number;
|
||||
userId: number;
|
||||
amount: number;
|
||||
status: 'pending' | 'approved' | 'rejected';
|
||||
fileUrl: string;
|
||||
createdAt: string;
|
||||
user?: IUser;
|
||||
}
|
||||
|
||||
const CheckVerification: React.FC = () => {
|
||||
const [checks, setChecks] = useState<ICheck[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [actionLoading, setActionLoading] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string>('');
|
||||
|
||||
// Получить защищённый URL для файла чека
|
||||
const getCheckFileUrl = (fileUrl: string): string => {
|
||||
const filename = fileUrl.split('/').pop();
|
||||
return `${API_URL}/api/check/file/${filename}`;
|
||||
};
|
||||
|
||||
// Открыть изображение чека в новом окне с авторизацией
|
||||
const openCheckImage = async (fileUrl: string) => {
|
||||
try {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const url = getCheckFileUrl(fileUrl);
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Ошибка загрузки изображения');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
window.open(objectUrl, '_blank');
|
||||
} catch (error) {
|
||||
console.error('[CheckVerification] Ошибка загрузки изображения:', error);
|
||||
alert('Не удалось загрузить изображение чека');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchChecks = async (): Promise<void> => {
|
||||
console.log('[CheckVerification] Загрузка чеков для проверки...');
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await apiClient.get<ICheck[]>(`${API_URL}/api/check`);
|
||||
setChecks(res.data);
|
||||
console.log('[CheckVerification] Загружено чеков:', res.data.length);
|
||||
} catch (err) {
|
||||
console.error('[CheckVerification] Ошибка загрузки чеков:', err);
|
||||
setError('Ошибка загрузки чеков');
|
||||
setChecks([]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
fetchChecks();
|
||||
}, []);
|
||||
|
||||
const handleAction = async (checkId: number, action: 'approve' | 'reject'): Promise<void> => {
|
||||
console.log(`[CheckVerification] ${action === 'approve' ? 'Подтверждение' : 'Отклонение'} чека #${checkId}`);
|
||||
setActionLoading(checkId);
|
||||
setError('');
|
||||
try {
|
||||
await apiClient.post(`${API_URL}/api/check/${action}`, { checkId });
|
||||
|
||||
console.log(`[CheckVerification] Чек #${checkId} ${action === 'approve' ? 'подтверждён' : 'отклонён'}`);
|
||||
|
||||
setChecks((prevChecks: ICheck[]) =>
|
||||
prevChecks.map((c: ICheck) =>
|
||||
c.id === checkId ? { ...c, status: action === 'approve' ? 'approved' : 'rejected' } : c
|
||||
)
|
||||
);
|
||||
|
||||
// Если подтверждение — обновить баланс пользователя
|
||||
if (action === 'approve') {
|
||||
try {
|
||||
console.log('[CheckVerification] Обновление данных пользователя...');
|
||||
const userRes = await apiClient.get(`${API_URL}/api/auth/me`);
|
||||
|
||||
// Глобально обновить userData через типизированное событие (для Dashboard)
|
||||
window.dispatchEvent(new CustomEvent<import('./types').UserData>('userDataUpdate', {
|
||||
detail: {
|
||||
user: userRes.data.user,
|
||||
balance: userRes.data.user.balance ?? 0,
|
||||
tickets: userRes.data.user.tickets ?? [],
|
||||
}
|
||||
}));
|
||||
console.log('[CheckVerification] Данные пользователя обновлены');
|
||||
} catch (error) {
|
||||
console.error('[CheckVerification] Ошибка обновления userData:', error);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[CheckVerification] Ошибка ${action === 'approve' ? 'подтверждения' : 'отклонения'}:`, err);
|
||||
setError('Ошибка действия');
|
||||
}
|
||||
setActionLoading(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-white rounded-3xl shadow-xl max-w-4xl mx-auto">
|
||||
<h2 className="text-3xl font-bold text-gray-800 mb-6">Проверка чеков</h2>
|
||||
{loading ? (
|
||||
<p className="text-lg text-gray-500">Загрузка чеков...</p>
|
||||
) : error ? (
|
||||
<p className="text-lg text-red-500">{error}</p>
|
||||
) : checks.length === 0 ? (
|
||||
<p className="text-lg text-gray-500">Нет чеков для проверки.</p>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{checks.map((check: ICheck) => (
|
||||
<div key={check.id} className="border rounded-2xl p-6 flex flex-col md:flex-row items-center justify-between bg-gray-50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="mb-2">
|
||||
<span className="font-bold text-gray-800">Пользователь:</span> <span className="text-gray-700">{check.user?.username || check.user?.email}</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="font-bold text-gray-800">Сумма:</span> <span className="text-gray-700">₽{check.amount}</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="font-bold text-gray-800">Статус:</span> <span className={`font-bold ${check.status === 'pending' ? 'text-yellow-600' : check.status === 'approved' ? 'text-green-600' : 'text-red-600'}`}>{check.status === 'pending' ? 'На проверке' : check.status === 'approved' ? 'Подтверждён' : 'Отклонён'}</span>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<span className="font-bold text-gray-800">Дата:</span> <span className="text-gray-700">{new Date(check.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2 md:ml-8">
|
||||
<button
|
||||
onClick={() => openCheckImage(check.fileUrl)}
|
||||
className="block mb-2 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div className="w-32 h-32 flex items-center justify-center bg-gray-200 rounded-xl border">
|
||||
<span className="text-gray-600 text-sm text-center px-2">
|
||||
Нажмите для просмотра чека
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{check.status === 'pending' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleAction(check.id, 'approve')}
|
||||
disabled={actionLoading === check.id}
|
||||
className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-full font-bold mb-2"
|
||||
>
|
||||
{actionLoading === check.id ? 'Подтверждение...' : 'Подтвердить'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction(check.id, 'reject')}
|
||||
disabled={actionLoading === check.id}
|
||||
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-full font-bold"
|
||||
>
|
||||
{actionLoading === check.id ? 'Отклонение...' : 'Отклонить'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CheckVerification;
|
||||
@@ -12,7 +12,6 @@ import TicketsPage from './tickets/index';
|
||||
import Billing from './billing';
|
||||
import Settings from './settings';
|
||||
import NotificationsPage from './notifications';
|
||||
import CheckVerification from './checkverification';
|
||||
import Checkout from './checkout';
|
||||
import StoragePage from './storage';
|
||||
import StorageBucketPage from './storage-bucket';
|
||||
@@ -92,9 +91,6 @@ const Dashboard = () => {
|
||||
{ key: 'settings', label: isEn ? 'Settings' : 'Настройки', to: '/dashboard/settings' },
|
||||
{ key: 'notifications', label: isEn ? 'Notifications' : 'Уведомления', to: '/dashboard/notifications' },
|
||||
];
|
||||
const adminTabs = [
|
||||
{ key: 'checkverification', label: isEn ? 'Check Verification' : 'Проверка чеков', to: '/dashboard/checkverification' },
|
||||
];
|
||||
|
||||
const superAdminTabs = [
|
||||
{ key: 'admin', label: isEn ? 'Admin Panel' : 'Админ-панель', to: '/dashboard/admin' },
|
||||
@@ -159,27 +155,6 @@ const Dashboard = () => {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{isOperator && (
|
||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 px-4">
|
||||
{isEn ? 'Admin Panel' : 'Админ панель'}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{adminTabs.map(tab => (
|
||||
<Link
|
||||
key={tab.key}
|
||||
to={tab.to}
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={`flex items-center py-3 px-4 rounded-xl font-semibold transition-colors duration-200 ${
|
||||
activeTab === tab.key ? 'bg-ospab-primary text-white shadow-lg' : 'text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs font-semibold text-red-500 dark:text-red-400 uppercase tracking-wider mb-3 px-4">
|
||||
@@ -253,7 +228,7 @@ const Dashboard = () => {
|
||||
<div className="flex-1 flex flex-col w-full lg:w-auto">
|
||||
<div className="bg-white border-b border-gray-200 px-4 lg:px-8 py-4 pt-16 lg:pt-4">
|
||||
<h1 className="text-xl lg:text-2xl font-bold text-gray-900 capitalize break-words">
|
||||
{tabs.concat(adminTabs).concat(superAdminTabs).find(t => t.key === activeTab)?.label || (isEn ? 'Dashboard' : 'Панель управления')}
|
||||
{tabs.concat(superAdminTabs).find(t => t.key === activeTab)?.label || (isEn ? 'Dashboard' : 'Панель управления')}
|
||||
</h1>
|
||||
<p className="text-xs lg:text-sm text-gray-600 mt-1">
|
||||
{new Date().toLocaleDateString(isEn ? 'en-US' : 'ru-RU', {
|
||||
@@ -282,11 +257,6 @@ const Dashboard = () => {
|
||||
)}
|
||||
<Route path="settings" element={<Settings />} />
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
{isOperator && (
|
||||
<>
|
||||
<Route path="checkverification" element={<CheckVerification />} />
|
||||
</>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Route path="admin" element={<AdminPanel />} />
|
||||
|
||||
@@ -259,7 +259,7 @@ const RegisterPage = () => {
|
||||
<input
|
||||
type="checkbox"
|
||||
required
|
||||
className="mt-1 mr-2 h-4 w-4 text-ospab-primary focus:ring-ospab-primary border-gray-300 rounded"
|
||||
className="mt-0.5 mr-3 h-5 w-5 text-ospab-primary focus:ring-ospab-primary border-gray-300 rounded cursor-pointer"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<span className="text-sm text-gray-600">
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface Notification {
|
||||
message: string;
|
||||
serverId?: number;
|
||||
ticketId?: number;
|
||||
checkId?: number;
|
||||
actionUrl?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
|
||||
Reference in New Issue
Block a user