mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Merge pull request #105 from BEDOLAGA-DEV/feat/full-i18n-migration
feat/full i18n migration
This commit is contained in:
1
package-lock.json
generated
1
package-lock.json
generated
@@ -7,6 +7,7 @@
|
||||
"": {
|
||||
"name": "cabinet-frontend",
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
||||
@@ -139,22 +139,22 @@ export interface PromoOfferLogListResponse {
|
||||
|
||||
// Target segments for broadcast
|
||||
export const TARGET_SEGMENTS = {
|
||||
all: 'Все пользователи',
|
||||
active: 'Активные подписчики',
|
||||
trial: 'Триал пользователи',
|
||||
trial_ending: 'Заканчивается триал',
|
||||
expiring: 'Заканчивается подписка',
|
||||
expired: 'Истекшая подписка',
|
||||
zero: 'Нулевой баланс',
|
||||
autopay_failed: 'Ошибка автоплатежа',
|
||||
low_balance: 'Низкий баланс',
|
||||
inactive_30d: 'Неактивны 30 дней',
|
||||
inactive_60d: 'Неактивны 60 дней',
|
||||
inactive_90d: 'Неактивны 90 дней',
|
||||
custom_today: 'Зарегистрированы сегодня',
|
||||
custom_week: 'Зарегистрированы за неделю',
|
||||
custom_month: 'Зарегистрированы за месяц',
|
||||
custom_active_today: 'Активны сегодня',
|
||||
all: 'admin.promoOffers.segments.all',
|
||||
active: 'admin.promoOffers.segments.active',
|
||||
trial: 'admin.promoOffers.segments.trial',
|
||||
trial_ending: 'admin.promoOffers.segments.trialEnding',
|
||||
expiring: 'admin.promoOffers.segments.expiring',
|
||||
expired: 'admin.promoOffers.segments.expired',
|
||||
zero: 'admin.promoOffers.segments.zero',
|
||||
autopay_failed: 'admin.promoOffers.segments.autopayFailed',
|
||||
low_balance: 'admin.promoOffers.segments.lowBalance',
|
||||
inactive_30d: 'admin.promoOffers.segments.inactive30d',
|
||||
inactive_60d: 'admin.promoOffers.segments.inactive60d',
|
||||
inactive_90d: 'admin.promoOffers.segments.inactive90d',
|
||||
custom_today: 'admin.promoOffers.segments.customToday',
|
||||
custom_week: 'admin.promoOffers.segments.customWeek',
|
||||
custom_month: 'admin.promoOffers.segments.customMonth',
|
||||
custom_active_today: 'admin.promoOffers.segments.customActiveToday',
|
||||
} as const;
|
||||
|
||||
export type TargetSegment = keyof typeof TARGET_SEGMENTS;
|
||||
@@ -163,21 +163,21 @@ export type TargetSegment = keyof typeof TARGET_SEGMENTS;
|
||||
export const OFFER_TYPE_CONFIG = {
|
||||
test_access: {
|
||||
icon: '🧪',
|
||||
label: 'Тестовый доступ',
|
||||
labelKey: 'admin.promoOffers.offerType.testAccess',
|
||||
effect: 'test_access',
|
||||
description: 'Временный доступ к дополнительным серверам',
|
||||
descriptionKey: 'admin.promoOffers.offerType.testAccessDesc',
|
||||
},
|
||||
extend_discount: {
|
||||
icon: '💎',
|
||||
label: 'Скидка на продление',
|
||||
labelKey: 'admin.promoOffers.offerType.extendDiscount',
|
||||
effect: 'percent_discount',
|
||||
description: 'Скидка для текущих подписчиков',
|
||||
descriptionKey: 'admin.promoOffers.offerType.extendDiscountDesc',
|
||||
},
|
||||
purchase_discount: {
|
||||
icon: '🎯',
|
||||
label: 'Скидка на покупку',
|
||||
labelKey: 'admin.promoOffers.offerType.purchaseDiscount',
|
||||
effect: 'percent_discount',
|
||||
description: 'Скидка для новых пользователей',
|
||||
descriptionKey: 'admin.promoOffers.offerType.purchaseDiscountDesc',
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -453,7 +453,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
||||
<BackIcon />
|
||||
</button>
|
||||
<h2 className="text-lg font-bold text-dark-100">
|
||||
{t('subscription.connection.selectPlatform') || 'Выберите платформу'}
|
||||
{t('subscription.connection.selectPlatform')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="space-y-2 p-4">
|
||||
@@ -498,8 +498,8 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
||||
<span className="text-sm text-dark-400">
|
||||
{appCount}{' '}
|
||||
{appCount === 1
|
||||
? t('subscription.connection.app') || 'приложение'
|
||||
: t('subscription.connection.apps') || 'приложений'}
|
||||
? t('subscription.connection.app')
|
||||
: t('subscription.connection.apps')}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronIcon />
|
||||
@@ -623,9 +623,7 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
|
||||
</div>
|
||||
<div className="flex-1 text-left">
|
||||
<div className="font-medium text-dark-100">{selectedApp?.name}</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
{t('subscription.connection.changeApp') || 'Сменить приложение'}
|
||||
</div>
|
||||
<div className="text-sm text-dark-400">{t('subscription.connection.changeApp')}</div>
|
||||
</div>
|
||||
<ChevronIcon />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { promoApi, PromoOffer } from '../api/promo';
|
||||
|
||||
// Icons
|
||||
@@ -50,7 +51,10 @@ const ServerIcon = () => (
|
||||
);
|
||||
|
||||
// Helper functions
|
||||
const formatTimeLeft = (expiresAt: string): string => {
|
||||
const formatTimeLeft = (
|
||||
expiresAt: string,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): string => {
|
||||
const now = new Date();
|
||||
// Ensure UTC parsing - if no timezone specified, assume UTC
|
||||
let expires: Date;
|
||||
@@ -62,19 +66,19 @@ const formatTimeLeft = (expiresAt: string): string => {
|
||||
}
|
||||
const diffMs = expires.getTime() - now.getTime();
|
||||
|
||||
if (diffMs <= 0) return 'Истекло';
|
||||
if (diffMs <= 0) return t('promo.offers.expired');
|
||||
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 24) {
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} дн.`;
|
||||
return `${days} ${t('promo.time.days')}`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}ч ${minutes}м`;
|
||||
return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}`;
|
||||
}
|
||||
return `${minutes}м`;
|
||||
return `${minutes}${t('promo.time.minutes')}`;
|
||||
};
|
||||
|
||||
const getOfferIcon = (effectType: string) => {
|
||||
@@ -82,22 +86,30 @@ const getOfferIcon = (effectType: string) => {
|
||||
return <SparklesIcon />;
|
||||
};
|
||||
|
||||
const getOfferTitle = (offer: PromoOffer): string => {
|
||||
const getOfferTitle = (
|
||||
offer: PromoOffer,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): string => {
|
||||
if (offer.effect_type === 'test_access') {
|
||||
return 'Тестовый доступ';
|
||||
return t('promo.offers.testAccess');
|
||||
}
|
||||
if (offer.discount_percent) {
|
||||
return `Скидка ${offer.discount_percent}%`;
|
||||
return t('promo.offers.discountPercent', { percent: offer.discount_percent });
|
||||
}
|
||||
return 'Специальное предложение';
|
||||
return t('promo.offers.specialOffer');
|
||||
};
|
||||
|
||||
const getOfferDescription = (offer: PromoOffer): string => {
|
||||
const getOfferDescription = (
|
||||
offer: PromoOffer,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
): string => {
|
||||
if (offer.effect_type === 'test_access') {
|
||||
const squads = offer.extra_data?.test_squad_uuids?.length || 0;
|
||||
return squads > 0 ? `Доступ к ${squads} серверам` : 'Доступ к дополнительным серверам';
|
||||
return squads > 0
|
||||
? t('promo.offers.serverAccess', { count: squads })
|
||||
: t('promo.offers.additionalServers');
|
||||
}
|
||||
return 'Активируйте скидку на покупку подписки';
|
||||
return t('promo.offers.activateDiscountHint');
|
||||
};
|
||||
|
||||
interface PromoOffersSectionProps {
|
||||
@@ -105,6 +117,7 @@ interface PromoOffersSectionProps {
|
||||
}
|
||||
|
||||
export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [claimingId, setClaimingId] = useState<number | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
@@ -137,7 +150,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const axiosErr = error as { response?: { data?: { detail?: string } } };
|
||||
setErrorMessage(axiosErr.response?.data?.detail || 'Не удалось активировать предложение');
|
||||
setErrorMessage(axiosErr.response?.data?.detail || t('promo.offers.activationFailed'));
|
||||
setClaimingId(null);
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
},
|
||||
@@ -174,17 +187,23 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h3 className="font-semibold text-dark-100">
|
||||
Скидка {activeDiscount.discount_percent}% активна
|
||||
{t('promo.offers.discountActiveTitle', {
|
||||
percent: activeDiscount.discount_percent,
|
||||
})}
|
||||
</h3>
|
||||
<span className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
|
||||
Действует
|
||||
{t('promo.offers.statusActive')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-dark-400">
|
||||
{activeDiscount.expires_at && (
|
||||
<div className="flex items-center gap-1">
|
||||
<ClockIcon />
|
||||
<span>Истекает: {formatTimeLeft(activeDiscount.expires_at)}</span>
|
||||
<span>
|
||||
{t('promo.offers.expires', {
|
||||
time: formatTimeLeft(activeDiscount.expires_at, t),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -221,18 +240,20 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h3 className="font-semibold text-dark-100">{getOfferTitle(offer)}</h3>
|
||||
<h3 className="font-semibold text-dark-100">{getOfferTitle(offer, t)}</h3>
|
||||
{offer.effect_type === 'test_access' && (
|
||||
<span className="rounded bg-purple-500/20 px-2 py-0.5 text-xs text-purple-400">
|
||||
Тест
|
||||
{t('promo.offers.test')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-3 text-sm text-dark-400">{getOfferDescription(offer)}</p>
|
||||
<p className="mb-3 text-sm text-dark-400">{getOfferDescription(offer, t)}</p>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-1 text-xs text-dark-500">
|
||||
<ClockIcon />
|
||||
<span>Осталось: {formatTimeLeft(offer.expires_at)}</span>
|
||||
<span>
|
||||
{t('promo.offers.remaining', { time: formatTimeLeft(offer.expires_at, t) })}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleClaim(offer.id)}
|
||||
@@ -242,12 +263,12 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
||||
{claimingId === offer.id ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
<span>Активация...</span>
|
||||
<span>{t('promo.offers.activating')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GiftIcon />
|
||||
<span>Активировать</span>
|
||||
<span>{t('promo.offers.activate')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -204,11 +204,11 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
onSuccess: (data) => {
|
||||
const webApp = window.Telegram?.WebApp;
|
||||
if (!data.invoice_url) {
|
||||
setError('Сервер не вернул ссылку на оплату');
|
||||
setError(t('balance.errors.noPaymentLink'));
|
||||
return;
|
||||
}
|
||||
if (!webApp?.openInvoice) {
|
||||
setError('Оплата Stars доступна только в Telegram Mini App');
|
||||
setError(t('balance.errors.starsOnlyInTelegram'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -221,12 +221,12 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
setError('Ошибка: ' + String(e));
|
||||
setError(t('balance.errors.generic', { details: String(e) }));
|
||||
}
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const axiosError = err as { response?: { data?: { detail?: string }; status?: number } };
|
||||
setError(`Ошибка: ${axiosError?.response?.data?.detail || 'Не удалось создать счёт'}`);
|
||||
setError(axiosError?.response?.data?.detail || t('balance.errors.invoiceFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -268,21 +268,23 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
inputRef.current?.blur();
|
||||
|
||||
if (!checkRateLimit(RATE_LIMIT_KEYS.PAYMENT, 3, 30000)) {
|
||||
setError('Подождите ' + getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) + ' сек.');
|
||||
setError(
|
||||
t('balance.errors.rateLimit', { seconds: getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (hasOptions && !selectedOption) {
|
||||
setError('Выберите способ');
|
||||
setError(t('balance.errors.selectMethod'));
|
||||
return;
|
||||
}
|
||||
const amountCurrency = parseFloat(amount);
|
||||
if (isNaN(amountCurrency) || amountCurrency <= 0) {
|
||||
setError('Введите сумму');
|
||||
setError(t('balance.errors.enterAmount'));
|
||||
return;
|
||||
}
|
||||
const amountRubles = convertToRub(amountCurrency);
|
||||
if (amountRubles < minRubles || amountRubles > maxRubles) {
|
||||
setError(`Сумма: ${minRubles} – ${maxRubles} ₽`);
|
||||
setError(t('balance.errors.amountRange', { min: minRubles, max: maxRubles }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -498,17 +500,10 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
<div className="space-y-3 rounded-2xl border border-success-500/20 bg-success-500/10 p-4">
|
||||
<div className="flex items-center gap-2 text-success-400">
|
||||
<CheckIcon />
|
||||
<span className="font-semibold">
|
||||
{t('balance.paymentReady', 'Ссылка на оплату готова')}
|
||||
</span>
|
||||
<span className="font-semibold">{t('balance.paymentReady')}</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-dark-400">
|
||||
{t(
|
||||
'balance.clickToOpenPayment',
|
||||
'Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке',
|
||||
)}
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">{t('balance.clickToOpenPayment')}</p>
|
||||
|
||||
{/* Main open button - NO preventDefault, let <a> work natively for iOS Safari */}
|
||||
<a
|
||||
@@ -518,7 +513,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-success-500 font-bold text-white transition-colors hover:bg-success-400 active:bg-success-600"
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
<span>{t('balance.openPaymentPage', 'Открыть страницу оплаты')}</span>
|
||||
<span>{t('balance.openPaymentPage')}</span>
|
||||
</a>
|
||||
|
||||
{/* Copy and link display */}
|
||||
@@ -534,7 +529,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-dark-800/70 text-dark-400 hover:bg-dark-700 hover:text-dark-200'
|
||||
}`}
|
||||
title={t('common.copy', 'Копировать')}
|
||||
title={t('common.copy')}
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SettingDefinition } from '../../api/adminSettings';
|
||||
import { CheckIcon, CloseIcon, EditIcon } from './icons';
|
||||
|
||||
@@ -32,6 +33,7 @@ function isListOrJsonKey(key: string): boolean {
|
||||
}
|
||||
|
||||
export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -95,24 +97,24 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSave();
|
||||
}}
|
||||
autoFocus
|
||||
placeholder="Введите значение..."
|
||||
placeholder={t('admin.settings.inputPlaceholder')}
|
||||
className="min-h-[100px] w-full resize-none rounded-xl border border-accent-500 bg-dark-700 px-4 py-3 font-mono text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-dark-500">Ctrl+Enter для сохранения</span>
|
||||
<span className="text-xs text-dark-500">{t('admin.settings.ctrlEnterHint')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="rounded-lg bg-dark-600 px-3 py-1.5 text-sm text-dark-300 transition-colors hover:bg-dark-500"
|
||||
>
|
||||
Отмена
|
||||
{t('admin.settings.cancelButton')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-accent-500 px-3 py-1.5 text-sm text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<CheckIcon />
|
||||
Сохранить
|
||||
{t('admin.settings.saveButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,20 +136,20 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
if (e.key === 'Escape') handleCancel();
|
||||
}}
|
||||
autoFocus
|
||||
placeholder="Введите значение..."
|
||||
placeholder={t('admin.settings.inputPlaceholder')}
|
||||
className="w-48 rounded-lg border border-accent-500 bg-dark-700 px-3 py-2 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 sm:w-56"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="rounded-lg bg-accent-500 p-2 text-white transition-colors hover:bg-accent-600"
|
||||
title="Сохранить (Enter)"
|
||||
title={t('admin.settings.saveHint')}
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="rounded-lg bg-dark-600 p-2 text-dark-300 transition-colors hover:bg-dark-500"
|
||||
title="Отмена (Esc)"
|
||||
title={t('admin.settings.cancelHint')}
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
|
||||
@@ -79,7 +79,11 @@ export function SettingRow({
|
||||
? 'bg-warning-500/15 text-warning-400 hover:bg-warning-500/25'
|
||||
: 'text-dark-500 opacity-0 hover:bg-dark-700/50 hover:text-warning-400 group-hover:opacity-100'
|
||||
}`}
|
||||
title={isFavorite ? 'Убрать из избранного' : 'В избранное'}
|
||||
title={
|
||||
isFavorite
|
||||
? t('admin.settings.removeFromFavorites')
|
||||
: t('admin.settings.addToFavorites')
|
||||
}
|
||||
>
|
||||
<StarIcon filled={isFavorite} />
|
||||
</button>
|
||||
@@ -105,7 +109,9 @@ export function SettingRow({
|
||||
// Boolean toggle
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-dark-400">
|
||||
{setting.current === true || setting.current === 'true' ? 'Включено' : 'Выключено'}
|
||||
{setting.current === true || setting.current === 'true'
|
||||
? t('admin.settings.enabled')
|
||||
: t('admin.settings.disabled')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
@@ -161,7 +167,7 @@ export function SettingRow({
|
||||
title={t('admin.settings.reset')}
|
||||
>
|
||||
<RefreshIcon />
|
||||
<span>Сбросить</span>
|
||||
<span>{t('admin.settings.reset')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -74,14 +74,20 @@ export function SettingsSearchResults({
|
||||
searchQuery: string;
|
||||
resultsCount: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!searchQuery.trim()) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex items-center gap-2 text-sm">
|
||||
<span className="text-dark-400">
|
||||
{resultsCount > 0 ? `Найдено: ${resultsCount}` : 'Ничего не найдено'}
|
||||
{resultsCount > 0
|
||||
? t('admin.settings.foundCount', { count: resultsCount })
|
||||
: t('admin.settings.notFound')}
|
||||
</span>
|
||||
{resultsCount > 0 && <span className="text-dark-500">по запросу «{searchQuery}»</span>}
|
||||
{resultsCount > 0 && (
|
||||
<span className="text-dark-500">{t('admin.settings.byQuery', { query: searchQuery })}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,10 +56,10 @@ export default function ChannelSubscriptionScreen() {
|
||||
error.response?.status === 403 &&
|
||||
error.response?.data?.detail?.code === 'channel_subscription_required'
|
||||
) {
|
||||
setError(t('blocking.channel.notSubscribed', 'Вы ещё не подписались на канал'));
|
||||
setError(t('blocking.channel.notSubscribed'));
|
||||
} else {
|
||||
// Other error - might be network issue
|
||||
setError(t('blocking.channel.checkError', 'Ошибка проверки. Попробуйте позже.'));
|
||||
setError(t('blocking.channel.checkError'));
|
||||
}
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
@@ -80,14 +80,11 @@ export default function ChannelSubscriptionScreen() {
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="mb-4 text-2xl font-bold text-white">
|
||||
{t('blocking.channel.title', 'Подписка на канал')}
|
||||
</h1>
|
||||
<h1 className="mb-4 text-2xl font-bold text-white">{t('blocking.channel.title')}</h1>
|
||||
|
||||
{/* Message */}
|
||||
<p className="mb-8 text-lg text-gray-400">
|
||||
{channelInfo?.message ||
|
||||
t('blocking.channel.defaultMessage', 'Для продолжения работы подпишитесь на наш канал')}
|
||||
{channelInfo?.message || t('blocking.channel.defaultMessage')}
|
||||
</p>
|
||||
|
||||
{/* Error message */}
|
||||
@@ -108,7 +105,7 @@ export default function ChannelSubscriptionScreen() {
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
{t('blocking.channel.openChannel', 'Открыть канал')}
|
||||
{t('blocking.channel.openChannel')}
|
||||
</button>
|
||||
|
||||
{/* Check subscription button */}
|
||||
@@ -139,7 +136,7 @@ export default function ChannelSubscriptionScreen() {
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{t('blocking.channel.checking', 'Проверяем...')}
|
||||
{t('blocking.channel.checking')}
|
||||
</>
|
||||
) : cooldown > 0 ? (
|
||||
<>
|
||||
@@ -156,7 +153,7 @@ export default function ChannelSubscriptionScreen() {
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{t('blocking.channel.waitSeconds', 'Подождите {{seconds}} сек.', {
|
||||
{t('blocking.channel.waitSeconds', {
|
||||
seconds: cooldown,
|
||||
})}
|
||||
</>
|
||||
@@ -170,16 +167,14 @@ export default function ChannelSubscriptionScreen() {
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{t('blocking.channel.checkSubscription', 'Проверить подписку')}
|
||||
{t('blocking.channel.checkSubscription')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hint */}
|
||||
<p className="mt-6 text-sm text-gray-500">
|
||||
{t('blocking.channel.hint', 'После подписки нажмите кнопку проверки')}
|
||||
</p>
|
||||
<p className="mt-6 text-sm text-gray-500">{t('blocking.channel.hint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -28,25 +28,17 @@ export default function MaintenanceScreen() {
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="mb-4 text-2xl font-bold text-white">
|
||||
{t('blocking.maintenance.title', 'Технические работы')}
|
||||
</h1>
|
||||
<h1 className="mb-4 text-2xl font-bold text-white">{t('blocking.maintenance.title')}</h1>
|
||||
|
||||
{/* Message */}
|
||||
<p className="mb-6 text-lg text-gray-400">
|
||||
{maintenanceInfo?.message ||
|
||||
t(
|
||||
'blocking.maintenance.defaultMessage',
|
||||
'Сервис временно недоступен. Проводятся технические работы.',
|
||||
)}
|
||||
{maintenanceInfo?.message || t('blocking.maintenance.defaultMessage')}
|
||||
</p>
|
||||
|
||||
{/* Reason */}
|
||||
{maintenanceInfo?.reason && (
|
||||
<div className="mb-6 rounded-xl bg-dark-800/50 p-4">
|
||||
<p className="mb-1 text-sm text-gray-500">
|
||||
{t('blocking.maintenance.reason', 'Причина')}:
|
||||
</p>
|
||||
<p className="mb-1 text-sm text-gray-500">{t('blocking.maintenance.reason')}:</p>
|
||||
<p className="text-gray-300">{maintenanceInfo.reason}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -67,9 +59,7 @@ export default function MaintenanceScreen() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-sm text-gray-500">
|
||||
{t('blocking.maintenance.waitMessage', 'Пожалуйста, подождите...')}
|
||||
</p>
|
||||
<p className="mt-4 text-sm text-gray-500">{t('blocking.maintenance.waitMessage')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1125
src/locales/fa.json
1125
src/locales/fa.json
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1123
src/locales/zh.json
1123
src/locales/zh.json
File diff suppressed because it is too large
Load Diff
@@ -514,7 +514,7 @@ export default function AdminBanSystem() {
|
||||
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
{t('banSystem.contactTelegram', { defaultValue: 'Написать в Telegram' })}
|
||||
{t('banSystem.contactTelegram')}
|
||||
</a>
|
||||
|
||||
{/* Back Button */}
|
||||
@@ -535,7 +535,7 @@ export default function AdminBanSystem() {
|
||||
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
|
||||
/>
|
||||
</svg>
|
||||
{t('common.back', { defaultValue: 'Назад' })}
|
||||
{t('common.back')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,33 +112,59 @@ const ChevronDownIcon = () => (
|
||||
);
|
||||
|
||||
// Status badge component
|
||||
const statusConfig: Record<string, { bg: string; text: string; labelKey: string }> = {
|
||||
queued: {
|
||||
bg: 'bg-yellow-500/20',
|
||||
text: 'text-yellow-400',
|
||||
labelKey: 'admin.broadcasts.status.queued',
|
||||
},
|
||||
in_progress: {
|
||||
bg: 'bg-blue-500/20',
|
||||
text: 'text-blue-400',
|
||||
labelKey: 'admin.broadcasts.status.inProgress',
|
||||
},
|
||||
completed: {
|
||||
bg: 'bg-green-500/20',
|
||||
text: 'text-green-400',
|
||||
labelKey: 'admin.broadcasts.status.completed',
|
||||
},
|
||||
partial: {
|
||||
bg: 'bg-orange-500/20',
|
||||
text: 'text-orange-400',
|
||||
labelKey: 'admin.broadcasts.status.partial',
|
||||
},
|
||||
failed: { bg: 'bg-red-500/20', text: 'text-red-400', labelKey: 'admin.broadcasts.status.failed' },
|
||||
cancelled: {
|
||||
bg: 'bg-gray-500/20',
|
||||
text: 'text-gray-400',
|
||||
labelKey: 'admin.broadcasts.status.cancelled',
|
||||
},
|
||||
cancelling: {
|
||||
bg: 'bg-yellow-500/20',
|
||||
text: 'text-yellow-400',
|
||||
labelKey: 'admin.broadcasts.status.cancelling',
|
||||
},
|
||||
};
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const statusConfig: Record<string, { bg: string; text: string; label: string }> = {
|
||||
queued: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'В очереди' },
|
||||
in_progress: { bg: 'bg-blue-500/20', text: 'text-blue-400', label: 'Отправляется' },
|
||||
completed: { bg: 'bg-green-500/20', text: 'text-green-400', label: 'Завершено' },
|
||||
partial: { bg: 'bg-orange-500/20', text: 'text-orange-400', label: 'Частично' },
|
||||
failed: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Ошибка' },
|
||||
cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-400', label: 'Отменено' },
|
||||
cancelling: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'Отменяется' },
|
||||
};
|
||||
const { t } = useTranslation();
|
||||
const config = statusConfig[status] || statusConfig.queued;
|
||||
return (
|
||||
<span className={`rounded-full px-2 py-1 text-xs font-medium ${config.bg} ${config.text}`}>
|
||||
{config.label}
|
||||
{t(config.labelKey)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Filter labels
|
||||
const FILTER_GROUP_LABELS: Record<string, string> = {
|
||||
basic: 'Основные',
|
||||
subscription: 'По подписке',
|
||||
traffic: 'По трафику',
|
||||
registration: 'По регистрации',
|
||||
activity: 'По активности',
|
||||
source: 'По источнику',
|
||||
tariff: 'По тарифу',
|
||||
// Filter labels (labelKey pattern: store i18n keys, resolve at render time with t())
|
||||
const FILTER_GROUP_LABEL_KEYS: Record<string, string> = {
|
||||
basic: 'admin.broadcasts.filterGroups.basic',
|
||||
subscription: 'admin.broadcasts.filterGroups.subscription',
|
||||
traffic: 'admin.broadcasts.filterGroups.traffic',
|
||||
registration: 'admin.broadcasts.filterGroups.registration',
|
||||
activity: 'admin.broadcasts.filterGroups.activity',
|
||||
source: 'admin.broadcasts.filterGroups.source',
|
||||
tariff: 'admin.broadcasts.filterGroups.tariff',
|
||||
};
|
||||
|
||||
// Create broadcast modal
|
||||
@@ -351,7 +377,9 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
||||
Object.entries(groupedFilters).map(([group, filters]) => (
|
||||
<div key={group}>
|
||||
<div className="sticky top-0 bg-dark-800 px-3 py-2 text-xs font-medium text-dark-400">
|
||||
{FILTER_GROUP_LABELS[group] || group}
|
||||
{FILTER_GROUP_LABEL_KEYS[group]
|
||||
? t(FILTER_GROUP_LABEL_KEYS[group])
|
||||
: group}
|
||||
</div>
|
||||
{filters.map((filter) => (
|
||||
<button
|
||||
@@ -756,7 +784,7 @@ export default function AdminBroadcasts() {
|
||||
disabled={page === 0}
|
||||
className="rounded-lg bg-dark-700 px-3 py-1 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('common.prev')}
|
||||
{t('admin.broadcasts.prev')}
|
||||
</button>
|
||||
<span className="text-dark-400">
|
||||
{page + 1} / {totalPages}
|
||||
@@ -766,7 +794,7 @@ export default function AdminBroadcasts() {
|
||||
disabled={page >= totalPages - 1}
|
||||
className="rounded-lg bg-dark-700 px-3 py-1 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('common.next')}
|
||||
{t('admin.broadcasts.next')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import i18n from '../i18n';
|
||||
import {
|
||||
campaignsApi,
|
||||
CampaignListItem,
|
||||
@@ -108,23 +110,47 @@ const CopyIcon = () => (
|
||||
// Bonus type labels and colors
|
||||
const bonusTypeConfig: Record<
|
||||
CampaignBonusType,
|
||||
{ label: string; color: string; bgColor: string }
|
||||
{ labelKey: string; color: string; bgColor: string }
|
||||
> = {
|
||||
balance: { label: 'Баланс', color: 'text-emerald-400', bgColor: 'bg-emerald-500/20' },
|
||||
subscription: { label: 'Подписка', color: 'text-blue-400', bgColor: 'bg-blue-500/20' },
|
||||
tariff: { label: 'Тариф', color: 'text-purple-400', bgColor: 'bg-purple-500/20' },
|
||||
none: { label: 'Только ссылка', color: 'text-gray-400', bgColor: 'bg-gray-500/20' },
|
||||
balance: {
|
||||
labelKey: 'admin.campaigns.bonusType.balance',
|
||||
color: 'text-emerald-400',
|
||||
bgColor: 'bg-emerald-500/20',
|
||||
},
|
||||
subscription: {
|
||||
labelKey: 'admin.campaigns.bonusType.subscription',
|
||||
color: 'text-blue-400',
|
||||
bgColor: 'bg-blue-500/20',
|
||||
},
|
||||
tariff: {
|
||||
labelKey: 'admin.campaigns.bonusType.tariff',
|
||||
color: 'text-purple-400',
|
||||
bgColor: 'bg-purple-500/20',
|
||||
},
|
||||
none: {
|
||||
labelKey: 'admin.campaigns.bonusType.none',
|
||||
color: 'text-gray-400',
|
||||
bgColor: 'bg-gray-500/20',
|
||||
},
|
||||
};
|
||||
|
||||
// Locale mapping for formatting
|
||||
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
||||
|
||||
// Format number as rubles
|
||||
const formatRubles = (kopeks: number) =>
|
||||
(kopeks / 100).toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 2 }) +
|
||||
' ₽';
|
||||
const formatRubles = (kopeks: number) => {
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
return (
|
||||
(kopeks / 100).toLocaleString(locale, { minimumFractionDigits: 0, maximumFractionDigits: 2 }) +
|
||||
' ₽'
|
||||
);
|
||||
};
|
||||
|
||||
// Format date
|
||||
const formatDate = (dateStr: string | null) => {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString('ru-RU', {
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
return new Date(dateStr).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
@@ -151,6 +177,7 @@ function CampaignModal({
|
||||
onClose,
|
||||
isLoading,
|
||||
}: CampaignModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = !!campaign;
|
||||
|
||||
const [name, setName] = useState(campaign?.name || '');
|
||||
@@ -218,7 +245,7 @@ function CampaignModal({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{isEdit ? 'Редактирование кампании' : 'Новая кампания'}
|
||||
{isEdit ? t('admin.campaigns.modal.editTitle') : t('admin.campaigns.modal.createTitle')}
|
||||
</h2>
|
||||
<button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
|
||||
<XIcon />
|
||||
@@ -229,19 +256,23 @@ function CampaignModal({
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Название</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.campaigns.form.name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
placeholder="Например: Instagram Реклама"
|
||||
placeholder={t('admin.campaigns.form.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Start Parameter */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Метка (start параметр)</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.campaigns.form.startParameter')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={startParameter}
|
||||
@@ -249,12 +280,16 @@ function CampaignModal({
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
placeholder="instagram_jan2024"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">Только латиница, цифры, _ и -</p>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('admin.campaigns.form.startParameterHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bonus Type */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm text-dark-300">Тип бонуса</label>
|
||||
<label className="mb-2 block text-sm text-dark-300">
|
||||
{t('admin.campaigns.form.bonusType')}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(Object.keys(bonusTypeConfig) as CampaignBonusType[]).map((type) => (
|
||||
<button
|
||||
@@ -267,7 +302,7 @@ function CampaignModal({
|
||||
: 'border border-dark-600 bg-dark-700 text-dark-300 hover:border-dark-500'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm font-medium">{bonusTypeConfig[type].label}</span>
|
||||
<span className="text-sm font-medium">{t(bonusTypeConfig[type].labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -277,7 +312,7 @@ function CampaignModal({
|
||||
{bonusType === 'balance' && (
|
||||
<div className="rounded-lg border border-emerald-500/30 bg-emerald-500/10 p-4">
|
||||
<label className="mb-2 block text-sm font-medium text-emerald-400">
|
||||
Бонус на баланс
|
||||
{t('admin.campaigns.form.balanceBonus')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
@@ -297,10 +332,14 @@ function CampaignModal({
|
||||
|
||||
{bonusType === 'subscription' && (
|
||||
<div className="space-y-3 rounded-lg border border-blue-500/30 bg-blue-500/10 p-4">
|
||||
<label className="block text-sm font-medium text-blue-400">Пробная подписка</label>
|
||||
<label className="block text-sm font-medium text-blue-400">
|
||||
{t('admin.campaigns.form.trialSubscription')}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">Дней</label>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.campaigns.form.days')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={subscriptionDays}
|
||||
@@ -312,7 +351,9 @@ function CampaignModal({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">Трафик (ГБ)</label>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.campaigns.form.trafficGb')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={subscriptionTraffic}
|
||||
@@ -324,7 +365,9 @@ function CampaignModal({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">Устройств</label>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.campaigns.form.devices')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={subscriptionDevices}
|
||||
@@ -338,7 +381,9 @@ function CampaignModal({
|
||||
</div>
|
||||
{servers.length > 0 && (
|
||||
<div>
|
||||
<label className="mb-2 block text-xs text-dark-500">Серверы</label>
|
||||
<label className="mb-2 block text-xs text-dark-500">
|
||||
{t('admin.campaigns.form.servers')}
|
||||
</label>
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto">
|
||||
{servers.map((server) => (
|
||||
<button
|
||||
@@ -371,24 +416,35 @@ function CampaignModal({
|
||||
|
||||
{bonusType === 'tariff' && (
|
||||
<div className="space-y-3 rounded-lg border border-purple-500/30 bg-purple-500/10 p-4">
|
||||
<label className="block text-sm font-medium text-purple-400">Тариф</label>
|
||||
<label className="block text-sm font-medium text-purple-400">
|
||||
{t('admin.campaigns.form.tariff')}
|
||||
</label>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">Выберите тариф</label>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.campaigns.form.selectTariff')}
|
||||
</label>
|
||||
<select
|
||||
value={tariffId || ''}
|
||||
onChange={(e) => setTariffId(e.target.value ? parseInt(e.target.value) : null)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-purple-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Не выбран</option>
|
||||
<option value="">{t('admin.campaigns.form.notSelected')}</option>
|
||||
{tariffs.map((tariff) => (
|
||||
<option key={tariff.id} value={tariff.id}>
|
||||
{tariff.name} ({tariff.traffic_limit_gb} ГБ, {tariff.device_limit} уст.)
|
||||
{tariff.name} (
|
||||
{t('admin.campaigns.form.tariffOption', {
|
||||
traffic: tariff.traffic_limit_gb,
|
||||
devices: tariff.device_limit,
|
||||
})}
|
||||
)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">Длительность (дней)</label>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.campaigns.form.durationDays')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={tariffDays}
|
||||
@@ -403,14 +459,14 @@ function CampaignModal({
|
||||
{bonusType === 'none' && (
|
||||
<div className="rounded-lg border border-gray-500/30 bg-gray-500/10 p-4">
|
||||
<p className="text-sm text-dark-400">
|
||||
Кампания без бонусов - только для отслеживания переходов и регистраций.
|
||||
{t('admin.campaigns.form.noBonusDescription')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active toggle */}
|
||||
<div className="flex items-center justify-between rounded-lg bg-dark-700 p-3">
|
||||
<span className="text-sm text-dark-300">Активна</span>
|
||||
<span className="text-sm text-dark-300">{t('admin.campaigns.form.active')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsActive(!isActive)}
|
||||
@@ -433,14 +489,14 @@ function CampaignModal({
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
Отмена
|
||||
{t('admin.campaigns.form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid || isLoading}
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Сохранение...' : 'Сохранить'}
|
||||
{isLoading ? t('admin.campaigns.form.saving') : t('admin.campaigns.form.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -456,6 +512,7 @@ interface StatsModalProps {
|
||||
}
|
||||
|
||||
function StatsModal({ stats, onClose, onViewUsers }: StatsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyLink = () => {
|
||||
@@ -477,15 +534,15 @@ function StatsModal({ stats, onClose, onViewUsers }: StatsModalProps) {
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${bonusTypeConfig[stats.bonus_type].bgColor} ${bonusTypeConfig[stats.bonus_type].color}`}
|
||||
>
|
||||
{bonusTypeConfig[stats.bonus_type].label}
|
||||
{t(bonusTypeConfig[stats.bonus_type].labelKey)}
|
||||
</span>
|
||||
{stats.is_active ? (
|
||||
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
||||
Активна
|
||||
{t('admin.campaigns.stats.active')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||
Неактивна
|
||||
{t('admin.campaigns.stats.inactive')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -510,7 +567,9 @@ function StatsModal({ stats, onClose, onViewUsers }: StatsModalProps) {
|
||||
className="flex shrink-0 items-center gap-1 rounded-lg bg-dark-600 px-3 py-1.5 text-dark-300 transition-colors hover:bg-dark-500"
|
||||
>
|
||||
<CopyIcon />
|
||||
<span className="text-sm">{copied ? 'Скопировано!' : 'Копировать'}</span>
|
||||
<span className="text-sm">
|
||||
{copied ? t('admin.campaigns.stats.copied') : t('admin.campaigns.stats.copy')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -520,28 +579,32 @@ function StatsModal({ stats, onClose, onViewUsers }: StatsModalProps) {
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-lg bg-dark-700 p-3 text-center">
|
||||
<div className="text-2xl font-bold text-dark-100">{stats.registrations}</div>
|
||||
<div className="text-xs text-dark-500">Регистраций</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.campaigns.stats.registrations')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700 p-3 text-center">
|
||||
<div className="text-2xl font-bold text-emerald-400">
|
||||
{formatRubles(stats.total_revenue_kopeks)}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">Доход</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.revenue')}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700 p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-400">{stats.paid_users_count}</div>
|
||||
<div className="text-xs text-dark-500">Оплатили</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.paidUsers')}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700 p-3 text-center">
|
||||
<div className="text-2xl font-bold text-purple-400">{stats.conversion_rate}%</div>
|
||||
<div className="text-xs text-dark-500">Конверсия</div>
|
||||
<div className="text-xs text-dark-500">{t('admin.campaigns.stats.conversion')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Stats */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-2 text-sm text-dark-400">Бонусы выданы</div>
|
||||
<div className="mb-2 text-sm text-dark-400">
|
||||
{t('admin.campaigns.stats.bonusesIssued')}
|
||||
</div>
|
||||
{stats.bonus_type === 'balance' && (
|
||||
<div className="text-lg font-medium text-emerald-400">
|
||||
{formatRubles(stats.balance_issued_kopeks)}
|
||||
@@ -549,12 +612,14 @@ function StatsModal({ stats, onClose, onViewUsers }: StatsModalProps) {
|
||||
)}
|
||||
{stats.bonus_type === 'subscription' && (
|
||||
<div className="text-lg font-medium text-blue-400">
|
||||
{stats.subscription_issued} подписок
|
||||
{t('admin.campaigns.stats.subscriptionsIssued', {
|
||||
count: stats.subscription_issued,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{stats.bonus_type === 'tariff' && (
|
||||
<div className="text-lg font-medium text-purple-400">
|
||||
{stats.subscription_issued} тарифов
|
||||
{t('admin.campaigns.stats.tariffsIssued', { count: stats.subscription_issued })}
|
||||
</div>
|
||||
)}
|
||||
{stats.bonus_type === 'none' && (
|
||||
@@ -562,31 +627,44 @@ function StatsModal({ stats, onClose, onViewUsers }: StatsModalProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-2 text-sm text-dark-400">Средний доход с пользователя</div>
|
||||
<div className="mb-2 text-sm text-dark-400">
|
||||
{t('admin.campaigns.stats.avgRevenuePerUser')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-dark-200">
|
||||
{formatRubles(stats.avg_revenue_per_user_kopeks)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-2 text-sm text-dark-400">Средний первый платёж</div>
|
||||
<div className="mb-2 text-sm text-dark-400">
|
||||
{t('admin.campaigns.stats.avgFirstPayment')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-dark-200">
|
||||
{formatRubles(stats.avg_first_payment_kopeks)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-2 text-sm text-dark-400">Пробных подписок</div>
|
||||
<div className="mb-2 text-sm text-dark-400">
|
||||
{t('admin.campaigns.stats.trialSubscriptions')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-dark-200">
|
||||
{stats.trial_users_count} (активных: {stats.active_trials_count})
|
||||
{t('admin.campaigns.stats.trialCount', {
|
||||
total: stats.trial_users_count,
|
||||
active: stats.active_trials_count,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-2 text-sm text-dark-400">Конверсия из триала</div>
|
||||
<div className="mb-2 text-sm text-dark-400">
|
||||
{t('admin.campaigns.stats.trialConversion')}
|
||||
</div>
|
||||
<div className="text-lg font-medium text-dark-200">
|
||||
{stats.trial_conversion_rate}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-dark-700/50 p-3">
|
||||
<div className="mb-2 text-sm text-dark-400">Последняя регистрация</div>
|
||||
<div className="mb-2 text-sm text-dark-400">
|
||||
{t('admin.campaigns.stats.lastRegistration')}
|
||||
</div>
|
||||
<div className="text-sm font-medium text-dark-200">
|
||||
{formatDate(stats.last_registration)}
|
||||
</div>
|
||||
@@ -601,13 +679,13 @@ function StatsModal({ stats, onClose, onViewUsers }: StatsModalProps) {
|
||||
className="flex items-center gap-2 rounded-lg bg-dark-700 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<UsersIcon />
|
||||
Пользователи
|
||||
{t('admin.campaigns.stats.users')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
Закрыть
|
||||
{t('admin.campaigns.modal.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -623,6 +701,7 @@ interface UsersModalProps {
|
||||
}
|
||||
|
||||
function UsersModal({ campaignId, campaignName, onClose }: UsersModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [page, setPage] = useState(1);
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
@@ -640,9 +719,14 @@ function UsersModal({ campaignId, campaignName, onClose }: UsersModalProps) {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">Пользователи кампании</h2>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.campaigns.users.title')}
|
||||
</h2>
|
||||
<p className="text-sm text-dark-400">
|
||||
{campaignName} - {total} регистраций
|
||||
{t('admin.campaigns.users.campaignRegistrations', {
|
||||
name: campaignName,
|
||||
count: total,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
|
||||
@@ -658,17 +742,27 @@ function UsersModal({ campaignId, campaignName, onClose }: UsersModalProps) {
|
||||
</div>
|
||||
) : registrations.length === 0 ? (
|
||||
<div className="py-12 text-center text-dark-400">
|
||||
Нет зарегистрированных пользователей
|
||||
{t('admin.campaigns.users.noUsers')}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full">
|
||||
<thead className="sticky top-0 bg-dark-700">
|
||||
<tr>
|
||||
<th className="p-3 text-left text-xs font-medium text-dark-400">Пользователь</th>
|
||||
<th className="p-3 text-left text-xs font-medium text-dark-400">Бонус</th>
|
||||
<th className="p-3 text-left text-xs font-medium text-dark-400">Баланс</th>
|
||||
<th className="p-3 text-left text-xs font-medium text-dark-400">Статус</th>
|
||||
<th className="p-3 text-left text-xs font-medium text-dark-400">Дата</th>
|
||||
<th className="p-3 text-left text-xs font-medium text-dark-400">
|
||||
{t('admin.campaigns.users.user')}
|
||||
</th>
|
||||
<th className="p-3 text-left text-xs font-medium text-dark-400">
|
||||
{t('admin.campaigns.users.bonus')}
|
||||
</th>
|
||||
<th className="p-3 text-left text-xs font-medium text-dark-400">
|
||||
{t('admin.campaigns.users.balance')}
|
||||
</th>
|
||||
<th className="p-3 text-left text-xs font-medium text-dark-400">
|
||||
{t('admin.campaigns.users.status')}
|
||||
</th>
|
||||
<th className="p-3 text-left text-xs font-medium text-dark-400">
|
||||
{t('admin.campaigns.users.date')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-dark-700">
|
||||
@@ -676,7 +770,7 @@ function UsersModal({ campaignId, campaignName, onClose }: UsersModalProps) {
|
||||
<tr key={reg.id} className="hover:bg-dark-700/50">
|
||||
<td className="p-3">
|
||||
<div className="text-sm text-dark-100">
|
||||
{reg.first_name || 'Без имени'}
|
||||
{reg.first_name || t('admin.campaigns.users.noName')}
|
||||
{reg.username && (
|
||||
<span className="ml-1 text-dark-400">@{reg.username}</span>
|
||||
)}
|
||||
@@ -687,8 +781,9 @@ function UsersModal({ campaignId, campaignName, onClose }: UsersModalProps) {
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${bonusTypeConfig[reg.bonus_type as CampaignBonusType]?.bgColor || 'bg-dark-600'} ${bonusTypeConfig[reg.bonus_type as CampaignBonusType]?.color || 'text-dark-400'}`}
|
||||
>
|
||||
{bonusTypeConfig[reg.bonus_type as CampaignBonusType]?.label ||
|
||||
reg.bonus_type}
|
||||
{bonusTypeConfig[reg.bonus_type as CampaignBonusType]?.labelKey
|
||||
? t(bonusTypeConfig[reg.bonus_type as CampaignBonusType].labelKey)
|
||||
: reg.bonus_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-3 text-sm text-dark-300">
|
||||
@@ -703,7 +798,7 @@ function UsersModal({ campaignId, campaignName, onClose }: UsersModalProps) {
|
||||
)}
|
||||
{reg.has_paid && (
|
||||
<span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-xs text-emerald-400">
|
||||
Платил
|
||||
{t('admin.campaigns.users.paid')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -724,17 +819,17 @@ function UsersModal({ campaignId, campaignName, onClose }: UsersModalProps) {
|
||||
disabled={page === 1}
|
||||
className="rounded-lg bg-dark-700 px-3 py-1.5 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Назад
|
||||
{t('admin.campaigns.users.prev')}
|
||||
</button>
|
||||
<span className="text-sm text-dark-400">
|
||||
{page} из {totalPages}
|
||||
{t('admin.campaigns.users.pageOf', { page, totalPages })}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page === totalPages}
|
||||
className="rounded-lg bg-dark-700 px-3 py-1.5 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Далее
|
||||
{t('admin.campaigns.users.next')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -745,6 +840,7 @@ function UsersModal({ campaignId, campaignName, onClose }: UsersModalProps) {
|
||||
|
||||
// Main Component
|
||||
export default function AdminCampaigns() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
@@ -856,8 +952,8 @@ export default function AdminCampaigns() {
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">Рекламные кампании</h1>
|
||||
<p className="text-sm text-dark-400">Управление рекламными ссылками</p>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.campaigns.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.campaigns.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -868,7 +964,7 @@ export default function AdminCampaigns() {
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<PlusIcon />
|
||||
Создать
|
||||
{t('admin.campaigns.createButton')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -877,21 +973,27 @@ export default function AdminCampaigns() {
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-dark-100">{overview.total}</div>
|
||||
<div className="text-sm text-dark-400">Всего кампаний</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
{t('admin.campaigns.overview.totalCampaigns')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-success-400">{overview.active}</div>
|
||||
<div className="text-sm text-dark-400">Активных</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.campaigns.overview.active')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-blue-400">{overview.total_registrations}</div>
|
||||
<div className="text-sm text-dark-400">Регистраций</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
{t('admin.campaigns.overview.registrations')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-emerald-400">
|
||||
{formatRubles(overview.total_balance_issued_kopeks)}
|
||||
</div>
|
||||
<div className="text-sm text-dark-400">Выдано бонусов</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
{t('admin.campaigns.overview.bonusesIssued')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -903,7 +1005,7 @@ export default function AdminCampaigns() {
|
||||
</div>
|
||||
) : campaigns.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-dark-400">Нет рекламных кампаний</p>
|
||||
<p className="text-dark-400">{t('admin.campaigns.noData')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -921,19 +1023,29 @@ export default function AdminCampaigns() {
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${bonusTypeConfig[campaign.bonus_type].bgColor} ${bonusTypeConfig[campaign.bonus_type].color}`}
|
||||
>
|
||||
{bonusTypeConfig[campaign.bonus_type].label}
|
||||
{t(bonusTypeConfig[campaign.bonus_type].labelKey)}
|
||||
</span>
|
||||
{!campaign.is_active && (
|
||||
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||
Неактивна
|
||||
{t('admin.campaigns.table.inactive')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
|
||||
<span className="font-mono text-xs">?start={campaign.start_parameter}</span>
|
||||
<span>{campaign.registrations_count} регистраций</span>
|
||||
<span>{formatRubles(campaign.total_revenue_kopeks)} доход</span>
|
||||
<span>{campaign.conversion_rate}% конверсия</span>
|
||||
<span>
|
||||
{t('admin.campaigns.table.registrations', {
|
||||
count: campaign.registrations_count,
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t('admin.campaigns.table.revenue', {
|
||||
amount: formatRubles(campaign.total_revenue_kopeks),
|
||||
})}
|
||||
</span>
|
||||
<span>
|
||||
{t('admin.campaigns.table.conversion', { rate: campaign.conversion_rate })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -942,7 +1054,7 @@ export default function AdminCampaigns() {
|
||||
<button
|
||||
onClick={() => handleViewStats(campaign.id)}
|
||||
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||
title="Статистика"
|
||||
title={t('admin.campaigns.table.statistics')}
|
||||
>
|
||||
<ChartIcon />
|
||||
</button>
|
||||
@@ -955,7 +1067,11 @@ export default function AdminCampaigns() {
|
||||
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
|
||||
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
|
||||
}`}
|
||||
title={campaign.is_active ? 'Деактивировать' : 'Активировать'}
|
||||
title={
|
||||
campaign.is_active
|
||||
? t('admin.campaigns.table.deactivate')
|
||||
: t('admin.campaigns.table.activate')
|
||||
}
|
||||
>
|
||||
{campaign.is_active ? <CheckIcon /> : <XIcon />}
|
||||
</button>
|
||||
@@ -964,7 +1080,7 @@ export default function AdminCampaigns() {
|
||||
<button
|
||||
onClick={() => handleEdit(campaign.id)}
|
||||
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||
title="Редактировать"
|
||||
title={t('admin.campaigns.table.edit')}
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
@@ -973,7 +1089,7 @@ export default function AdminCampaigns() {
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(campaign.id)}
|
||||
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-error-500/20 hover:text-error-400"
|
||||
title="Удалить"
|
||||
title={t('admin.campaigns.table.delete')}
|
||||
disabled={campaign.registrations_count > 0}
|
||||
>
|
||||
<TrashIcon />
|
||||
@@ -1022,22 +1138,22 @@ export default function AdminCampaigns() {
|
||||
{deleteConfirm !== null && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm rounded-xl bg-dark-800 p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">Удалить кампанию?</h3>
|
||||
<p className="mb-6 text-dark-400">
|
||||
Это действие нельзя отменить. Кампании с регистрациями удалить нельзя.
|
||||
</p>
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
||||
{t('admin.campaigns.confirm.deleteTitle')}
|
||||
</h3>
|
||||
<p className="mb-6 text-dark-400">{t('admin.campaigns.confirm.deleteText')}</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
Отмена
|
||||
{t('admin.campaigns.confirm.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(deleteConfirm)}
|
||||
className="rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600"
|
||||
>
|
||||
Удалить
|
||||
{t('admin.campaigns.confirm.delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -568,8 +568,8 @@ export default function AdminDashboard() {
|
||||
<ChevronDownIcon />
|
||||
</span>
|
||||
{showAllNodes
|
||||
? `Скрыть (${stats.nodes.nodes.length - 3})`
|
||||
: `Показать еще ${stats.nodes.nodes.length - 3} нод`}
|
||||
? t('adminDashboard.nodes.hide', { count: stats.nodes.nodes.length - 3 })
|
||||
: t('adminDashboard.nodes.showMore', { count: stats.nodes.nodes.length - 3 })}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -799,10 +799,11 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-dark-100 sm:text-lg">
|
||||
Топ рефералов
|
||||
{t('adminDashboard.topReferrers.title')}
|
||||
</h2>
|
||||
<p className="text-xs text-dark-400 sm:text-sm">
|
||||
{referrers.total_referrers} реф., {referrers.total_referrals} пригл.
|
||||
{referrers.total_referrers}{' '}
|
||||
{t('adminDashboard.topReferrers.stats', { count: referrers.total_referrals })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -818,7 +819,7 @@ export default function AdminDashboard() {
|
||||
: 'bg-dark-700/50 text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
По доходу
|
||||
{t('adminDashboard.topReferrers.byEarnings')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setReferrersTab('invited')}
|
||||
@@ -828,7 +829,7 @@ export default function AdminDashboard() {
|
||||
: 'bg-dark-700/50 text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
По пригл.
|
||||
{t('adminDashboard.topReferrers.byInvited')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -862,13 +863,13 @@ export default function AdminDashboard() {
|
||||
{formatAmount(ref.earnings_total_kopeks / 100)} {currencySymbol}
|
||||
</div>
|
||||
<div className="text-[10px] text-dark-500 sm:text-xs">
|
||||
{ref.invited_count} пригл.
|
||||
{ref.invited_count} {t('adminDashboard.topReferrers.invites')}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs font-semibold text-accent-400 sm:text-sm">
|
||||
{ref.invited_count} чел.
|
||||
{ref.invited_count} {t('adminDashboard.topReferrers.people')}
|
||||
</div>
|
||||
<div className="text-[10px] text-dark-500 sm:text-xs">
|
||||
{formatAmount(ref.earnings_total_kopeks / 100)} {currencySymbol}
|
||||
@@ -883,7 +884,9 @@ export default function AdminDashboard() {
|
||||
{/* Period Stats */}
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 border-t border-dark-700 pt-4 sm:gap-3">
|
||||
<div className="text-center">
|
||||
<div className="mb-1 text-[10px] text-dark-500 sm:text-xs">Сегодня</div>
|
||||
<div className="mb-1 text-[10px] text-dark-500 sm:text-xs">
|
||||
{t('adminDashboard.period.today')}
|
||||
</div>
|
||||
<div className="truncate text-xs font-semibold text-dark-200 sm:text-base">
|
||||
{formatAmount(
|
||||
(referrersTab === 'earnings'
|
||||
@@ -895,7 +898,9 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="mb-1 text-[10px] text-dark-500 sm:text-xs">Неделя</div>
|
||||
<div className="mb-1 text-[10px] text-dark-500 sm:text-xs">
|
||||
{t('adminDashboard.period.week')}
|
||||
</div>
|
||||
<div className="truncate text-xs font-semibold text-dark-200 sm:text-base">
|
||||
{formatAmount(
|
||||
(referrersTab === 'earnings'
|
||||
@@ -907,7 +912,9 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="mb-1 text-[10px] text-dark-500 sm:text-xs">Месяц</div>
|
||||
<div className="mb-1 text-[10px] text-dark-500 sm:text-xs">
|
||||
{t('adminDashboard.period.month')}
|
||||
</div>
|
||||
<div className="truncate text-xs font-semibold text-dark-200 sm:text-base">
|
||||
{formatAmount(
|
||||
(referrersTab === 'earnings'
|
||||
@@ -930,9 +937,12 @@ export default function AdminDashboard() {
|
||||
<MegaphoneIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-dark-100 sm:text-lg">Топ РК ссылок</h2>
|
||||
<h2 className="text-base font-semibold text-dark-100 sm:text-lg">
|
||||
{t('adminDashboard.topCampaigns.title')}
|
||||
</h2>
|
||||
<p className="text-xs text-dark-400 sm:text-sm">
|
||||
{campaigns.total_campaigns} камп., {campaigns.total_registrations} рег.
|
||||
{campaigns.total_campaigns}{' '}
|
||||
{t('adminDashboard.topCampaigns.stats', { count: campaigns.total_registrations })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -970,7 +980,9 @@ export default function AdminDashboard() {
|
||||
|
||||
<div className="mt-4 border-t border-dark-700 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-dark-400 sm:text-sm">Всего от РК</span>
|
||||
<span className="text-xs text-dark-400 sm:text-sm">
|
||||
{t('adminDashboard.topCampaigns.total')}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-warning-400 sm:text-base">
|
||||
{formatAmount(campaigns.total_revenue_kopeks / 100)} {currencySymbol}
|
||||
</span>
|
||||
@@ -990,13 +1002,18 @@ export default function AdminDashboard() {
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-dark-100 sm:text-lg">
|
||||
Последние платежи
|
||||
{t('adminDashboard.recentPayments.title')}
|
||||
</h2>
|
||||
<p className="text-xs text-dark-400 sm:text-sm">
|
||||
Сегодня: {formatAmount(payments.total_today_kopeks / 100)} {currencySymbol}
|
||||
{t('adminDashboard.recentPayments.today', {
|
||||
amount: `${formatAmount(payments.total_today_kopeks / 100)} ${currencySymbol}`,
|
||||
})}
|
||||
<span className="hidden sm:inline">
|
||||
{' '}
|
||||
· За неделю: {formatAmount(payments.total_week_kopeks / 100)} {currencySymbol}
|
||||
·{' '}
|
||||
{t('adminDashboard.recentPayments.week', {
|
||||
amount: `${formatAmount(payments.total_week_kopeks / 100)} ${currencySymbol}`,
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -1009,12 +1026,20 @@ export default function AdminDashboard() {
|
||||
<thead>
|
||||
<tr className="border-b border-dark-700">
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-dark-500">
|
||||
Пользователь
|
||||
{t('adminDashboard.table.user')}
|
||||
</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-dark-500">
|
||||
{t('adminDashboard.table.type')}
|
||||
</th>
|
||||
<th className="px-2 py-3 text-right text-xs font-medium text-dark-500">
|
||||
{t('adminDashboard.table.amount')}
|
||||
</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-dark-500">
|
||||
{t('adminDashboard.table.method')}
|
||||
</th>
|
||||
<th className="px-2 py-3 text-right text-xs font-medium text-dark-500">
|
||||
{t('adminDashboard.table.date')}
|
||||
</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-dark-500">Тип</th>
|
||||
<th className="px-2 py-3 text-right text-xs font-medium text-dark-500">Сумма</th>
|
||||
<th className="px-2 py-3 text-left text-xs font-medium text-dark-500">Метод</th>
|
||||
<th className="px-2 py-3 text-right text-xs font-medium text-dark-500">Дата</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
||||
@@ -222,10 +222,10 @@ function TemplateEditor({
|
||||
queryKey: ['admin', 'email-template', detail.notification_type],
|
||||
});
|
||||
setIsDirty(false);
|
||||
showToast('success', t('admin.emailTemplates.saved', 'Template saved'));
|
||||
showToast('success', t('admin.emailTemplates.saved'));
|
||||
},
|
||||
onError: () => {
|
||||
showToast('error', t('common.error', 'Error'));
|
||||
showToast('error', t('common.error'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -238,10 +238,10 @@ function TemplateEditor({
|
||||
queryKey: ['admin', 'email-template', detail.notification_type],
|
||||
});
|
||||
setIsDirty(false);
|
||||
showToast('success', t('admin.emailTemplates.resetted', 'Template reset to default'));
|
||||
showToast('success', t('admin.emailTemplates.resetted'));
|
||||
},
|
||||
onError: () => {
|
||||
showToast('error', t('common.error', 'Error'));
|
||||
showToast('error', t('common.error'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -258,7 +258,7 @@ function TemplateEditor({
|
||||
setShowPreview(true);
|
||||
},
|
||||
onError: () => {
|
||||
showToast('error', t('common.error', 'Error'));
|
||||
showToast('error', t('common.error'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -269,13 +269,10 @@ function TemplateEditor({
|
||||
language: activeLang,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
showToast(
|
||||
'success',
|
||||
`${t('admin.emailTemplates.testSent', 'Test email sent')} → ${data.sent_to}`,
|
||||
);
|
||||
showToast('success', `${t('admin.emailTemplates.testSent')} → ${data.sent_to}`);
|
||||
},
|
||||
onError: () => {
|
||||
showToast('error', t('common.error', 'Error'));
|
||||
showToast('error', t('common.error'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -337,16 +334,7 @@ function TemplateEditor({
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => {
|
||||
if (
|
||||
isDirty &&
|
||||
!window.confirm(
|
||||
t(
|
||||
'admin.emailTemplates.unsavedWarning',
|
||||
'Unsaved changes will be lost. Continue?',
|
||||
),
|
||||
)
|
||||
)
|
||||
return;
|
||||
if (isDirty && !window.confirm(t('admin.emailTemplates.unsavedWarning'))) return;
|
||||
setActiveLang(lang);
|
||||
}}
|
||||
className={`flex flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md px-2 py-2 text-xs font-medium transition-all duration-150 sm:gap-1.5 sm:px-3 sm:text-sm ${
|
||||
@@ -368,14 +356,14 @@ function TemplateEditor({
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||
{t('admin.emailTemplates.subject', 'Subject')}
|
||||
{t('admin.emailTemplates.subject')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editSubject}
|
||||
onChange={(e) => handleSubjectChange(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2.5 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500"
|
||||
placeholder={t('admin.emailTemplates.subjectPlaceholder', 'Email subject line...')}
|
||||
placeholder={t('admin.emailTemplates.subjectPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -383,14 +371,14 @@ function TemplateEditor({
|
||||
{detail.context_vars.length > 0 && (
|
||||
<div className="rounded-lg border border-dark-700 bg-dark-900/60 p-2.5 sm:p-3">
|
||||
<p className="mb-1.5 text-xs font-medium text-dark-300">
|
||||
{t('admin.emailTemplates.variables', 'Available Variables')}
|
||||
{t('admin.emailTemplates.variables')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 sm:gap-1.5">
|
||||
{detail.context_vars.map((v) => (
|
||||
<code
|
||||
key={v}
|
||||
className="cursor-pointer rounded bg-dark-700 px-2 py-0.5 font-mono text-xs text-accent-400 transition-colors hover:bg-dark-600"
|
||||
title={t('admin.emailTemplates.clickToCopy', 'Click to copy')}
|
||||
title={t('admin.emailTemplates.clickToCopy')}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`{${v}}`);
|
||||
showToast('success', `Copied {${v}}`);
|
||||
@@ -406,7 +394,7 @@ function TemplateEditor({
|
||||
{/* Body HTML editor */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||
{t('admin.emailTemplates.body', 'Body (HTML)')}
|
||||
{t('admin.emailTemplates.body')}
|
||||
</label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
@@ -417,12 +405,7 @@ function TemplateEditor({
|
||||
placeholder="<h2>Title</h2><p>Content...</p>"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="mt-1 text-2xs text-dark-500">
|
||||
{t(
|
||||
'admin.emailTemplates.bodyHint',
|
||||
'HTML content that will be wrapped in the base email template with header and footer.',
|
||||
)}
|
||||
</p>
|
||||
<p className="mt-1 text-2xs text-dark-500">{t('admin.emailTemplates.bodyHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
@@ -434,7 +417,7 @@ function TemplateEditor({
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-accent-500 px-3 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-40 sm:px-4 sm:py-2"
|
||||
>
|
||||
<SaveIcon />
|
||||
{saveMutation.isPending ? t('common.loading', 'Loading...') : t('common.save', 'Save')}
|
||||
{saveMutation.isPending ? t('common.loading') : t('common.save')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -443,7 +426,7 @@ function TemplateEditor({
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:px-4 sm:py-2"
|
||||
>
|
||||
<EyeIcon />
|
||||
{t('admin.emailTemplates.preview', 'Preview')}
|
||||
{t('admin.emailTemplates.preview')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -454,22 +437,13 @@ function TemplateEditor({
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:px-4 sm:py-2"
|
||||
>
|
||||
<SendIcon />
|
||||
{testMutation.isPending
|
||||
? t('common.loading', 'Loading...')
|
||||
: t('admin.emailTemplates.sendTest', 'Send Test')}
|
||||
{testMutation.isPending ? t('common.loading') : t('admin.emailTemplates.sendTest')}
|
||||
</button>
|
||||
|
||||
{langData && !langData.is_default && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
'admin.emailTemplates.resetConfirm',
|
||||
'Reset this template to the default version?',
|
||||
),
|
||||
)
|
||||
) {
|
||||
if (window.confirm(t('admin.emailTemplates.resetConfirm'))) {
|
||||
resetMutation.mutate();
|
||||
}
|
||||
}}
|
||||
@@ -477,9 +451,7 @@ function TemplateEditor({
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-warning-400 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:ml-auto sm:px-4 sm:py-2"
|
||||
>
|
||||
<ResetIcon />
|
||||
<span className="truncate">
|
||||
{t('admin.emailTemplates.resetDefault', 'Reset to Default')}
|
||||
</span>
|
||||
<span className="truncate">{t('admin.emailTemplates.resetDefault')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -502,7 +474,7 @@ function TemplateEditor({
|
||||
<div className="flex max-h-[90vh] w-full flex-col rounded-t-2xl border border-dark-600 bg-dark-800 shadow-2xl sm:max-h-[85vh] sm:max-w-2xl sm:rounded-2xl">
|
||||
<div className="flex items-center justify-between border-b border-dark-700 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<h3 className="text-base font-semibold text-dark-100">
|
||||
{t('admin.emailTemplates.preview', 'Preview')}
|
||||
{t('admin.emailTemplates.preview')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowPreview(false)}
|
||||
@@ -562,10 +534,10 @@ export default function AdminEmailTemplates() {
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="truncate text-lg font-bold text-dark-100 sm:text-xl">
|
||||
{t('admin.emailTemplates.title', 'Email Templates')}
|
||||
{t('admin.emailTemplates.title')}
|
||||
</h1>
|
||||
<p className="truncate text-xs text-dark-400">
|
||||
{t('admin.emailTemplates.description', 'Manage email notification templates')}
|
||||
{t('admin.emailTemplates.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -297,7 +297,7 @@ export default function AdminPanel() {
|
||||
const groups: AdminGroup[] = [
|
||||
{
|
||||
id: 'analytics',
|
||||
title: t('admin.groups.analytics', 'Аналитика'),
|
||||
title: t('admin.groups.analytics'),
|
||||
icon: <AnalyticsGroupIcon />,
|
||||
gradient: 'from-emerald-500/10 to-emerald-500/5',
|
||||
borderColor: 'border-emerald-500/20',
|
||||
@@ -313,14 +313,14 @@ export default function AdminPanel() {
|
||||
{
|
||||
to: '/admin/payments',
|
||||
icon: <BanknotesIcon />,
|
||||
title: t('admin.nav.payments', 'Платежи'),
|
||||
description: t('admin.panel.paymentsDesc', 'История и проверка платежей'),
|
||||
title: t('admin.nav.payments'),
|
||||
description: t('admin.panel.paymentsDesc'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
title: t('admin.groups.users', 'Пользователи'),
|
||||
title: t('admin.groups.users'),
|
||||
icon: <UsersGroupIcon />,
|
||||
gradient: 'from-blue-500/10 to-blue-500/5',
|
||||
borderColor: 'border-blue-500/20',
|
||||
@@ -330,8 +330,8 @@ export default function AdminPanel() {
|
||||
{
|
||||
to: '/admin/users',
|
||||
icon: <UsersIcon />,
|
||||
title: t('admin.nav.users', 'Пользователи'),
|
||||
description: t('admin.panel.usersDesc', 'Управление пользователями'),
|
||||
title: t('admin.nav.users'),
|
||||
description: t('admin.panel.usersDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/tickets',
|
||||
@@ -349,7 +349,7 @@ export default function AdminPanel() {
|
||||
},
|
||||
{
|
||||
id: 'tariffs',
|
||||
title: t('admin.groups.tariffs', 'Тарифы и продажи'),
|
||||
title: t('admin.groups.tariffs'),
|
||||
icon: <TariffsGroupIcon />,
|
||||
gradient: 'from-amber-500/10 to-amber-500/5',
|
||||
borderColor: 'border-amber-500/20',
|
||||
@@ -365,26 +365,26 @@ export default function AdminPanel() {
|
||||
{
|
||||
to: '/admin/promocodes',
|
||||
icon: <TicketIcon />,
|
||||
title: t('admin.nav.promocodes', 'Промокоды'),
|
||||
description: t('admin.panel.promocodesDesc', 'Управление промокодами'),
|
||||
title: t('admin.nav.promocodes'),
|
||||
description: t('admin.panel.promocodesDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/promo-offers',
|
||||
icon: <GiftIcon />,
|
||||
title: t('admin.nav.promoOffers', 'Промопредложения'),
|
||||
description: t('admin.panel.promoOffersDesc', 'Персональные скидки'),
|
||||
title: t('admin.nav.promoOffers'),
|
||||
description: t('admin.panel.promoOffersDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/payment-methods',
|
||||
icon: <BanknotesIcon />,
|
||||
title: t('admin.nav.paymentMethods', 'Платёжные методы'),
|
||||
description: t('admin.panel.paymentMethodsDesc', 'Настройка и порядок платежек'),
|
||||
title: t('admin.nav.paymentMethods'),
|
||||
description: t('admin.panel.paymentMethodsDesc'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
title: t('admin.groups.marketing', 'Маркетинг'),
|
||||
title: t('admin.groups.marketing'),
|
||||
icon: <MarketingGroupIcon />,
|
||||
gradient: 'from-rose-500/10 to-rose-500/5',
|
||||
borderColor: 'border-rose-500/20',
|
||||
@@ -394,8 +394,8 @@ export default function AdminPanel() {
|
||||
{
|
||||
to: '/admin/campaigns',
|
||||
icon: <MegaphoneIcon />,
|
||||
title: t('admin.nav.campaigns', 'Кампании'),
|
||||
description: t('admin.panel.campaignsDesc', 'Рекламные кампании'),
|
||||
title: t('admin.nav.campaigns'),
|
||||
description: t('admin.panel.campaignsDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/broadcasts',
|
||||
@@ -413,7 +413,7 @@ export default function AdminPanel() {
|
||||
},
|
||||
{
|
||||
id: 'system',
|
||||
title: t('admin.groups.system', 'Система'),
|
||||
title: t('admin.groups.system'),
|
||||
icon: <SystemGroupIcon />,
|
||||
gradient: 'from-violet-500/10 to-violet-500/5',
|
||||
borderColor: 'border-violet-500/20',
|
||||
@@ -441,14 +441,14 @@ export default function AdminPanel() {
|
||||
{
|
||||
to: '/admin/remnawave',
|
||||
icon: <CubeTransparentIcon />,
|
||||
title: t('admin.nav.remnawave', 'RemnaWave'),
|
||||
description: t('admin.panel.remnawaveDesc', 'Управление панелью'),
|
||||
title: t('admin.nav.remnawave'),
|
||||
description: t('admin.panel.remnawaveDesc'),
|
||||
},
|
||||
{
|
||||
to: '/admin/email-templates',
|
||||
icon: <EnvelopeIcon />,
|
||||
title: t('admin.nav.emailTemplates', 'Email-шаблоны'),
|
||||
description: t('admin.panel.emailTemplatesDesc', 'Шаблоны email-уведомлений'),
|
||||
title: t('admin.nav.emailTemplates'),
|
||||
description: t('admin.panel.emailTemplatesDesc'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -131,16 +131,13 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
||||
// Build condition summary chips
|
||||
const chips: string[] = [];
|
||||
if (config.user_type_filter === 'telegram')
|
||||
chips.push(t('admin.paymentMethods.userTypeTelegram', 'Telegram'));
|
||||
if (config.user_type_filter === 'email')
|
||||
chips.push(t('admin.paymentMethods.userTypeEmail', 'Email'));
|
||||
if (config.first_topup_filter === 'yes')
|
||||
chips.push(t('admin.paymentMethods.firstTopupYes', 'C пополнением'));
|
||||
if (config.first_topup_filter === 'no')
|
||||
chips.push(t('admin.paymentMethods.firstTopupNo', 'Без пополнения'));
|
||||
chips.push(t('admin.paymentMethods.userTypeTelegram'));
|
||||
if (config.user_type_filter === 'email') chips.push(t('admin.paymentMethods.userTypeEmail'));
|
||||
if (config.first_topup_filter === 'yes') chips.push(t('admin.paymentMethods.firstTopupYes'));
|
||||
if (config.first_topup_filter === 'no') chips.push(t('admin.paymentMethods.firstTopupNo'));
|
||||
if (config.promo_group_filter_mode === 'selected' && config.allowed_promo_group_ids.length > 0) {
|
||||
chips.push(
|
||||
`${config.allowed_promo_group_ids.length} ${t('admin.paymentMethods.promoGroupsShort', 'групп')}`,
|
||||
`${config.allowed_promo_group_ids.length} ${t('admin.paymentMethods.promoGroupsShort')}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,7 +170,7 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex-shrink-0 cursor-grab touch-manipulation rounded-lg p-1.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing"
|
||||
title={t('admin.paymentMethods.dragToReorder', 'Перетащите для изменения порядка')}
|
||||
title={t('admin.paymentMethods.dragToReorder')}
|
||||
>
|
||||
<GripIcon />
|
||||
</button>
|
||||
@@ -189,16 +186,16 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
||||
<span className="truncate font-semibold text-dark-100">{displayName}</span>
|
||||
{config.is_enabled ? (
|
||||
<span className="flex-shrink-0 rounded-full border border-success-500/20 bg-success-500/15 px-2 py-0.5 text-xs text-success-400">
|
||||
{t('admin.paymentMethods.enabled', 'Вкл')}
|
||||
{t('admin.paymentMethods.enabled')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-shrink-0 rounded-full border border-dark-700/30 bg-dark-700/50 px-2 py-0.5 text-xs text-dark-500">
|
||||
{t('admin.paymentMethods.disabled', 'Выкл')}
|
||||
{t('admin.paymentMethods.disabled')}
|
||||
</span>
|
||||
)}
|
||||
{!config.is_provider_configured && (
|
||||
<span className="flex-shrink-0 rounded-full border border-warning-500/20 bg-warning-500/15 px-2 py-0.5 text-xs text-warning-400">
|
||||
{t('admin.paymentMethods.notConfigured', 'Не настроен')}
|
||||
{t('admin.paymentMethods.notConfigured')}
|
||||
</span>
|
||||
)}
|
||||
{subOptionsInfo && (
|
||||
@@ -363,11 +360,11 @@ function PaymentMethodDetailModal({
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-dark-200">
|
||||
{t('admin.paymentMethods.methodEnabled', 'Метод включён')}
|
||||
{t('admin.paymentMethods.methodEnabled')}
|
||||
</div>
|
||||
{!config.is_provider_configured && (
|
||||
<div className="mt-0.5 text-xs text-warning-400">
|
||||
{t('admin.paymentMethods.providerNotConfigured', 'Провайдер не настроен в env')}
|
||||
{t('admin.paymentMethods.providerNotConfigured')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -388,7 +385,7 @@ function PaymentMethodDetailModal({
|
||||
{/* Display name */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||
{t('admin.paymentMethods.displayName', 'Отображаемое имя')}
|
||||
{t('admin.paymentMethods.displayName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -398,8 +395,7 @@ function PaymentMethodDetailModal({
|
||||
className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('admin.paymentMethods.displayNameHint', 'Оставьте пустым для имени по умолчанию')}:{' '}
|
||||
{config.default_display_name}
|
||||
{t('admin.paymentMethods.displayNameHint')}: {config.default_display_name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -407,7 +403,7 @@ function PaymentMethodDetailModal({
|
||||
{config.available_sub_options && config.available_sub_options.length > 0 && (
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||
{t('admin.paymentMethods.subOptions', 'Варианты оплаты')}
|
||||
{t('admin.paymentMethods.subOptions')}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{config.available_sub_options.map((opt) => {
|
||||
@@ -443,7 +439,7 @@ function PaymentMethodDetailModal({
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||
{t('admin.paymentMethods.minAmount', 'Мин. сумма (коп.)')}
|
||||
{t('admin.paymentMethods.minAmount')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -455,7 +451,7 @@ function PaymentMethodDetailModal({
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||
{t('admin.paymentMethods.maxAmount', 'Макс. сумма (коп.)')}
|
||||
{t('admin.paymentMethods.maxAmount')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -470,13 +466,13 @@ function PaymentMethodDetailModal({
|
||||
{/* Display conditions */}
|
||||
<div className="border-t border-dark-700 pt-3">
|
||||
<h3 className="mb-4 text-sm font-semibold text-dark-200">
|
||||
{t('admin.paymentMethods.conditions', 'Условия отображения')}
|
||||
{t('admin.paymentMethods.conditions')}
|
||||
</h3>
|
||||
|
||||
{/* User type filter */}
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-sm text-dark-300">
|
||||
{t('admin.paymentMethods.userTypeFilter', 'Тип пользователя')}
|
||||
{t('admin.paymentMethods.userTypeFilter')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(['all', 'telegram', 'email'] as const).map((val) => (
|
||||
@@ -490,7 +486,7 @@ function PaymentMethodDetailModal({
|
||||
}`}
|
||||
>
|
||||
{val === 'all'
|
||||
? t('admin.paymentMethods.userTypeAll', 'Все')
|
||||
? t('admin.paymentMethods.userTypeAll')
|
||||
: val === 'telegram'
|
||||
? 'Telegram'
|
||||
: 'Email'}
|
||||
@@ -502,7 +498,7 @@ function PaymentMethodDetailModal({
|
||||
{/* First topup filter */}
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-sm text-dark-300">
|
||||
{t('admin.paymentMethods.firstTopupFilter', 'Первое пополнение')}
|
||||
{t('admin.paymentMethods.firstTopupFilter')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(['any', 'yes', 'no'] as const).map((val) => (
|
||||
@@ -516,10 +512,10 @@ function PaymentMethodDetailModal({
|
||||
}`}
|
||||
>
|
||||
{val === 'any'
|
||||
? t('admin.paymentMethods.firstTopupAny', 'Не важно')
|
||||
? t('admin.paymentMethods.firstTopupAny')
|
||||
: val === 'yes'
|
||||
? t('admin.paymentMethods.firstTopupYes', 'Было')
|
||||
: t('admin.paymentMethods.firstTopupNo', 'Не было')}
|
||||
? t('admin.paymentMethods.firstTopupWas')
|
||||
: t('admin.paymentMethods.firstTopupWasNot')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -528,7 +524,7 @@ function PaymentMethodDetailModal({
|
||||
{/* Promo groups filter */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm text-dark-300">
|
||||
{t('admin.paymentMethods.promoGroupFilter', 'Промо-группы')}
|
||||
{t('admin.paymentMethods.promoGroupFilter')}
|
||||
</label>
|
||||
<div className="mb-3 flex gap-2">
|
||||
{(['all', 'selected'] as const).map((val) => (
|
||||
@@ -542,8 +538,8 @@ function PaymentMethodDetailModal({
|
||||
}`}
|
||||
>
|
||||
{val === 'all'
|
||||
? t('admin.paymentMethods.promoGroupAll', 'Все группы')
|
||||
: t('admin.paymentMethods.promoGroupSelected', 'Выбранные')}
|
||||
? t('admin.paymentMethods.promoGroupAll')
|
||||
: t('admin.paymentMethods.promoGroupSelected')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -552,7 +548,7 @@ function PaymentMethodDetailModal({
|
||||
<div className="max-h-48 space-y-1.5 overflow-y-auto rounded-xl border border-dark-700/50 bg-dark-900/30 p-3">
|
||||
{promoGroups.length === 0 ? (
|
||||
<p className="py-2 text-center text-sm text-dark-500">
|
||||
{t('admin.paymentMethods.noPromoGroups', 'Нет промо-групп')}
|
||||
{t('admin.paymentMethods.noPromoGroups')}
|
||||
</p>
|
||||
) : (
|
||||
promoGroups.map((group) => {
|
||||
@@ -591,7 +587,7 @@ function PaymentMethodDetailModal({
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-xl bg-dark-700 px-4 py-2.5 font-medium text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
{t('common.cancel', 'Отмена')}
|
||||
{t('admin.paymentMethods.cancelButton')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
@@ -603,7 +599,7 @@ function PaymentMethodDetailModal({
|
||||
) : (
|
||||
<SaveIcon />
|
||||
)}
|
||||
{t('common.save', 'Сохранить')}
|
||||
{t('admin.paymentMethods.saveButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -663,10 +659,10 @@ export default function AdminPaymentMethods() {
|
||||
onSuccess: () => {
|
||||
setOrderChanged(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] });
|
||||
setToastMessage(t('admin.paymentMethods.orderSaved', 'Порядок сохранён'));
|
||||
setToastMessage(t('admin.paymentMethods.orderSaved'));
|
||||
},
|
||||
onError: () => {
|
||||
setToastMessage(t('common.error', 'Ошибка'));
|
||||
setToastMessage(t('common.error'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -677,10 +673,10 @@ export default function AdminPaymentMethods() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] });
|
||||
setSelectedMethod(null);
|
||||
setToastMessage(t('admin.paymentMethods.saved', 'Настройки сохранены'));
|
||||
setToastMessage(t('admin.paymentMethods.saved'));
|
||||
},
|
||||
onError: () => {
|
||||
setToastMessage(t('common.error', 'Ошибка'));
|
||||
setToastMessage(t('common.error'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -726,12 +722,8 @@ export default function AdminPaymentMethods() {
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-dark-50">
|
||||
{t('admin.paymentMethods.title', 'Платёжные методы')}
|
||||
</h1>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('admin.paymentMethods.description', 'Настройка порядка и условий отображения')}
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold text-dark-50">{t('admin.paymentMethods.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.paymentMethods.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{orderChanged && (
|
||||
@@ -745,7 +737,7 @@ export default function AdminPaymentMethods() {
|
||||
) : (
|
||||
<SaveIcon />
|
||||
)}
|
||||
{t('admin.paymentMethods.saveOrder', 'Сохранить порядок')}
|
||||
{t('admin.paymentMethods.saveOrder')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -753,10 +745,7 @@ export default function AdminPaymentMethods() {
|
||||
{/* Drag hint */}
|
||||
<div className="flex items-center gap-2 text-sm text-dark-500">
|
||||
<GripIcon />
|
||||
{t(
|
||||
'admin.paymentMethods.dragHint',
|
||||
'Перетаскивайте карточки для изменения порядка. Нажмите для настройки.',
|
||||
)}
|
||||
{t('admin.paymentMethods.dragHint')}
|
||||
</div>
|
||||
|
||||
{/* Methods list */}
|
||||
@@ -791,9 +780,7 @@ export default function AdminPaymentMethods() {
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
|
||||
<span className="text-3xl">{'\uD83D\uDCB3'}</span>
|
||||
</div>
|
||||
<div className="text-dark-400">
|
||||
{t('admin.paymentMethods.noMethods', 'Нет настроенных платёжных методов')}
|
||||
</div>
|
||||
<div className="text-dark-400">{t('admin.paymentMethods.noMethods')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -83,12 +83,8 @@ export default function AdminPayments() {
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-dark-50">
|
||||
{t('admin.payments.title', 'Проверка платежей')}
|
||||
</h1>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('admin.payments.description', 'Ожидающие платежи за последние 24 часа')}
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold text-dark-50">{t('admin.payments.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.payments.description')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => refetch()} className="btn-secondary flex items-center gap-2">
|
||||
@@ -105,7 +101,7 @@ export default function AdminPayments() {
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
{t('common.refresh', 'Обновить')}
|
||||
{t('common.refresh')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -114,9 +110,7 @@ export default function AdminPayments() {
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-4">
|
||||
<div className="text-2xl font-bold text-dark-50">{stats.total_pending}</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
{t('admin.payments.totalPending', 'Всего ожидает')}
|
||||
</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.payments.totalPending')}</div>
|
||||
</div>
|
||||
{Object.entries(stats.by_method).map(([method, count]) => (
|
||||
<div
|
||||
@@ -138,9 +132,7 @@ export default function AdminPayments() {
|
||||
{/* Filter */}
|
||||
{methodOptions.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('admin.payments.filterByMethod', 'Фильтр по методу')}:
|
||||
</span>
|
||||
<span className="text-sm text-dark-400">{t('admin.payments.filterByMethod')}:</span>
|
||||
<button
|
||||
onClick={() => setMethodFilter('')}
|
||||
className={`rounded-lg px-3 py-1.5 text-sm transition-all ${
|
||||
@@ -149,7 +141,7 @@ export default function AdminPayments() {
|
||||
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
{t('common.all', 'Все')}
|
||||
{t('common.all')}
|
||||
</button>
|
||||
{methodOptions.map((method) => (
|
||||
<button
|
||||
@@ -195,7 +187,7 @@ export default function AdminPayments() {
|
||||
</span>
|
||||
{payment.is_paid && (
|
||||
<span className="rounded-full bg-success-500/20 px-2 py-0.5 text-sm text-success-400">
|
||||
{t('admin.payments.paid', 'Оплачено')}
|
||||
{t('admin.payments.paid')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -211,9 +203,7 @@ export default function AdminPayments() {
|
||||
{/* User info */}
|
||||
{(payment.user_username || payment.user_telegram_id) && (
|
||||
<div className="mt-2 text-sm text-dark-400">
|
||||
<span className="text-dark-500">
|
||||
{t('admin.payments.user', 'Пользователь')}:
|
||||
</span>{' '}
|
||||
<span className="text-dark-500">{t('admin.payments.user')}:</span>{' '}
|
||||
{payment.user_username ? (
|
||||
<span className="text-dark-200">@{payment.user_username}</span>
|
||||
) : (
|
||||
@@ -230,7 +220,7 @@ export default function AdminPayments() {
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary px-3 py-1.5 text-xs"
|
||||
>
|
||||
{t('admin.payments.openLink', 'Открыть ссылку')}
|
||||
{t('admin.payments.openLink')}
|
||||
</a>
|
||||
)}
|
||||
{payment.is_checkable && (
|
||||
@@ -256,10 +246,10 @@ export default function AdminPayments() {
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
{t('admin.payments.checking', 'Проверка...')}
|
||||
{t('admin.payments.checking')}
|
||||
</span>
|
||||
) : (
|
||||
t('admin.payments.checkStatus', 'Проверить статус')
|
||||
t('admin.payments.checkStatus')
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
@@ -299,9 +289,7 @@ export default function AdminPayments() {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-dark-400">
|
||||
{t('admin.payments.noPayments', 'Нет ожидающих платежей')}
|
||||
</div>
|
||||
<div className="text-dark-400">{t('admin.payments.noPayments')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -316,7 +304,7 @@ export default function AdminPayments() {
|
||||
payments.page <= 1 ? 'cursor-not-allowed opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
{t('common.back', 'Назад')}
|
||||
{t('admin.payments.prev')}
|
||||
</button>
|
||||
<div className="flex-1 text-center">
|
||||
{t('balance.page', '{current} / {total}', {
|
||||
@@ -332,7 +320,7 @@ export default function AdminPayments() {
|
||||
payments.page >= payments.pages ? 'cursor-not-allowed opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
{t('common.next', 'Далее')}
|
||||
{t('admin.payments.next')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import i18n from '../i18n';
|
||||
import {
|
||||
promoOffersApi,
|
||||
PromoOfferTemplate,
|
||||
@@ -91,7 +93,9 @@ const UsersIcon = () => (
|
||||
// Helper functions
|
||||
const formatDateTime = (date: string | null): string => {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleString('ru-RU', {
|
||||
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
return new Date(date).toLocaleString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
@@ -102,10 +106,10 @@ const formatDateTime = (date: string | null): string => {
|
||||
|
||||
const getActionLabel = (action: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
created: 'Создано',
|
||||
claimed: 'Активировано',
|
||||
consumed: 'Использовано',
|
||||
disabled: 'Деактивировано',
|
||||
created: i18n.t('admin.promoOffers.actions.created'),
|
||||
claimed: i18n.t('admin.promoOffers.actions.claimed'),
|
||||
consumed: i18n.t('admin.promoOffers.actions.consumed'),
|
||||
disabled: i18n.t('admin.promoOffers.actions.disabled'),
|
||||
};
|
||||
return labels[action] || action;
|
||||
};
|
||||
@@ -125,7 +129,8 @@ const getOfferTypeIcon = (offerType: string): string => {
|
||||
};
|
||||
|
||||
const getOfferTypeLabel = (offerType: string): string => {
|
||||
return OFFER_TYPE_CONFIG[offerType as OfferType]?.label || offerType;
|
||||
const config = OFFER_TYPE_CONFIG[offerType as OfferType];
|
||||
return config ? i18n.t(config.labelKey) : offerType;
|
||||
};
|
||||
|
||||
// Template Edit Modal
|
||||
@@ -137,6 +142,7 @@ interface TemplateEditModalProps {
|
||||
}
|
||||
|
||||
function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(template.name);
|
||||
const [messageText, setMessageText] = useState(template.message_text);
|
||||
const [buttonText, setButtonText] = useState(template.button_text);
|
||||
@@ -174,7 +180,9 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
|
||||
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span>
|
||||
<h2 className="text-lg font-semibold text-dark-100">Редактирование шаблона</h2>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.promoOffers.form.editTemplate')}
|
||||
</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
|
||||
<XIcon />
|
||||
@@ -184,7 +192,9 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
|
||||
{/* Content */}
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Название шаблона</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.promoOffers.form.templateName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
@@ -194,7 +204,9 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Текст сообщения</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.promoOffers.form.messageText')}
|
||||
</label>
|
||||
<textarea
|
||||
value={messageText}
|
||||
onChange={(e) => setMessageText(e.target.value)}
|
||||
@@ -204,7 +216,9 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Текст кнопки</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.promoOffers.form.buttonText')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={buttonText}
|
||||
@@ -215,7 +229,9 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Срок предложения (часы)</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.promoOffers.form.validHours')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={validHours}
|
||||
@@ -223,12 +239,16 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={1}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">Время на активацию</p>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('admin.promoOffers.form.activationTime')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!isTestAccess && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Скидка (%)</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.promoOffers.form.discountPercent')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={discountPercent}
|
||||
@@ -246,7 +266,7 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
|
||||
{isTestAccess ? (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
Длительность доступа (часы)
|
||||
{t('admin.promoOffers.form.testDurationHours')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
@@ -255,11 +275,15 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">0 = по умолчанию</p>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('admin.promoOffers.form.defaultZero')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Действие скидки (часы)</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.promoOffers.form.activeDiscountHours')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={activeDiscountHours}
|
||||
@@ -267,7 +291,9 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">Сколько действует скидка после активации</p>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('admin.promoOffers.form.discountDurationHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -285,7 +311,9 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-dark-200">Шаблон активен</span>
|
||||
<span className="text-sm text-dark-200">
|
||||
{t('admin.promoOffers.form.templateActive')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -295,14 +323,14 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
Отмена
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name.trim() || isLoading}
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Сохранение...' : 'Сохранить'}
|
||||
{isLoading ? t('admin.promoOffers.form.saving') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -319,6 +347,7 @@ interface SendOfferModalProps {
|
||||
}
|
||||
|
||||
function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(
|
||||
templates[0]?.id || null,
|
||||
);
|
||||
@@ -355,7 +384,9 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
||||
<div className="rounded-lg bg-accent-500/20 p-2">
|
||||
<SendIcon />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">Отправка промопредложения</h2>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.promoOffers.send.title')}
|
||||
</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
|
||||
<XIcon />
|
||||
@@ -366,7 +397,9 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{/* Template Selection */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm text-dark-300">Шаблон предложения</label>
|
||||
<label className="mb-2 block text-sm text-dark-300">
|
||||
{t('admin.promoOffers.send.offerTemplate')}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{activeTemplates.map((template) => (
|
||||
<button
|
||||
@@ -383,10 +416,16 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
||||
<div className="flex-1">
|
||||
<div className="font-medium text-dark-100">{template.name}</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
{template.discount_percent > 0 && `${template.discount_percent}% скидка`}
|
||||
{template.offer_type === 'test_access' && 'Тестовый доступ'}
|
||||
{template.discount_percent > 0 &&
|
||||
t('admin.promoOffers.send.discountLabel', {
|
||||
percent: template.discount_percent,
|
||||
})}
|
||||
{template.offer_type === 'test_access' &&
|
||||
t('admin.promoOffers.offerType.testAccess')}
|
||||
<span className="mx-1">•</span>
|
||||
{template.valid_hours}ч на активацию
|
||||
{t('admin.promoOffers.send.hoursToActivate', {
|
||||
hours: template.valid_hours,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{selectedTemplateId === template.id && <CheckIcon />}
|
||||
@@ -398,7 +437,9 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
||||
|
||||
{/* Send Mode */}
|
||||
<div>
|
||||
<label className="mb-2 block text-sm text-dark-300">Кому отправить</label>
|
||||
<label className="mb-2 block text-sm text-dark-300">
|
||||
{t('admin.promoOffers.send.sendTo')}
|
||||
</label>
|
||||
<div className="mb-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => setSendMode('segment')}
|
||||
@@ -409,7 +450,7 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
||||
}`}
|
||||
>
|
||||
<UsersIcon />
|
||||
<span className="ml-2">Сегмент</span>
|
||||
<span className="ml-2">{t('admin.promoOffers.send.segment')}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSendMode('user')}
|
||||
@@ -420,7 +461,7 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
||||
}`}
|
||||
>
|
||||
<UserIcon />
|
||||
<span className="ml-2">Пользователь</span>
|
||||
<span className="ml-2">{t('admin.promoOffers.send.user')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -430,9 +471,9 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
||||
onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
>
|
||||
{Object.entries(TARGET_SEGMENTS).map(([key, label]) => (
|
||||
{Object.entries(TARGET_SEGMENTS).map(([key, labelKey]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
{t(labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -441,7 +482,7 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
||||
type="text"
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder="Telegram ID или User ID"
|
||||
placeholder={t('admin.promoOffers.send.userIdPlaceholder')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
@@ -450,7 +491,9 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
||||
{/* Preview */}
|
||||
{selectedTemplate && (
|
||||
<div className="rounded-lg bg-dark-700/50 p-4">
|
||||
<h4 className="mb-2 text-sm font-medium text-dark-300">Предпросмотр</h4>
|
||||
<h4 className="mb-2 text-sm font-medium text-dark-300">
|
||||
{t('admin.promoOffers.send.preview')}
|
||||
</h4>
|
||||
<div className="whitespace-pre-wrap text-sm text-dark-200">
|
||||
{selectedTemplate.message_text}
|
||||
</div>
|
||||
@@ -469,7 +512,7 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
Отмена
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
@@ -477,7 +520,9 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<SendIcon />
|
||||
{isLoading ? 'Отправка...' : 'Отправить'}
|
||||
{isLoading
|
||||
? t('admin.promoOffers.send.sending')
|
||||
: t('admin.promoOffers.send.sendButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -494,6 +539,7 @@ interface ResultModalProps {
|
||||
}
|
||||
|
||||
function ResultModal({ title, message, isSuccess, onClose }: ResultModalProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm rounded-xl bg-dark-800 p-6 text-center">
|
||||
@@ -530,7 +576,7 @@ function ResultModal({ title, message, isSuccess, onClose }: ResultModalProps) {
|
||||
onClick={onClose}
|
||||
className="rounded-lg bg-accent-500 px-6 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
Закрыть
|
||||
{t('common.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -538,6 +584,7 @@ function ResultModal({ title, message, isSuccess, onClose }: ResultModalProps) {
|
||||
}
|
||||
|
||||
export default function AdminPromoOffers() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'templates' | 'send' | 'logs'>('templates');
|
||||
@@ -577,16 +624,22 @@ export default function AdminPromoOffers() {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] });
|
||||
setShowSendModal(false);
|
||||
|
||||
let message = `Создано предложений: ${result.created_offers}`;
|
||||
let message = t('admin.promoOffers.result.offersCreated', { count: result.created_offers });
|
||||
if (result.notifications_sent > 0 || result.notifications_failed > 0) {
|
||||
message += `\nУведомлений отправлено: ${result.notifications_sent}`;
|
||||
message +=
|
||||
'\n' +
|
||||
t('admin.promoOffers.result.notificationsSent', { count: result.notifications_sent });
|
||||
if (result.notifications_failed > 0) {
|
||||
message += ` (не доставлено: ${result.notifications_failed})`;
|
||||
message +=
|
||||
' ' +
|
||||
t('admin.promoOffers.result.notificationsFailed', {
|
||||
count: result.notifications_failed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setResultModal({
|
||||
title: 'Отправлено!',
|
||||
title: t('admin.promoOffers.result.sentTitle'),
|
||||
message,
|
||||
isSuccess: true,
|
||||
});
|
||||
@@ -594,8 +647,8 @@ export default function AdminPromoOffers() {
|
||||
onError: (error: unknown) => {
|
||||
const axiosErr = error as { response?: { data?: { detail?: string } } };
|
||||
setResultModal({
|
||||
title: 'Ошибка',
|
||||
message: axiosErr.response?.data?.detail || 'Не удалось отправить предложение',
|
||||
title: t('common.error'),
|
||||
message: axiosErr.response?.data?.detail || t('admin.promoOffers.result.sendError'),
|
||||
isSuccess: false,
|
||||
});
|
||||
},
|
||||
@@ -642,8 +695,8 @@ export default function AdminPromoOffers() {
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">Промопредложения</h1>
|
||||
<p className="text-sm text-dark-400">Шаблоны, рассылка и логи предложений</p>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.promoOffers.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.promoOffers.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -651,7 +704,7 @@ export default function AdminPromoOffers() {
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<SendIcon />
|
||||
Отправить
|
||||
{t('admin.promoOffers.sendButton')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -665,7 +718,7 @@ export default function AdminPromoOffers() {
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
Шаблоны ({templates.length})
|
||||
{t('admin.promoOffers.tabs.templates', { count: templates.length })}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('logs')}
|
||||
@@ -673,7 +726,7 @@ export default function AdminPromoOffers() {
|
||||
activeTab === 'logs' ? 'bg-dark-700 text-dark-100' : 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
Логи
|
||||
{t('admin.promoOffers.tabs.logs')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -686,7 +739,7 @@ export default function AdminPromoOffers() {
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-dark-400">Нет шаблонов</p>
|
||||
<p className="text-dark-400">{t('admin.promoOffers.noData.templates')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
@@ -718,26 +771,44 @@ export default function AdminPromoOffers() {
|
||||
<div className="space-y-2 text-sm">
|
||||
{template.discount_percent > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Скидка:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.promoOffers.table.discount')}:
|
||||
</span>
|
||||
<span className="font-medium text-accent-400">
|
||||
{template.discount_percent}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Срок предложения:</span>
|
||||
<span className="text-dark-200">{template.valid_hours}ч</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.promoOffers.table.offerDuration')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{t('admin.promoOffers.table.hoursShort', { hours: template.valid_hours })}
|
||||
</span>
|
||||
</div>
|
||||
{template.active_discount_hours && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Действие скидки:</span>
|
||||
<span className="text-dark-200">{template.active_discount_hours}ч</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.promoOffers.table.discountDuration')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{t('admin.promoOffers.table.hoursShort', {
|
||||
hours: template.active_discount_hours,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{template.test_duration_hours && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Тестовый доступ:</span>
|
||||
<span className="text-dark-200">{template.test_duration_hours}ч</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.promoOffers.table.testAccess')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{t('admin.promoOffers.table.hoursShort', {
|
||||
hours: template.test_duration_hours,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -746,11 +817,11 @@ export default function AdminPromoOffers() {
|
||||
<div className="flex items-center gap-2">
|
||||
{template.is_active ? (
|
||||
<span className="rounded bg-emerald-500/20 px-2 py-0.5 text-xs text-emerald-400">
|
||||
Активен
|
||||
{t('admin.promoOffers.status.active')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||
Неактивен
|
||||
{t('admin.promoOffers.status.inactive')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -771,7 +842,7 @@ export default function AdminPromoOffers() {
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-dark-400">Нет записей</p>
|
||||
<p className="text-dark-400">{t('admin.promoOffers.noData.logs')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import i18n from '../i18n';
|
||||
import {
|
||||
promocodesApi,
|
||||
PromoCode,
|
||||
@@ -117,11 +119,11 @@ const UserIcon = () => (
|
||||
// Helper functions
|
||||
const getTypeLabel = (type: PromoCodeType): string => {
|
||||
const labels: Record<PromoCodeType, string> = {
|
||||
balance: 'Баланс',
|
||||
subscription_days: 'Дни подписки',
|
||||
trial_subscription: 'Тестовая подписка',
|
||||
promo_group: 'Группа скидок',
|
||||
discount: 'Скидка %',
|
||||
balance: i18n.t('admin.promocodes.type.balance'),
|
||||
subscription_days: i18n.t('admin.promocodes.type.subscriptionDays'),
|
||||
trial_subscription: i18n.t('admin.promocodes.type.trialSubscription'),
|
||||
promo_group: i18n.t('admin.promocodes.type.promoGroup'),
|
||||
discount: i18n.t('admin.promocodes.type.discount'),
|
||||
};
|
||||
return labels[type] || type;
|
||||
};
|
||||
@@ -139,7 +141,9 @@ const getTypeColor = (type: PromoCodeType): string => {
|
||||
|
||||
const formatDate = (date: string | null): string => {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleDateString('ru-RU', {
|
||||
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
return new Date(date).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
@@ -148,7 +152,9 @@ const formatDate = (date: string | null): string => {
|
||||
|
||||
const formatDateTime = (date: string | null): string => {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleString('ru-RU', {
|
||||
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
return new Date(date).toLocaleString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
@@ -173,6 +179,7 @@ function PromocodeModal({
|
||||
onClose,
|
||||
isLoading,
|
||||
}: PromocodeModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = !!promocode;
|
||||
|
||||
const [code, setCode] = useState(promocode?.code || '');
|
||||
@@ -233,7 +240,9 @@ function PromocodeModal({
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{isEdit ? 'Редактирование промокода' : 'Новый промокод'}
|
||||
{isEdit
|
||||
? t('admin.promocodes.modal.editPromocode')
|
||||
: t('admin.promocodes.modal.newPromocode')}
|
||||
</h2>
|
||||
<button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
|
||||
<XIcon />
|
||||
@@ -244,7 +253,9 @@ function PromocodeModal({
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{/* Code */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Код промокода</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.promocodes.form.code')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={code}
|
||||
@@ -256,24 +267,32 @@ function PromocodeModal({
|
||||
|
||||
{/* Type */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Тип промокода</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.promocodes.form.type')}
|
||||
</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as PromoCodeType)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
>
|
||||
<option value="balance">Пополнение баланса</option>
|
||||
<option value="subscription_days">Дни подписки</option>
|
||||
<option value="trial_subscription">Тестовая подписка</option>
|
||||
<option value="promo_group">Группа скидок</option>
|
||||
<option value="discount">Процентная скидка</option>
|
||||
<option value="balance">{t('admin.promocodes.form.typeBalance')}</option>
|
||||
<option value="subscription_days">
|
||||
{t('admin.promocodes.form.typeSubscriptionDays')}
|
||||
</option>
|
||||
<option value="trial_subscription">
|
||||
{t('admin.promocodes.form.typeTrialSubscription')}
|
||||
</option>
|
||||
<option value="promo_group">{t('admin.promocodes.form.typePromoGroup')}</option>
|
||||
<option value="discount">{t('admin.promocodes.form.typeDiscount')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Type-specific fields */}
|
||||
{type === 'balance' && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Сумма бонуса</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.promocodes.form.bonusAmount')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
@@ -285,14 +304,16 @@ function PromocodeModal({
|
||||
min={0}
|
||||
step={1}
|
||||
/>
|
||||
<span className="text-dark-400">руб.</span>
|
||||
<span className="text-dark-400">{t('admin.promocodes.form.rub')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(type === 'subscription_days' || type === 'trial_subscription') && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Количество дней</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.promocodes.form.daysCount')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
@@ -301,20 +322,22 @@ function PromocodeModal({
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={1}
|
||||
/>
|
||||
<span className="text-dark-400">дней</span>
|
||||
<span className="text-dark-400">{t('admin.promocodes.form.days')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{type === 'promo_group' && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Группа скидок</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.promocodes.form.discountGroup')}
|
||||
</label>
|
||||
<select
|
||||
value={promoGroupId || ''}
|
||||
onChange={(e) => setPromoGroupId(e.target.value ? parseInt(e.target.value) : null)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
>
|
||||
<option value="">Выберите группу...</option>
|
||||
<option value="">{t('admin.promocodes.form.selectGroup')}</option>
|
||||
{promoGroups.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
@@ -327,7 +350,9 @@ function PromocodeModal({
|
||||
{type === 'discount' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Процент скидки</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.promocodes.form.discountPercent')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
@@ -343,10 +368,14 @@ function PromocodeModal({
|
||||
/>
|
||||
<span className="text-dark-400">%</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-dark-500">Скидка применяется один раз при оплате</p>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('admin.promocodes.form.discountHint')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Время действия</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.promocodes.form.validityPeriod')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
@@ -357,10 +386,10 @@ function PromocodeModal({
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={1}
|
||||
/>
|
||||
<span className="text-dark-400">часов</span>
|
||||
<span className="text-dark-400">{t('admin.promocodes.form.hours')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
Сколько часов после активации скидка действует
|
||||
{t('admin.promocodes.form.validityHint')}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
@@ -368,7 +397,9 @@ function PromocodeModal({
|
||||
|
||||
{/* Max Uses */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Макс. использований</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.promocodes.form.maxUses')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
@@ -377,20 +408,26 @@ function PromocodeModal({
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
/>
|
||||
<span className="text-xs text-dark-500">0 = безлимит</span>
|
||||
<span className="text-xs text-dark-500">
|
||||
{t('admin.promocodes.form.unlimitedHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Valid Until */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Действует до</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.promocodes.form.validUntil')}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={validUntil}
|
||||
onChange={(e) => setValidUntil(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">Оставьте пустым для бессрочного промокода</p>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('admin.promocodes.form.validUntilHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
@@ -409,7 +446,7 @@ function PromocodeModal({
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-dark-200">Активен</span>
|
||||
<span className="text-sm text-dark-200">{t('admin.promocodes.form.active')}</span>
|
||||
</label>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
@@ -426,7 +463,9 @@ function PromocodeModal({
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-dark-200">Только для первой покупки</span>
|
||||
<span className="text-sm text-dark-200">
|
||||
{t('admin.promocodes.form.firstPurchaseOnly')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -437,14 +476,14 @@ function PromocodeModal({
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
Отмена
|
||||
{t('admin.promocodes.form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid() || isLoading}
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Сохранение...' : 'Сохранить'}
|
||||
{isLoading ? t('admin.promocodes.form.saving') : t('admin.promocodes.form.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -466,6 +505,7 @@ interface PeriodDiscount {
|
||||
}
|
||||
|
||||
function PromoGroupModal({ group, onSave, onClose, isLoading }: PromoGroupModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = !!group;
|
||||
|
||||
const [name, setName] = useState(group?.name || '');
|
||||
@@ -531,7 +571,7 @@ function PromoGroupModal({ group, onSave, onClose, isLoading }: PromoGroupModalP
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{isEdit ? 'Редактирование группы' : 'Новая группа скидок'}
|
||||
{isEdit ? t('admin.promocodes.modal.editGroup') : t('admin.promocodes.modal.newGroup')}
|
||||
</h2>
|
||||
<button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
|
||||
<XIcon />
|
||||
@@ -542,22 +582,28 @@ function PromoGroupModal({ group, onSave, onClose, isLoading }: PromoGroupModalP
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Название группы</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.promocodes.groups.name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
placeholder="VIP клиенты"
|
||||
placeholder={t('admin.promocodes.groups.namePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category Discounts */}
|
||||
<div className="space-y-3 rounded-lg bg-dark-700/50 p-4">
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-200">Скидки по категориям</h4>
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-200">
|
||||
{t('admin.promocodes.groups.categoryDiscounts')}
|
||||
</h4>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-32 text-sm text-dark-400">На серверы:</span>
|
||||
<span className="w-32 text-sm text-dark-400">
|
||||
{t('admin.promocodes.groups.servers')}:
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={serverDiscount}
|
||||
@@ -572,7 +618,9 @@ function PromoGroupModal({ group, onSave, onClose, isLoading }: PromoGroupModalP
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-32 text-sm text-dark-400">На трафик:</span>
|
||||
<span className="w-32 text-sm text-dark-400">
|
||||
{t('admin.promocodes.groups.traffic')}:
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={trafficDiscount}
|
||||
@@ -587,7 +635,9 @@ function PromoGroupModal({ group, onSave, onClose, isLoading }: PromoGroupModalP
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-32 text-sm text-dark-400">На устройства:</span>
|
||||
<span className="w-32 text-sm text-dark-400">
|
||||
{t('admin.promocodes.groups.devices')}:
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={deviceDiscount}
|
||||
@@ -605,22 +655,26 @@ function PromoGroupModal({ group, onSave, onClose, isLoading }: PromoGroupModalP
|
||||
{/* Period Discounts */}
|
||||
<div className="space-y-3 rounded-lg bg-dark-700/50 p-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-dark-200">Скидки по периодам</h4>
|
||||
<h4 className="text-sm font-medium text-dark-200">
|
||||
{t('admin.promocodes.groups.periodDiscounts')}
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addPeriodDiscount}
|
||||
className="flex items-center gap-1 rounded bg-accent-500/20 px-2 py-1 text-xs text-accent-400 transition-colors hover:bg-accent-500/30"
|
||||
>
|
||||
<PlusIcon />
|
||||
Добавить
|
||||
{t('admin.promocodes.groups.add')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mb-3 text-xs text-dark-500">
|
||||
Скидка применяется при покупке подписки на указанное кол-во дней
|
||||
{t('admin.promocodes.groups.periodDiscountsHint')}
|
||||
</p>
|
||||
|
||||
{periodDiscounts.length === 0 ? (
|
||||
<p className="py-2 text-center text-sm text-dark-500">Нет скидок по периодам</p>
|
||||
<p className="py-2 text-center text-sm text-dark-500">
|
||||
{t('admin.promocodes.groups.noPeriodDiscounts')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{periodDiscounts.map((pd, index) => (
|
||||
@@ -637,9 +691,11 @@ function PromoGroupModal({ group, onSave, onClose, isLoading }: PromoGroupModalP
|
||||
}
|
||||
className="w-20 rounded border border-dark-500 bg-dark-600 px-2 py-1 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={1}
|
||||
placeholder="Дни"
|
||||
placeholder={t('admin.promocodes.groups.daysPlaceholder')}
|
||||
/>
|
||||
<span className="text-xs text-dark-400">дней →</span>
|
||||
<span className="text-xs text-dark-400">
|
||||
{t('admin.promocodes.groups.daysArrow')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={pd.percent}
|
||||
@@ -672,7 +728,7 @@ function PromoGroupModal({ group, onSave, onClose, isLoading }: PromoGroupModalP
|
||||
{/* Auto-assign */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
Авто-назначение при тратах от
|
||||
{t('admin.promocodes.groups.autoAssign')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
@@ -682,9 +738,11 @@ function PromoGroupModal({ group, onSave, onClose, isLoading }: PromoGroupModalP
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
/>
|
||||
<span className="text-dark-400">руб.</span>
|
||||
<span className="text-dark-400">{t('admin.promocodes.form.rub')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-dark-500">0 = не назначать автоматически</p>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('admin.promocodes.groups.autoAssignHint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Apply to addons */}
|
||||
@@ -702,7 +760,9 @@ function PromoGroupModal({ group, onSave, onClose, isLoading }: PromoGroupModalP
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
<span className="text-sm text-dark-200">Применять к дополнительным услугам</span>
|
||||
<span className="text-sm text-dark-200">
|
||||
{t('admin.promocodes.groups.applyToAddons')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -712,14 +772,14 @@ function PromoGroupModal({ group, onSave, onClose, isLoading }: PromoGroupModalP
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
Отмена
|
||||
{t('admin.promocodes.form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name.trim() || isLoading}
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Сохранение...' : 'Сохранить'}
|
||||
{isLoading ? t('admin.promocodes.form.saving') : t('admin.promocodes.form.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -735,6 +795,7 @@ interface PromocodeStatsModalProps {
|
||||
}
|
||||
|
||||
function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-dark-800">
|
||||
@@ -751,7 +812,7 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal
|
||||
</span>
|
||||
{!promocode.is_active && (
|
||||
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||
Неактивен
|
||||
{t('admin.promocodes.stats.inactive')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -766,79 +827,95 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="rounded-xl bg-dark-700/50 p-4 text-center">
|
||||
<div className="mb-1 text-3xl font-bold text-dark-100">{promocode.total_uses}</div>
|
||||
<div className="text-sm text-dark-400">Всего использований</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.promocodes.stats.totalUses')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-700/50 p-4 text-center">
|
||||
<div className="mb-1 text-3xl font-bold text-emerald-400">{promocode.today_uses}</div>
|
||||
<div className="text-sm text-dark-400">Сегодня</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.promocodes.stats.today')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-700/50 p-4 text-center">
|
||||
<div className="mb-1 text-3xl font-bold text-accent-400">
|
||||
{promocode.max_uses === 0 ? '∞' : promocode.uses_left}
|
||||
</div>
|
||||
<div className="text-sm text-dark-400">Осталось</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.promocodes.stats.remaining')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="space-y-3 rounded-xl bg-dark-700/50 p-4">
|
||||
<h4 className="mb-3 font-medium text-dark-200">Детали промокода</h4>
|
||||
<h4 className="mb-3 font-medium text-dark-200">
|
||||
{t('admin.promocodes.stats.details')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Тип:</span>
|
||||
<span className="text-dark-400">{t('admin.promocodes.stats.type')}:</span>
|
||||
<span className="text-dark-200">{getTypeLabel(promocode.type)}</span>
|
||||
</div>
|
||||
{promocode.type === 'balance' && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Бонус:</span>
|
||||
<span className="text-emerald-400">+{promocode.balance_bonus_rubles} руб.</span>
|
||||
<span className="text-dark-400">{t('admin.promocodes.stats.bonus')}:</span>
|
||||
<span className="text-emerald-400">
|
||||
+{promocode.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{(promocode.type === 'subscription_days' ||
|
||||
promocode.type === 'trial_subscription') && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Дней:</span>
|
||||
<span className="text-dark-400">{t('admin.promocodes.stats.daysLabel')}:</span>
|
||||
<span className="text-blue-400">+{promocode.subscription_days}</span>
|
||||
</div>
|
||||
)}
|
||||
{promocode.type === 'discount' && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Скидка:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.promocodes.stats.discountLabel')}:
|
||||
</span>
|
||||
<span className="text-pink-400">-{promocode.balance_bonus_kopeks}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Действует:</span>
|
||||
<span className="text-pink-400">{promocode.subscription_days} часов</span>
|
||||
<span className="text-dark-400">{t('admin.promocodes.stats.validFor')}:</span>
|
||||
<span className="text-pink-400">
|
||||
{t('admin.promocodes.stats.hoursValue', {
|
||||
count: promocode.subscription_days,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Лимит:</span>
|
||||
<span className="text-dark-400">{t('admin.promocodes.stats.limit')}:</span>
|
||||
<span className="text-dark-200">
|
||||
{promocode.current_uses}/{promocode.max_uses === 0 ? '∞' : promocode.max_uses}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Статус:</span>
|
||||
<span className="text-dark-400">{t('admin.promocodes.stats.status')}:</span>
|
||||
<span className={promocode.is_valid ? 'text-emerald-400' : 'text-error-400'}>
|
||||
{promocode.is_valid ? 'Активен' : 'Неактивен'}
|
||||
{promocode.is_valid
|
||||
? t('admin.promocodes.stats.active')
|
||||
: t('admin.promocodes.stats.inactive')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Создан:</span>
|
||||
<span className="text-dark-400">{t('admin.promocodes.stats.created')}:</span>
|
||||
<span className="text-dark-200">{formatDateTime(promocode.created_at)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Действует до:</span>
|
||||
<span className="text-dark-400">{t('admin.promocodes.stats.validUntil')}:</span>
|
||||
<span className="text-dark-200">
|
||||
{promocode.valid_until ? formatDate(promocode.valid_until) : 'Бессрочно'}
|
||||
{promocode.valid_until
|
||||
? formatDate(promocode.valid_until)
|
||||
: t('admin.promocodes.stats.unlimited')}
|
||||
</span>
|
||||
</div>
|
||||
{promocode.first_purchase_only && (
|
||||
<div className="col-span-2 flex justify-between">
|
||||
<span className="text-dark-400">Ограничение:</span>
|
||||
<span className="text-amber-400">Только первая покупка</span>
|
||||
<span className="text-dark-400">{t('admin.promocodes.stats.restriction')}:</span>
|
||||
<span className="text-amber-400">
|
||||
{t('admin.promocodes.stats.firstPurchaseOnly')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -848,11 +925,11 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal
|
||||
<div className="rounded-xl bg-dark-700/50 p-4">
|
||||
<h4 className="mb-3 flex items-center gap-2 font-medium text-dark-200">
|
||||
<ClockIcon />
|
||||
История использования
|
||||
{t('admin.promocodes.stats.usageHistory')}
|
||||
</h4>
|
||||
{promocode.recent_uses.length === 0 ? (
|
||||
<p className="py-4 text-center text-sm text-dark-500">
|
||||
Промокод еще не использовался
|
||||
{t('admin.promocodes.stats.noUsages')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-64 space-y-2 overflow-y-auto">
|
||||
@@ -888,14 +965,14 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
Закрыть
|
||||
{t('admin.promocodes.modal.close')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<EditIcon />
|
||||
Редактировать
|
||||
{t('admin.promocodes.modal.edit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -904,6 +981,7 @@ function PromocodeStatsModal({ promocode, onClose, onEdit }: PromocodeStatsModal
|
||||
}
|
||||
|
||||
export default function AdminPromocodes() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'promocodes' | 'groups'>('promocodes');
|
||||
@@ -1048,8 +1126,8 @@ export default function AdminPromocodes() {
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-dark-100">Промокоды</h1>
|
||||
<p className="text-sm text-dark-400">Управление промокодами и группами скидок</p>
|
||||
<h1 className="text-xl font-semibold text-dark-100">{t('admin.promocodes.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.promocodes.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -1065,7 +1143,9 @@ export default function AdminPromocodes() {
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<PlusIcon />
|
||||
{activeTab === 'promocodes' ? 'Добавить промокод' : 'Добавить группу'}
|
||||
{activeTab === 'promocodes'
|
||||
? t('admin.promocodes.addPromocode')
|
||||
: t('admin.promocodes.addGroup')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1074,25 +1154,27 @@ export default function AdminPromocodes() {
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-dark-100">{promocodes.length}</div>
|
||||
<div className="text-xs text-dark-400">Всего промокодов</div>
|
||||
<div className="text-xs text-dark-400">
|
||||
{t('admin.promocodes.stats.totalPromocodes')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-emerald-400">
|
||||
{promocodes.filter((p) => p.is_active && p.is_valid).length}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400">Активных</div>
|
||||
<div className="text-xs text-dark-400">{t('admin.promocodes.stats.activeCount')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-accent-400">
|
||||
{promocodes.reduce((sum, p) => sum + p.current_uses, 0)}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400">Использований</div>
|
||||
<div className="text-xs text-dark-400">{t('admin.promocodes.stats.usagesCount')}</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="text-2xl font-bold text-amber-400">
|
||||
{promocodes.filter((p) => p.uses_left === 0 && p.max_uses > 0).length}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400">Исчерпано</div>
|
||||
<div className="text-xs text-dark-400">{t('admin.promocodes.stats.exhausted')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1107,7 +1189,7 @@ export default function AdminPromocodes() {
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
Промокоды ({promocodes.length})
|
||||
{t('admin.promocodes.tabs.promocodes', { count: promocodes.length })}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('groups')}
|
||||
@@ -1117,7 +1199,7 @@ export default function AdminPromocodes() {
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
Группы скидок ({groups.length})
|
||||
{t('admin.promocodes.tabs.groups', { count: groups.length })}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1130,7 +1212,7 @@ export default function AdminPromocodes() {
|
||||
</div>
|
||||
) : promocodes.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-dark-400">Нет промокодов</p>
|
||||
<p className="text-dark-400">{t('admin.promocodes.noPromocodes')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -1156,35 +1238,44 @@ export default function AdminPromocodes() {
|
||||
</span>
|
||||
{!promo.is_active && (
|
||||
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||
Неактивен
|
||||
{t('admin.promocodes.stats.inactive')}
|
||||
</span>
|
||||
)}
|
||||
{promo.first_purchase_only && (
|
||||
<span className="rounded bg-amber-500/20 px-2 py-0.5 text-xs text-amber-400">
|
||||
Первая покупка
|
||||
{t('admin.promocodes.firstPurchase')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
|
||||
{promo.type === 'balance' && (
|
||||
<span className="text-emerald-400">
|
||||
+{promo.balance_bonus_rubles} руб.
|
||||
+{promo.balance_bonus_rubles} {t('admin.promocodes.form.rub')}
|
||||
</span>
|
||||
)}
|
||||
{(promo.type === 'subscription_days' ||
|
||||
promo.type === 'trial_subscription') && (
|
||||
<span className="text-blue-400">+{promo.subscription_days} дней</span>
|
||||
<span className="text-blue-400">
|
||||
+{promo.subscription_days} {t('admin.promocodes.form.days')}
|
||||
</span>
|
||||
)}
|
||||
{promo.type === 'discount' && (
|
||||
<span className="text-pink-400">
|
||||
-{promo.balance_bonus_kopeks}% на {promo.subscription_days}ч
|
||||
{t('admin.promocodes.discountForHours', {
|
||||
percent: promo.balance_bonus_kopeks,
|
||||
hours: promo.subscription_days,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
Использовано: {promo.current_uses}/
|
||||
{t('admin.promocodes.used')}: {promo.current_uses}/
|
||||
{promo.max_uses === 0 ? '∞' : promo.max_uses}
|
||||
</span>
|
||||
{promo.valid_until && <span>До: {formatDate(promo.valid_until)}</span>}
|
||||
{promo.valid_until && (
|
||||
<span>
|
||||
{t('admin.promocodes.until')}: {formatDate(promo.valid_until)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1192,21 +1283,21 @@ export default function AdminPromocodes() {
|
||||
<button
|
||||
onClick={() => handleViewStats(promo.id)}
|
||||
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-accent-500/20 hover:text-accent-400"
|
||||
title="Статистика"
|
||||
title={t('admin.promocodes.actions.stats')}
|
||||
>
|
||||
<ChartIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEditPromocode(promo.id)}
|
||||
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||
title="Редактировать"
|
||||
title={t('admin.promocodes.actions.edit')}
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm({ type: 'promocode', id: promo.id })}
|
||||
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-error-500/20 hover:text-error-400"
|
||||
title="Удалить"
|
||||
title={t('admin.promocodes.actions.delete')}
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
@@ -1228,7 +1319,7 @@ export default function AdminPromocodes() {
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-dark-400">Нет групп скидок</p>
|
||||
<p className="text-dark-400">{t('admin.promocodes.noGroups')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -1240,36 +1331,47 @@ export default function AdminPromocodes() {
|
||||
<h3 className="font-medium text-dark-100">{group.name}</h3>
|
||||
{group.is_default && (
|
||||
<span className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
|
||||
По умолчанию
|
||||
{t('admin.promocodes.groups.default')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
|
||||
{group.server_discount_percent > 0 && (
|
||||
<span>Серверы: -{group.server_discount_percent}%</span>
|
||||
<span>
|
||||
{t('admin.promocodes.groups.servers')}: -{group.server_discount_percent}
|
||||
%
|
||||
</span>
|
||||
)}
|
||||
{group.traffic_discount_percent > 0 && (
|
||||
<span>Трафик: -{group.traffic_discount_percent}%</span>
|
||||
<span>
|
||||
{t('admin.promocodes.groups.traffic')}: -
|
||||
{group.traffic_discount_percent}%
|
||||
</span>
|
||||
)}
|
||||
{group.device_discount_percent > 0 && (
|
||||
<span>Устройства: -{group.device_discount_percent}%</span>
|
||||
<span>
|
||||
{t('admin.promocodes.groups.devices')}: -{group.device_discount_percent}
|
||||
%
|
||||
</span>
|
||||
)}
|
||||
{group.period_discounts &&
|
||||
Object.keys(group.period_discounts).length > 0 &&
|
||||
Object.entries(group.period_discounts).map(([days, percent]) => (
|
||||
<span key={days} className="text-accent-400">
|
||||
{days} дн.: -{percent}%
|
||||
{t('admin.promocodes.groups.daysShort', { days })}: -{percent}%
|
||||
</span>
|
||||
))}
|
||||
{group.auto_assign_total_spent_kopeks &&
|
||||
group.auto_assign_total_spent_kopeks > 0 && (
|
||||
<span className="text-amber-400">
|
||||
Авто от {group.auto_assign_total_spent_kopeks / 100} руб.
|
||||
{t('admin.promocodes.groups.autoFrom', {
|
||||
amount: group.auto_assign_total_spent_kopeks / 100,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<UsersIcon />
|
||||
{group.members_count} участников
|
||||
{t('admin.promocodes.groups.members', { count: group.members_count })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1281,14 +1383,14 @@ export default function AdminPromocodes() {
|
||||
setShowGroupModal(true);
|
||||
}}
|
||||
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||
title="Редактировать"
|
||||
title={t('admin.promocodes.actions.edit')}
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm({ type: 'group', id: group.id })}
|
||||
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-error-500/20 hover:text-error-400"
|
||||
title="Удалить"
|
||||
title={t('admin.promocodes.actions.delete')}
|
||||
disabled={group.is_default}
|
||||
>
|
||||
<TrashIcon />
|
||||
@@ -1347,25 +1449,27 @@ export default function AdminPromocodes() {
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm rounded-xl bg-dark-800 p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">
|
||||
{deleteConfirm.type === 'promocode' ? 'Удалить промокод?' : 'Удалить группу?'}
|
||||
{deleteConfirm.type === 'promocode'
|
||||
? t('admin.promocodes.confirm.deletePromocode')
|
||||
: t('admin.promocodes.confirm.deleteGroup')}
|
||||
</h3>
|
||||
<p className="mb-6 text-dark-400">
|
||||
{deleteConfirm.type === 'promocode'
|
||||
? 'Промокод будет удален безвозвратно.'
|
||||
: 'Группа будет удалена. Пользователи потеряют скидки этой группы.'}
|
||||
? t('admin.promocodes.confirm.deletePromocodeText')
|
||||
: t('admin.promocodes.confirm.deleteGroupText')}
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
Отмена
|
||||
{t('admin.promocodes.form.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="rounded-lg bg-error-500 px-4 py-2 text-white transition-colors hover:bg-error-600"
|
||||
>
|
||||
Удалить
|
||||
{t('admin.promocodes.confirm.deleteButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,11 +100,12 @@ interface TariffTypeSelectProps {
|
||||
}
|
||||
|
||||
function TariffTypeSelect({ onSelect, onClose }: TariffTypeSelectProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-xl bg-dark-800">
|
||||
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||
<h2 className="text-lg font-semibold text-dark-100">Выберите тип тарифа</h2>
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('admin.tariffs.selectType')}</h2>
|
||||
<button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
|
||||
<XIcon />
|
||||
</button>
|
||||
@@ -119,10 +120,8 @@ function TariffTypeSelect({ onSelect, onClose }: TariffTypeSelectProps) {
|
||||
<CalendarIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-dark-100">Периодный тариф</h3>
|
||||
<p className="mt-1 text-sm text-dark-400">
|
||||
Оплата за период (7, 30, 90 дней и т.д.). Произвольные периоды и цены.
|
||||
</p>
|
||||
<h3 className="font-medium text-dark-100">{t('admin.tariffs.periodTariff')}</h3>
|
||||
<p className="mt-1 text-sm text-dark-400">{t('admin.tariffs.periodTariffDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -135,10 +134,8 @@ function TariffTypeSelect({ onSelect, onClose }: TariffTypeSelectProps) {
|
||||
<SunIcon />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-dark-100">Суточный тариф</h3>
|
||||
<p className="mt-1 text-sm text-dark-400">
|
||||
Ежедневное списание с баланса. Можно ставить на паузу.
|
||||
</p>
|
||||
<h3 className="font-medium text-dark-100">{t('admin.tariffs.dailyTariff')}</h3>
|
||||
<p className="mt-1 text-sm text-dark-400">{t('admin.tariffs.dailyTariffDesc')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -164,6 +161,7 @@ function PeriodTariffModal({
|
||||
onClose,
|
||||
isLoading,
|
||||
}: PeriodTariffModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = !!tariff;
|
||||
|
||||
const [name, setName] = useState(tariff?.name || '');
|
||||
@@ -286,9 +284,9 @@ function PeriodTariffModal({
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{isEdit ? 'Редактирование тарифа' : 'Новый периодный тариф'}
|
||||
{isEdit ? t('admin.tariffs.editTitle') : t('admin.tariffs.newPeriodTitle')}
|
||||
</h2>
|
||||
<p className="text-xs text-dark-500">Оплата за период</p>
|
||||
<p className="text-xs text-dark-500">{t('admin.tariffs.periodPayment')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
|
||||
@@ -308,10 +306,10 @@ function PeriodTariffModal({
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{tab === 'basic' && 'Основное'}
|
||||
{tab === 'periods' && 'Периоды'}
|
||||
{tab === 'servers' && 'Серверы'}
|
||||
{tab === 'extra' && 'Дополнительно'}
|
||||
{tab === 'basic' && t('admin.tariffs.tabBasic')}
|
||||
{tab === 'periods' && t('admin.tariffs.tabPeriods')}
|
||||
{tab === 'servers' && t('admin.tariffs.tabServers')}
|
||||
{tab === 'extra' && t('admin.tariffs.tabExtra')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -322,31 +320,37 @@ function PeriodTariffModal({
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Название тарифа</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.tariffs.nameLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
placeholder="Например: Стандарт"
|
||||
placeholder={t('admin.tariffs.nameExamplePeriod')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Описание</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.tariffs.descriptionLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full resize-none rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
rows={2}
|
||||
placeholder="Краткое описание тарифа"
|
||||
placeholder={t('admin.tariffs.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Traffic Limit */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Лимит трафика</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.tariffs.trafficLimitLabel')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
@@ -355,20 +359,22 @@ function PeriodTariffModal({
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
/>
|
||||
<span className="text-dark-400">ГБ</span>
|
||||
<span className="text-dark-400">{t('admin.tariffs.gbUnit')}</span>
|
||||
{trafficLimitGb === 0 && (
|
||||
<span className="flex items-center gap-1 text-sm text-success-500">
|
||||
<InfinityIcon />
|
||||
Безлимит
|
||||
{t('admin.tariffs.unlimited')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-dark-500">0 = безлимитный трафик</p>
|
||||
<p className="mt-1 text-xs text-dark-500">{t('admin.tariffs.trafficLimitHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Device Limit */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Устройств в тарифе</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.tariffs.deviceLimitLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={deviceLimit}
|
||||
@@ -380,7 +386,9 @@ function PeriodTariffModal({
|
||||
|
||||
{/* Tier Level */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Уровень тарифа</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.tariffs.tierLevelLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={tierLevel}
|
||||
@@ -391,26 +399,25 @@ function PeriodTariffModal({
|
||||
min={1}
|
||||
max={10}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
Влияет на доступность перехода между тарифами
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-dark-500">{t('admin.tariffs.tierLevelHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'periods' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-dark-400">
|
||||
Добавьте периоды и цены для тарифа. Пользователи смогут выбирать из добавленных
|
||||
периодов.
|
||||
</p>
|
||||
<p className="text-sm text-dark-400">{t('admin.tariffs.periodsTabHint')}</p>
|
||||
|
||||
{/* Add new period */}
|
||||
<div className="rounded-lg border border-dashed border-dark-600 bg-dark-700/50 p-4">
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-300">Добавить период</h4>
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-300">
|
||||
{t('admin.tariffs.addPeriodTitle')}
|
||||
</h4>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">Дней</label>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.tariffs.daysLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newPeriodDays}
|
||||
@@ -420,7 +427,9 @@ function PeriodTariffModal({
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-dark-500">Цена (₽)</label>
|
||||
<label className="mb-1 block text-xs text-dark-500">
|
||||
{t('admin.tariffs.priceLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newPeriodPrice}
|
||||
@@ -437,7 +446,7 @@ function PeriodTariffModal({
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon />
|
||||
Добавить
|
||||
{t('admin.tariffs.addButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -445,7 +454,7 @@ function PeriodTariffModal({
|
||||
{/* Period list */}
|
||||
{periodPrices.length === 0 ? (
|
||||
<div className="py-8 text-center text-dark-500">
|
||||
Нет добавленных периодов. Добавьте хотя бы один период.
|
||||
{t('admin.tariffs.noPeriodsHint')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -454,7 +463,9 @@ function PeriodTariffModal({
|
||||
key={period.days}
|
||||
className="flex items-center gap-3 rounded-lg bg-dark-700/50 p-3"
|
||||
>
|
||||
<div className="w-20 font-medium text-dark-300">{period.days} дн.</div>
|
||||
<div className="w-20 font-medium text-dark-300">
|
||||
{period.days} {t('admin.tariffs.daysShort')}
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
value={period.price_kopeks / 100}
|
||||
@@ -485,11 +496,11 @@ function PeriodTariffModal({
|
||||
|
||||
{activeTab === 'servers' && (
|
||||
<div className="space-y-2">
|
||||
<p className="mb-4 text-sm text-dark-400">
|
||||
Выберите серверы, доступные на этом тарифе.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-dark-400">{t('admin.tariffs.serversTabHint')}</p>
|
||||
{servers.length === 0 ? (
|
||||
<p className="py-4 text-center text-dark-500">Нет доступных серверов</p>
|
||||
<p className="py-4 text-center text-dark-500">
|
||||
{t('admin.tariffs.noServersAvailable')}
|
||||
</p>
|
||||
) : (
|
||||
servers.map((server) => {
|
||||
const isSelected = selectedSquads.includes(server.squad_uuid);
|
||||
@@ -527,11 +538,13 @@ function PeriodTariffModal({
|
||||
<div className="space-y-6">
|
||||
{/* Докупка устройств */}
|
||||
<div className="rounded-lg bg-dark-700/50 p-4">
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-200">Докупка устройств</h4>
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-200">
|
||||
{t('admin.tariffs.extraDeviceTitle')}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-48 text-sm text-dark-400">
|
||||
Цена за устройство (30 дней):
|
||||
{t('admin.tariffs.devicePriceLabel')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
@@ -545,9 +558,11 @@ function PeriodTariffModal({
|
||||
/>
|
||||
<span className="text-dark-400">₽</span>
|
||||
</div>
|
||||
<p className="text-xs text-dark-500">0 = докупка недоступна</p>
|
||||
<p className="text-xs text-dark-500">{t('admin.tariffs.devicePriceHint')}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-48 text-sm text-dark-400">Макс. устройств на тарифе:</span>
|
||||
<span className="w-48 text-sm text-dark-400">
|
||||
{t('admin.tariffs.maxDeviceLabel')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={maxDeviceLimit}
|
||||
@@ -558,14 +573,16 @@ function PeriodTariffModal({
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-dark-500">0 = без ограничений</p>
|
||||
<p className="text-xs text-dark-500">{t('admin.tariffs.noLimitHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Докупка трафика */}
|
||||
<div className="rounded-lg bg-dark-700/50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-dark-200">Докупка трафика</h4>
|
||||
<h4 className="text-sm font-medium text-dark-200">
|
||||
{t('admin.tariffs.extraTrafficTitle')}
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrafficTopupEnabled(!trafficTopupEnabled)}
|
||||
@@ -583,7 +600,9 @@ function PeriodTariffModal({
|
||||
{trafficTopupEnabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-32 text-sm text-dark-400">Макс. лимит:</span>
|
||||
<span className="w-32 text-sm text-dark-400">
|
||||
{t('admin.tariffs.trafficMaxLimitLabel')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={maxTopupTrafficGb}
|
||||
@@ -593,15 +612,21 @@ function PeriodTariffModal({
|
||||
className="w-24 rounded-lg border border-dark-500 bg-dark-600 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
/>
|
||||
<span className="text-dark-400">ГБ</span>
|
||||
<span className="text-xs text-dark-500">(0 = без ограничений)</span>
|
||||
<span className="text-dark-400">{t('admin.tariffs.gbUnit')}</span>
|
||||
<span className="text-xs text-dark-500">
|
||||
{t('admin.tariffs.trafficLimitHint2')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<span className="text-sm text-dark-400">Пакеты трафика:</span>
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('admin.tariffs.trafficPackagesLabel')}
|
||||
</span>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{[5, 10, 20, 50].map((gb) => (
|
||||
<div key={gb} className="flex items-center gap-2">
|
||||
<span className="w-12 text-sm text-dark-300">{gb} ГБ:</span>
|
||||
<span className="w-12 text-sm text-dark-300">
|
||||
{gb} {t('admin.tariffs.gbPackageUnit')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={(trafficTopupPackages[String(gb)] || 0) / 100}
|
||||
@@ -630,10 +655,10 @@ function PeriodTariffModal({
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-dark-200">
|
||||
Произвольное количество дней
|
||||
{t('admin.tariffs.customDaysTitle')}
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
Пользователь сам выбирает срок подписки
|
||||
{t('admin.tariffs.customDaysDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -653,7 +678,9 @@ function PeriodTariffModal({
|
||||
{customDaysEnabled && (
|
||||
<div className="space-y-3 border-t border-dark-600 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-32 text-sm text-dark-400">Цена за день:</span>
|
||||
<span className="w-32 text-sm text-dark-400">
|
||||
{t('admin.tariffs.pricePerDayLabel')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={pricePerDayKopeks / 100}
|
||||
@@ -667,7 +694,9 @@ function PeriodTariffModal({
|
||||
<span className="text-dark-400">₽</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-32 text-sm text-dark-400">Мин. дней:</span>
|
||||
<span className="w-32 text-sm text-dark-400">
|
||||
{t('admin.tariffs.minDaysLabel')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={minDays}
|
||||
@@ -677,7 +706,9 @@ function PeriodTariffModal({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-32 text-sm text-dark-400">Макс. дней:</span>
|
||||
<span className="w-32 text-sm text-dark-400">
|
||||
{t('admin.tariffs.maxDaysLabel')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={maxDays}
|
||||
@@ -695,10 +726,10 @@ function PeriodTariffModal({
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-dark-200">
|
||||
Произвольный объём трафика
|
||||
{t('admin.tariffs.customTrafficTitle')}
|
||||
</h4>
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
Пользователь сам выбирает объём трафика при покупке
|
||||
{t('admin.tariffs.customTrafficDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -718,7 +749,9 @@ function PeriodTariffModal({
|
||||
{customTrafficEnabled && (
|
||||
<div className="space-y-3 border-t border-dark-600 pt-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-32 text-sm text-dark-400">Цена за 1 ГБ:</span>
|
||||
<span className="w-32 text-sm text-dark-400">
|
||||
{t('admin.tariffs.pricePerGbLabel')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={trafficPricePerGbKopeks / 100}
|
||||
@@ -734,7 +767,9 @@ function PeriodTariffModal({
|
||||
<span className="text-dark-400">₽</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-32 text-sm text-dark-400">Мин. ГБ:</span>
|
||||
<span className="w-32 text-sm text-dark-400">
|
||||
{t('admin.tariffs.minTrafficLabel')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={minTrafficGb}
|
||||
@@ -746,7 +781,9 @@ function PeriodTariffModal({
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-32 text-sm text-dark-400">Макс. ГБ:</span>
|
||||
<span className="w-32 text-sm text-dark-400">
|
||||
{t('admin.tariffs.maxTrafficLabel')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={maxTrafficGb}
|
||||
@@ -763,21 +800,44 @@ function PeriodTariffModal({
|
||||
|
||||
{/* Режим сброса трафика */}
|
||||
<div className="rounded-lg bg-dark-700/50 p-4">
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-200">Режим сброса трафика</h4>
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-200">
|
||||
{t('admin.tariffs.trafficResetModeTitle')}
|
||||
</h4>
|
||||
<p className="mb-3 text-xs text-dark-500">
|
||||
Определяет, когда сбрасывается использованный трафик у подписчиков этого тарифа
|
||||
{t('admin.tariffs.trafficResetModeDesc')}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{
|
||||
value: null,
|
||||
label: '🌐 Глобальная настройка',
|
||||
desc: 'Использовать значение из конфига бота',
|
||||
labelKey: 'admin.tariffs.resetModeGlobal',
|
||||
descKey: 'admin.tariffs.resetModeGlobalDesc',
|
||||
emoji: '🌐',
|
||||
},
|
||||
{
|
||||
value: 'DAY',
|
||||
labelKey: 'admin.tariffs.resetModeDaily',
|
||||
descKey: 'admin.tariffs.resetModeDailyDesc',
|
||||
emoji: '📅',
|
||||
},
|
||||
{
|
||||
value: 'WEEK',
|
||||
labelKey: 'admin.tariffs.resetModeWeekly',
|
||||
descKey: 'admin.tariffs.resetModeWeeklyDesc',
|
||||
emoji: '📆',
|
||||
},
|
||||
{
|
||||
value: 'MONTH',
|
||||
labelKey: 'admin.tariffs.resetModeMonthly',
|
||||
descKey: 'admin.tariffs.resetModeMonthlyDesc',
|
||||
emoji: '🗓️',
|
||||
},
|
||||
{
|
||||
value: 'NO_RESET',
|
||||
labelKey: 'admin.tariffs.resetModeNever',
|
||||
descKey: 'admin.tariffs.resetModeNeverDesc',
|
||||
emoji: '🚫',
|
||||
},
|
||||
{ value: 'DAY', label: '📅 Ежедневно', desc: 'Сброс каждый день' },
|
||||
{ value: 'WEEK', label: '📆 Еженедельно', desc: 'Сброс каждую неделю' },
|
||||
{ value: 'MONTH', label: '🗓️ Ежемесячно', desc: 'Сброс каждый месяц' },
|
||||
{ value: 'NO_RESET', label: '🚫 Никогда', desc: 'Трафик не сбрасывается' },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value || 'global'}
|
||||
@@ -791,8 +851,10 @@ function PeriodTariffModal({
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm text-dark-100">{option.label}</span>
|
||||
<p className="mt-0.5 text-xs text-dark-400">{option.desc}</p>
|
||||
<span className="text-sm text-dark-100">
|
||||
{option.emoji} {t(option.labelKey)}
|
||||
</span>
|
||||
<p className="mt-0.5 text-xs text-dark-400">{t(option.descKey)}</p>
|
||||
</div>
|
||||
{trafficResetMode === option.value && (
|
||||
<span className="text-accent-400">
|
||||
@@ -814,14 +876,14 @@ function PeriodTariffModal({
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
Отмена
|
||||
{t('admin.tariffs.cancelButton')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name || (periodPrices.length === 0 && !customDaysEnabled) || isLoading}
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Сохранение...' : 'Сохранить'}
|
||||
{isLoading ? t('admin.tariffs.savingButton') : t('admin.tariffs.saveButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -839,6 +901,7 @@ interface DailyTariffModalProps {
|
||||
}
|
||||
|
||||
function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: DailyTariffModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = !!tariff;
|
||||
|
||||
const [name, setName] = useState(tariff?.name || '');
|
||||
@@ -904,9 +967,9 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{isEdit ? 'Редактирование тарифа' : 'Новый суточный тариф'}
|
||||
{isEdit ? t('admin.tariffs.editTitle') : t('admin.tariffs.newDailyTitle')}
|
||||
</h2>
|
||||
<p className="text-xs text-dark-500">Ежедневное списание</p>
|
||||
<p className="text-xs text-dark-500">{t('admin.tariffs.dailyDeduction')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
|
||||
@@ -926,9 +989,9 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{tab === 'basic' && 'Основное'}
|
||||
{tab === 'servers' && 'Серверы'}
|
||||
{tab === 'extra' && 'Дополнительно'}
|
||||
{tab === 'basic' && t('admin.tariffs.tabBasic')}
|
||||
{tab === 'servers' && t('admin.tariffs.tabServers')}
|
||||
{tab === 'extra' && t('admin.tariffs.tabExtra')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -939,32 +1002,36 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Название тарифа</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.tariffs.nameLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
placeholder="Например: Суточный"
|
||||
placeholder={t('admin.tariffs.nameExampleDaily')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Описание</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.tariffs.descriptionLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full resize-none rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
rows={2}
|
||||
placeholder="Краткое описание тарифа"
|
||||
placeholder={t('admin.tariffs.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Daily Price */}
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4">
|
||||
<label className="mb-2 block text-sm font-medium text-amber-400">
|
||||
Цена за день
|
||||
{t('admin.tariffs.dailyPriceLabel')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
@@ -977,16 +1044,18 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
min={0}
|
||||
step={0.1}
|
||||
/>
|
||||
<span className="text-dark-400">₽/день</span>
|
||||
<span className="text-dark-400">{t('admin.tariffs.currencyPerDay')}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-dark-500">
|
||||
Списывается ежедневно с баланса пользователя
|
||||
{t('admin.tariffs.dailyDeductionDesc')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Traffic Limit */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Лимит трафика</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.tariffs.trafficLimitLabel')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
@@ -995,11 +1064,11 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
min={0}
|
||||
/>
|
||||
<span className="text-dark-400">ГБ</span>
|
||||
<span className="text-dark-400">{t('admin.tariffs.gbUnit')}</span>
|
||||
{trafficLimitGb === 0 && (
|
||||
<span className="flex items-center gap-1 text-sm text-success-500">
|
||||
<InfinityIcon />
|
||||
Безлимит
|
||||
{t('admin.tariffs.unlimited')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -1007,7 +1076,9 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
|
||||
{/* Device Limit */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Устройств в тарифе</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.tariffs.deviceLimitLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={deviceLimit}
|
||||
@@ -1019,7 +1090,9 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
|
||||
{/* Tier Level */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm text-dark-300">Уровень тарифа</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.tariffs.tierLevelLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={tierLevel}
|
||||
@@ -1036,11 +1109,11 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
|
||||
{activeTab === 'servers' && (
|
||||
<div className="space-y-2">
|
||||
<p className="mb-4 text-sm text-dark-400">
|
||||
Выберите серверы, доступные на этом тарифе.
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-dark-400">{t('admin.tariffs.serversTabHint')}</p>
|
||||
{servers.length === 0 ? (
|
||||
<p className="py-4 text-center text-dark-500">Нет доступных серверов</p>
|
||||
<p className="py-4 text-center text-dark-500">
|
||||
{t('admin.tariffs.noServersAvailable')}
|
||||
</p>
|
||||
) : (
|
||||
servers.map((server) => {
|
||||
const isSelected = selectedSquads.includes(server.squad_uuid);
|
||||
@@ -1078,11 +1151,13 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
<div className="space-y-6">
|
||||
{/* Докупка устройств */}
|
||||
<div className="rounded-lg bg-dark-700/50 p-4">
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-200">Докупка устройств</h4>
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-200">
|
||||
{t('admin.tariffs.extraDeviceTitle')}
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-48 text-sm text-dark-400">
|
||||
Цена за устройство (30 дней):
|
||||
{t('admin.tariffs.devicePriceLabel')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
@@ -1096,9 +1171,11 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
/>
|
||||
<span className="text-dark-400">₽</span>
|
||||
</div>
|
||||
<p className="text-xs text-dark-500">0 = докупка недоступна</p>
|
||||
<p className="text-xs text-dark-500">{t('admin.tariffs.devicePriceHint')}</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-48 text-sm text-dark-400">Макс. устройств на тарифе:</span>
|
||||
<span className="w-48 text-sm text-dark-400">
|
||||
{t('admin.tariffs.maxDeviceLabel')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={maxDeviceLimit}
|
||||
@@ -1109,14 +1186,16 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
min={0}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-dark-500">0 = без ограничений</p>
|
||||
<p className="text-xs text-dark-500">{t('admin.tariffs.noLimitHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Докупка трафика */}
|
||||
<div className="rounded-lg bg-dark-700/50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-dark-200">Докупка трафика</h4>
|
||||
<h4 className="text-sm font-medium text-dark-200">
|
||||
{t('admin.tariffs.extraTrafficTitle')}
|
||||
</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTrafficTopupEnabled(!trafficTopupEnabled)}
|
||||
@@ -1134,7 +1213,9 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
{trafficTopupEnabled && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="w-32 text-sm text-dark-400">Макс. лимит:</span>
|
||||
<span className="w-32 text-sm text-dark-400">
|
||||
{t('admin.tariffs.trafficMaxLimitLabel')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={maxTopupTrafficGb}
|
||||
@@ -1144,15 +1225,21 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
className="w-24 rounded-lg border border-dark-500 bg-dark-600 px-3 py-2 text-dark-100 focus:border-amber-500 focus:outline-none"
|
||||
min={0}
|
||||
/>
|
||||
<span className="text-dark-400">ГБ</span>
|
||||
<span className="text-xs text-dark-500">(0 = без ограничений)</span>
|
||||
<span className="text-dark-400">{t('admin.tariffs.gbUnit')}</span>
|
||||
<span className="text-xs text-dark-500">
|
||||
{t('admin.tariffs.trafficLimitHint2')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<span className="text-sm text-dark-400">Пакеты трафика:</span>
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('admin.tariffs.trafficPackagesLabel')}
|
||||
</span>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||
{[5, 10, 20, 50].map((gb) => (
|
||||
<div key={gb} className="flex items-center gap-2">
|
||||
<span className="w-12 text-sm text-dark-300">{gb} ГБ:</span>
|
||||
<span className="w-12 text-sm text-dark-300">
|
||||
{gb} {t('admin.tariffs.gbPackageUnit')}
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={(trafficTopupPackages[String(gb)] || 0) / 100}
|
||||
@@ -1178,21 +1265,44 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
|
||||
{/* Режим сброса трафика */}
|
||||
<div className="rounded-lg bg-dark-700/50 p-4">
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-200">Режим сброса трафика</h4>
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-200">
|
||||
{t('admin.tariffs.trafficResetModeTitle')}
|
||||
</h4>
|
||||
<p className="mb-3 text-xs text-dark-500">
|
||||
Определяет, когда сбрасывается использованный трафик у подписчиков этого тарифа
|
||||
{t('admin.tariffs.trafficResetModeDesc')}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{
|
||||
value: null,
|
||||
label: '🌐 Глобальная настройка',
|
||||
desc: 'Использовать значение из конфига бота',
|
||||
labelKey: 'admin.tariffs.resetModeGlobal',
|
||||
descKey: 'admin.tariffs.resetModeGlobalDesc',
|
||||
emoji: '🌐',
|
||||
},
|
||||
{
|
||||
value: 'DAY',
|
||||
labelKey: 'admin.tariffs.resetModeDaily',
|
||||
descKey: 'admin.tariffs.resetModeDailyDesc',
|
||||
emoji: '📅',
|
||||
},
|
||||
{
|
||||
value: 'WEEK',
|
||||
labelKey: 'admin.tariffs.resetModeWeekly',
|
||||
descKey: 'admin.tariffs.resetModeWeeklyDesc',
|
||||
emoji: '📆',
|
||||
},
|
||||
{
|
||||
value: 'MONTH',
|
||||
labelKey: 'admin.tariffs.resetModeMonthly',
|
||||
descKey: 'admin.tariffs.resetModeMonthlyDesc',
|
||||
emoji: '🗓️',
|
||||
},
|
||||
{
|
||||
value: 'NO_RESET',
|
||||
labelKey: 'admin.tariffs.resetModeNever',
|
||||
descKey: 'admin.tariffs.resetModeNeverDesc',
|
||||
emoji: '🚫',
|
||||
},
|
||||
{ value: 'DAY', label: '📅 Ежедневно', desc: 'Сброс каждый день' },
|
||||
{ value: 'WEEK', label: '📆 Еженедельно', desc: 'Сброс каждую неделю' },
|
||||
{ value: 'MONTH', label: '🗓️ Ежемесячно', desc: 'Сброс каждый месяц' },
|
||||
{ value: 'NO_RESET', label: '🚫 Никогда', desc: 'Трафик не сбрасывается' },
|
||||
].map((option) => (
|
||||
<button
|
||||
key={option.value || 'global'}
|
||||
@@ -1206,8 +1316,10 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-sm text-dark-100">{option.label}</span>
|
||||
<p className="mt-0.5 text-xs text-dark-400">{option.desc}</p>
|
||||
<span className="text-sm text-dark-100">
|
||||
{option.emoji} {t(option.labelKey)}
|
||||
</span>
|
||||
<p className="mt-0.5 text-xs text-dark-400">{t(option.descKey)}</p>
|
||||
</div>
|
||||
{trafficResetMode === option.value && (
|
||||
<span className="text-amber-400">
|
||||
@@ -1229,14 +1341,14 @@ function DailyTariffModal({ tariff, servers, onSave, onClose, isLoading }: Daily
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
Отмена
|
||||
{t('admin.tariffs.cancelButton')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name || dailyPriceKopeks <= 0 || isLoading}
|
||||
className="rounded-lg bg-amber-500 px-4 py-2 text-white transition-colors hover:bg-amber-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Сохранение...' : 'Сохранить'}
|
||||
{isLoading ? t('admin.tariffs.savingButton') : t('admin.tariffs.saveButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1400,11 +1512,11 @@ export default function AdminTariffs() {
|
||||
<h3 className="truncate font-medium text-dark-100">{tariff.name}</h3>
|
||||
{tariff.is_daily ? (
|
||||
<span className="rounded bg-amber-500/20 px-2 py-0.5 text-xs text-amber-400">
|
||||
Суточный
|
||||
{t('admin.tariffs.dailyType')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
|
||||
Периодный
|
||||
{t('admin.tariffs.periodType')}
|
||||
</span>
|
||||
)}
|
||||
{tariff.is_trial_available && (
|
||||
@@ -1421,7 +1533,8 @@ export default function AdminTariffs() {
|
||||
<div className="flex flex-wrap gap-x-4 gap-y-1 text-sm text-dark-400">
|
||||
{tariff.is_daily && tariff.daily_price_kopeks > 0 && (
|
||||
<span className="text-amber-400">
|
||||
{(tariff.daily_price_kopeks / 100).toFixed(2)} ₽/день
|
||||
{(tariff.daily_price_kopeks / 100).toFixed(2)}{' '}
|
||||
{t('admin.tariffs.currencyPerDay')}
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
@@ -1429,14 +1542,10 @@ export default function AdminTariffs() {
|
||||
? t('admin.tariffs.unlimited')
|
||||
: `${tariff.traffic_limit_gb} GB`}
|
||||
</span>
|
||||
<span>{t('admin.tariffs.devices', { count: tariff.device_limit })}</span>
|
||||
<span>{t('admin.tariffs.servers', { count: tariff.servers_count })}</span>
|
||||
<span>
|
||||
{tariff.device_limit} {t('admin.tariffs.devices')}
|
||||
</span>
|
||||
<span>
|
||||
{tariff.servers_count} {t('admin.tariffs.servers')}
|
||||
</span>
|
||||
<span>
|
||||
{tariff.subscriptions_count} {t('admin.tariffs.subscriptions')}
|
||||
{t('admin.tariffs.subscriptions', { count: tariff.subscriptions_count })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '../i18n';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import {
|
||||
adminUsersApi,
|
||||
@@ -132,6 +134,7 @@ interface UserRowProps {
|
||||
}
|
||||
|
||||
function UserRow({ user, onSelect, formatAmount }: UserRowProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(user)}
|
||||
@@ -165,10 +168,10 @@ function UserRow({ user, onSelect, formatAmount }: UserRowProps) {
|
||||
}`}
|
||||
>
|
||||
{user.subscription_status === 'active'
|
||||
? 'Подписка'
|
||||
? t('admin.users.status.subscription')
|
||||
: user.subscription_status === 'trial'
|
||||
? 'Триал'
|
||||
: 'Истекла'}
|
||||
? t('admin.users.status.trial')
|
||||
: t('admin.users.status.expired')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -178,7 +181,9 @@ function UserRow({ user, onSelect, formatAmount }: UserRowProps) {
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="font-medium text-dark-100">{formatAmount(user.balance_rubles)}</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{user.purchase_count > 0 ? `${user.purchase_count} покупок` : 'Нет покупок'}
|
||||
{user.purchase_count > 0
|
||||
? t('admin.users.purchaseCount', { count: user.purchase_count })
|
||||
: t('admin.users.noPurchases')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -196,7 +201,10 @@ interface UserDetailModalProps {
|
||||
}
|
||||
|
||||
function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };
|
||||
const locale = localeMap[i18n.language] || 'ru-RU';
|
||||
const [user, setUser] = useState<UserDetailResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'info' | 'subscription' | 'balance' | 'sync'>('info');
|
||||
@@ -259,7 +267,11 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
const amount = Math.abs(parseFloat(balanceAmount) * 100);
|
||||
await adminUsersApi.updateBalance(userId, {
|
||||
amount_kopeks: isAdd ? amount : -amount,
|
||||
description: balanceDescription || (isAdd ? 'Начисление админом' : 'Списание админом'),
|
||||
description:
|
||||
balanceDescription ||
|
||||
(isAdd
|
||||
? t('admin.users.detail.balance.addByAdmin')
|
||||
: t('admin.users.detail.balance.subtractByAdmin')),
|
||||
});
|
||||
await loadUser();
|
||||
setBalanceAmount('');
|
||||
@@ -299,7 +311,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
};
|
||||
|
||||
const handleBlockUser = async () => {
|
||||
if (!confirm('Заблокировать пользователя?')) return;
|
||||
if (!confirm(t('admin.users.confirm.block'))) return;
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await adminUsersApi.blockUser(userId);
|
||||
@@ -358,7 +370,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
|
||||
const formatDate = (date: string | null) => {
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleDateString('ru-RU', {
|
||||
return new Date(date).toLocaleDateString(locale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
@@ -414,10 +426,10 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{tab === 'info' && 'Информация'}
|
||||
{tab === 'subscription' && 'Подписка'}
|
||||
{tab === 'balance' && 'Баланс'}
|
||||
{tab === 'sync' && 'Синхронизация'}
|
||||
{tab === 'info' && t('admin.users.detail.tabs.info')}
|
||||
{tab === 'subscription' && t('admin.users.detail.tabs.subscription')}
|
||||
{tab === 'balance' && t('admin.users.detail.tabs.balance')}
|
||||
{tab === 'sync' && t('admin.users.detail.tabs.sync')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -429,7 +441,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
<div className="space-y-4">
|
||||
{/* Status */}
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-900/50 p-3">
|
||||
<span className="text-dark-400">Статус</span>
|
||||
<span className="text-dark-400">{t('admin.users.detail.status')}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={user.status} />
|
||||
{user.status === 'active' ? (
|
||||
@@ -438,7 +450,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
disabled={actionLoading}
|
||||
className="rounded-lg bg-rose-500/20 px-3 py-1 text-xs text-rose-400 transition-colors hover:bg-rose-500/30"
|
||||
>
|
||||
Заблокировать
|
||||
{t('admin.users.actions.block')}
|
||||
</button>
|
||||
) : user.status === 'blocked' ? (
|
||||
<button
|
||||
@@ -446,7 +458,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
disabled={actionLoading}
|
||||
className="rounded-lg bg-emerald-500/20 px-3 py-1 text-xs text-emerald-400 transition-colors hover:bg-emerald-500/30"
|
||||
>
|
||||
Разблокировать
|
||||
{t('admin.users.actions.unblock')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -459,50 +471,68 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
<div className="text-dark-100">{user.email || '-'}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">Язык</div>
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.language')}
|
||||
</div>
|
||||
<div className="text-dark-100">{user.language}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">Регистрация</div>
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.registration')}
|
||||
</div>
|
||||
<div className="text-dark-100">{formatDate(user.created_at)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">Последняя активность</div>
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.lastActivity')}
|
||||
</div>
|
||||
<div className="text-dark-100">{formatDate(user.last_activity)}</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">Всего потрачено</div>
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.totalSpent')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{formatWithCurrency(user.total_spent_kopeks / 100)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-1 text-xs text-dark-500">Покупок</div>
|
||||
<div className="mb-1 text-xs text-dark-500">
|
||||
{t('admin.users.detail.purchases')}
|
||||
</div>
|
||||
<div className="text-dark-100">{user.purchase_count}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Referral */}
|
||||
<div className="rounded-xl bg-dark-900/50 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-dark-200">Реферальная программа</div>
|
||||
<div className="mb-2 text-sm font-medium text-dark-200">
|
||||
{t('admin.users.detail.referral.title')}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{user.referral.referrals_count}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">Рефералов</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referral.referrals')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{formatWithCurrency(user.referral.total_earnings_kopeks / 100)}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">Заработано</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referral.earned')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{user.referral.commission_percent || 0}%
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">Комиссия</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.referral.commission')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -510,16 +540,22 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
{/* Restrictions */}
|
||||
{(user.restriction_topup || user.restriction_subscription) && (
|
||||
<div className="rounded-xl border border-rose-500/30 bg-rose-500/10 p-3">
|
||||
<div className="mb-2 text-sm font-medium text-rose-400">Ограничения</div>
|
||||
<div className="mb-2 text-sm font-medium text-rose-400">
|
||||
{t('admin.users.detail.restrictions.title')}
|
||||
</div>
|
||||
{user.restriction_topup && (
|
||||
<div className="text-xs text-rose-300">• Запрет пополнения</div>
|
||||
<div className="text-xs text-rose-300">
|
||||
{t('admin.users.detail.restrictions.topup')}
|
||||
</div>
|
||||
)}
|
||||
{user.restriction_subscription && (
|
||||
<div className="text-xs text-rose-300">• Запрет покупки подписки</div>
|
||||
<div className="text-xs text-rose-300">
|
||||
{t('admin.users.detail.restrictions.subscription')}
|
||||
</div>
|
||||
)}
|
||||
{user.restriction_reason && (
|
||||
<div className="mt-1 text-xs text-dark-400">
|
||||
Причина: {user.restriction_reason}
|
||||
{t('admin.users.detail.restrictions.reason')}: {user.restriction_reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -535,31 +571,42 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
{/* Current subscription */}
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="font-medium text-dark-200">Текущая подписка</span>
|
||||
<span className="font-medium text-dark-200">
|
||||
{t('admin.users.detail.subscription.current')}
|
||||
</span>
|
||||
<StatusBadge status={user.subscription.status} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">Тариф</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.tariff')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{user.subscription.tariff_name || 'Не указан'}
|
||||
{user.subscription.tariff_name ||
|
||||
t('admin.users.detail.subscription.notSpecified')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">Действует до</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.validUntil')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{formatDate(user.subscription.end_date)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">Трафик</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.traffic')}
|
||||
</div>
|
||||
<div className="text-dark-100">
|
||||
{user.subscription.traffic_used_gb.toFixed(1)} /{' '}
|
||||
{user.subscription.traffic_limit_gb} ГБ
|
||||
{user.subscription.traffic_limit_gb} {t('common.units.gb')}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-dark-500">Устройств</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('admin.users.detail.subscription.devices')}
|
||||
</div>
|
||||
<div className="text-dark-100">{user.subscription.device_limit}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -567,17 +614,27 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-3 font-medium text-dark-200">Действия</div>
|
||||
<div className="mb-3 font-medium text-dark-200">
|
||||
{t('admin.users.detail.subscription.actions')}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={subAction}
|
||||
onChange={(e) => setSubAction(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="extend">Продлить</option>
|
||||
<option value="change_tariff">Сменить тариф</option>
|
||||
<option value="cancel">Отменить</option>
|
||||
<option value="activate">Активировать</option>
|
||||
<option value="extend">
|
||||
{t('admin.users.detail.subscription.extend')}
|
||||
</option>
|
||||
<option value="change_tariff">
|
||||
{t('admin.users.detail.subscription.changeTariff')}
|
||||
</option>
|
||||
<option value="cancel">
|
||||
{t('admin.users.detail.subscription.cancel')}
|
||||
</option>
|
||||
<option value="activate">
|
||||
{t('admin.users.detail.subscription.activate')}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
{subAction === 'extend' && (
|
||||
@@ -585,7 +642,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
type="number"
|
||||
value={subDays}
|
||||
onChange={(e) => setSubDays(e.target.value)}
|
||||
placeholder="Дней"
|
||||
placeholder={t('admin.users.detail.subscription.days')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
)}
|
||||
@@ -598,10 +655,14 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="">Выберите тариф</option>
|
||||
{tariffs.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name} {!t.is_available && '(недоступен)'}
|
||||
<option value="">
|
||||
{t('admin.users.detail.subscription.selectTariff')}
|
||||
</option>
|
||||
{tariffs.map((tariffItem) => (
|
||||
<option key={tariffItem.id} value={tariffItem.id}>
|
||||
{tariffItem.name}{' '}
|
||||
{!tariffItem.is_available &&
|
||||
t('admin.users.detail.subscription.unavailable')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -612,14 +673,18 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
disabled={actionLoading}
|
||||
className="w-full rounded-lg bg-blue-500 py-2 text-white transition-colors hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? 'Применение...' : 'Применить'}
|
||||
{actionLoading
|
||||
? t('admin.users.actions.applying')
|
||||
: t('admin.users.actions.apply')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-4 text-center text-dark-400">Нет активной подписки</div>
|
||||
<div className="mb-4 text-center text-dark-400">
|
||||
{t('admin.users.detail.subscription.noActive')}
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<select
|
||||
value={selectedTariffId || ''}
|
||||
@@ -628,10 +693,10 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="">Выберите тариф</option>
|
||||
{tariffs.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
<option value="">{t('admin.users.detail.subscription.selectTariff')}</option>
|
||||
{tariffs.map((tariffItem) => (
|
||||
<option key={tariffItem.id} value={tariffItem.id}>
|
||||
{tariffItem.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -639,7 +704,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
type="number"
|
||||
value={subDays}
|
||||
onChange={(e) => setSubDays(e.target.value)}
|
||||
placeholder="Дней"
|
||||
placeholder={t('admin.users.detail.subscription.days')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<button
|
||||
@@ -650,7 +715,9 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
disabled={actionLoading}
|
||||
className="w-full rounded-lg bg-emerald-500 py-2 text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
|
||||
>
|
||||
{actionLoading ? 'Создание...' : 'Создать подписку'}
|
||||
{actionLoading
|
||||
? t('admin.users.detail.subscription.creating')
|
||||
: t('admin.users.detail.subscription.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -663,7 +730,9 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
<div className="space-y-4">
|
||||
{/* Current balance */}
|
||||
<div className="rounded-xl border border-blue-500/30 bg-gradient-to-r from-blue-500/20 to-purple-500/20 p-4">
|
||||
<div className="mb-1 text-sm text-dark-400">Текущий баланс</div>
|
||||
<div className="mb-1 text-sm text-dark-400">
|
||||
{t('admin.users.detail.balance.current')}
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-dark-100">
|
||||
{formatWithCurrency(user.balance_rubles)}
|
||||
</div>
|
||||
@@ -675,14 +744,14 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
type="number"
|
||||
value={balanceAmount}
|
||||
onChange={(e) => setBalanceAmount(e.target.value)}
|
||||
placeholder="Сумма в рублях"
|
||||
placeholder={t('admin.users.detail.balance.amountPlaceholder')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={balanceDescription}
|
||||
onChange={(e) => setBalanceDescription(e.target.value)}
|
||||
placeholder="Описание (опционально)"
|
||||
placeholder={t('admin.users.detail.balance.descriptionPlaceholder')}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
@@ -691,14 +760,14 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
disabled={actionLoading || !balanceAmount}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-emerald-500 py-2 text-white transition-colors hover:bg-emerald-600 disabled:opacity-50"
|
||||
>
|
||||
<PlusIcon /> Начислить
|
||||
<PlusIcon /> {t('admin.users.detail.balance.add')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleUpdateBalance(false)}
|
||||
disabled={actionLoading || !balanceAmount}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-rose-500 py-2 text-white transition-colors hover:bg-rose-600 disabled:opacity-50"
|
||||
>
|
||||
<MinusIcon /> Списать
|
||||
<MinusIcon /> {t('admin.users.detail.balance.subtract')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -706,7 +775,9 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
{/* Recent transactions */}
|
||||
{user.recent_transactions.length > 0 && (
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-3 font-medium text-dark-200">Последние транзакции</div>
|
||||
<div className="mb-3 font-medium text-dark-200">
|
||||
{t('admin.users.detail.balance.recentTransactions')}
|
||||
</div>
|
||||
<div className="max-h-48 space-y-2 overflow-y-auto">
|
||||
{user.recent_transactions.map((tx) => (
|
||||
<div
|
||||
@@ -741,9 +812,13 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
{syncStatus.has_differences ? (
|
||||
<span className="font-medium text-amber-400">Есть расхождения</span>
|
||||
<span className="font-medium text-amber-400">
|
||||
{t('admin.users.detail.sync.hasDifferences')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium text-emerald-400">Синхронизировано</span>
|
||||
<span className="font-medium text-emerald-400">
|
||||
{t('admin.users.detail.sync.synced')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -759,36 +834,48 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="mb-2 text-xs text-dark-500">Бот</div>
|
||||
<div className="mb-2 text-xs text-dark-500">
|
||||
{t('admin.users.detail.sync.bot')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Статус:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.statusLabel')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_subscription_status || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">До:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.until')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_subscription_end_date
|
||||
? new Date(syncStatus.bot_subscription_end_date).toLocaleDateString(
|
||||
'ru-RU',
|
||||
locale,
|
||||
)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Трафик:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.traffic')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_traffic_used_gb.toFixed(2)} ГБ
|
||||
{syncStatus.bot_traffic_used_gb.toFixed(2)} {t('common.units.gb')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Устройства:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.devices')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.bot_device_limit}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Сквады:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.squads')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.bot_squads?.length || 0}
|
||||
</span>
|
||||
@@ -796,32 +883,44 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-xs text-dark-500">Панель</div>
|
||||
<div className="mb-2 text-xs text-dark-500">
|
||||
{t('admin.users.detail.sync.panel')}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Статус:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.statusLabel')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.panel_status || '-'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">До:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.until')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_expire_at
|
||||
? new Date(syncStatus.panel_expire_at).toLocaleDateString('ru-RU')
|
||||
? new Date(syncStatus.panel_expire_at).toLocaleDateString(locale)
|
||||
: '-'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Трафик:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.traffic')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_traffic_used_gb.toFixed(2)} ГБ
|
||||
{syncStatus.panel_traffic_used_gb.toFixed(2)} {t('common.units.gb')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Устройства:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.devices')}:
|
||||
</span>
|
||||
<span className="text-dark-200">{syncStatus.panel_device_limit}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Сквады:</span>
|
||||
<span className="text-dark-400">
|
||||
{t('admin.users.detail.sync.squads')}:
|
||||
</span>
|
||||
<span className="text-dark-200">
|
||||
{syncStatus.panel_squads?.length || 0}
|
||||
</span>
|
||||
@@ -836,7 +935,9 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
<div className="rounded-xl bg-dark-900/50 p-4">
|
||||
<div className="mb-1 text-sm text-dark-400">Remnawave UUID</div>
|
||||
<div className="break-all font-mono text-sm text-dark-100">
|
||||
{syncStatus?.remnawave_uuid || user.remnawave_uuid || 'Не привязан'}
|
||||
{syncStatus?.remnawave_uuid ||
|
||||
user.remnawave_uuid ||
|
||||
t('admin.users.detail.sync.notLinked')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -848,7 +949,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-blue-500/30 bg-blue-500/20 py-3 text-blue-400 transition-colors hover:bg-blue-500/30 disabled:opacity-50"
|
||||
>
|
||||
<SyncIcon className={actionLoading ? 'animate-spin' : ''} />
|
||||
Из панели в бота
|
||||
{t('admin.users.detail.sync.fromPanel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSyncToPanel}
|
||||
@@ -856,7 +957,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl border border-purple-500/30 bg-purple-500/20 py-3 text-purple-400 transition-colors hover:bg-purple-500/30 disabled:opacity-50"
|
||||
>
|
||||
<SyncIcon className={actionLoading ? 'animate-spin' : ''} />
|
||||
Из бота в панель
|
||||
{t('admin.users.detail.sync.toPanel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -870,6 +971,7 @@ function UserDetailModal({ userId, onClose, onUpdate }: UserDetailModalProps) {
|
||||
// ============ Main Page ============
|
||||
|
||||
export default function AdminUsers() {
|
||||
const { t } = useTranslation();
|
||||
const { formatWithCurrency } = useCurrency();
|
||||
|
||||
const [users, setUsers] = useState<UserListItem[]>([]);
|
||||
@@ -941,8 +1043,8 @@ export default function AdminUsers() {
|
||||
<ChevronLeftIcon />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-dark-100">Пользователи</h1>
|
||||
<p className="text-sm text-dark-400">Управление пользователями бота</p>
|
||||
<h1 className="text-xl font-bold text-dark-100">{t('admin.users.title')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.users.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -959,15 +1061,27 @@ export default function AdminUsers() {
|
||||
{/* Stats */}
|
||||
{stats && (
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<StatCard title="Всего" value={stats.total_users} color="blue" />
|
||||
<StatCard title="Активных" value={stats.active_users} color="green" />
|
||||
<StatCard title={t('admin.users.stats.total')} value={stats.total_users} color="blue" />
|
||||
<StatCard
|
||||
title="С подпиской"
|
||||
title={t('admin.users.stats.active')}
|
||||
value={stats.active_users}
|
||||
color="green"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('admin.users.stats.withSubscription')}
|
||||
value={stats.users_with_active_subscription}
|
||||
color="purple"
|
||||
/>
|
||||
<StatCard title="Новых сегодня" value={stats.new_today} color="yellow" />
|
||||
<StatCard title="Заблокировано" value={stats.blocked_users} color="red" />
|
||||
<StatCard
|
||||
title={t('admin.users.stats.newToday')}
|
||||
value={stats.new_today}
|
||||
color="yellow"
|
||||
/>
|
||||
<StatCard
|
||||
title={t('admin.users.stats.blocked')}
|
||||
value={stats.blocked_users}
|
||||
color="red"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -979,7 +1093,7 @@ export default function AdminUsers() {
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Поиск по ID, имени, username..."
|
||||
placeholder={t('admin.users.search')}
|
||||
className="w-full rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-4 text-dark-100 placeholder-dark-500 focus:border-dark-600 focus:outline-none"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
||||
@@ -995,10 +1109,10 @@ export default function AdminUsers() {
|
||||
}}
|
||||
className="rounded-xl border border-dark-700 bg-dark-800 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="">Все статусы</option>
|
||||
<option value="active">Активные</option>
|
||||
<option value="blocked">Заблокированные</option>
|
||||
<option value="deleted">Удалённые</option>
|
||||
<option value="">{t('admin.users.filters.allStatuses')}</option>
|
||||
<option value="active">{t('admin.users.status.active')}</option>
|
||||
<option value="blocked">{t('admin.users.status.blocked')}</option>
|
||||
<option value="deleted">{t('admin.users.status.deleted')}</option>
|
||||
</select>
|
||||
<select
|
||||
value={sortBy}
|
||||
@@ -1008,10 +1122,10 @@ export default function AdminUsers() {
|
||||
}}
|
||||
className="rounded-xl border border-dark-700 bg-dark-800 px-3 py-2 text-dark-100"
|
||||
>
|
||||
<option value="created_at">По дате</option>
|
||||
<option value="balance">По балансу</option>
|
||||
<option value="last_activity">По активности</option>
|
||||
<option value="total_spent">По расходам</option>
|
||||
<option value="created_at">{t('admin.users.filters.byDate')}</option>
|
||||
<option value="balance">{t('admin.users.filters.byBalance')}</option>
|
||||
<option value="last_activity">{t('admin.users.filters.byActivity')}</option>
|
||||
<option value="total_spent">{t('admin.users.filters.bySpent')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -1022,7 +1136,7 @@ export default function AdminUsers() {
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-blue-500 border-t-transparent" />
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="py-12 text-center text-dark-400">Пользователи не найдены</div>
|
||||
<div className="py-12 text-center text-dark-400">{t('admin.users.noData')}</div>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<UserRow
|
||||
@@ -1039,7 +1153,11 @@ export default function AdminUsers() {
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-dark-400">
|
||||
Показано {offset + 1}-{Math.min(offset + limit, total)} из {total}
|
||||
{t('admin.users.pagination.showing', {
|
||||
from: offset + 1,
|
||||
to: Math.min(offset + limit, total),
|
||||
total,
|
||||
})}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
|
||||
@@ -471,7 +471,7 @@ export default function AdminWheel() {
|
||||
<div key={i} className="flex items-center justify-between">
|
||||
<span className="text-dark-300">{prize.display_name}</span>
|
||||
<span className="text-dark-100">
|
||||
{prize.count} {t('admin.wheel.statistics.times')}
|
||||
{t('admin.wheel.statistics.times', { count: prize.count })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -234,7 +234,7 @@ export default function Contests() {
|
||||
<div className="flex items-center gap-1 text-accent-400">
|
||||
<TrophyIcon />
|
||||
<span className="text-sm font-medium">
|
||||
+{contest.prize_days} {t('contests.days')}
|
||||
+{t('contests.days', { count: contest.prize_days })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -344,8 +344,8 @@ export default function Dashboard() {
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.timeLeft')}</div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{subscription.days_left > 0
|
||||
? `${subscription.days_left} ${t('subscription.days')}`
|
||||
: `${subscription.hours_left}${t('subscription.hours')} ${subscription.minutes_left}${t('subscription.minutes')}`}
|
||||
? t('subscription.days', { count: subscription.days_left })
|
||||
: `${t('subscription.hours', { count: subscription.hours_left })} ${t('subscription.minutes', { count: subscription.minutes_left })}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -503,9 +503,7 @@ export default function Dashboard() {
|
||||
<div className="text-2xl font-bold text-accent-400">
|
||||
{trialInfo.duration_days}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('subscription.trial.days', 'days')}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('subscription.trial.days')}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">
|
||||
@@ -515,9 +513,7 @@ export default function Dashboard() {
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-accent-400">{trialInfo.device_limit}</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('subscription.trial.devices', 'devices')}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">{t('subscription.trial.devices')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ const isValidDeepLink = (url: string): boolean => {
|
||||
};
|
||||
|
||||
export default function DeepLinkRedirect() {
|
||||
const { i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [status, setStatus] = useState<Status>('countdown');
|
||||
@@ -73,51 +73,6 @@ export default function DeepLinkRedirect() {
|
||||
const appName = appInfo?.name || appParam || 'VPN';
|
||||
const appIcon = appInfo?.icon || appName[0]?.toUpperCase() || 'V';
|
||||
|
||||
// Translations
|
||||
const texts = {
|
||||
en: {
|
||||
connecting: 'Connecting to',
|
||||
redirecting: 'Redirecting in',
|
||||
seconds: 'seconds',
|
||||
manual: 'If nothing happens, click the button below.',
|
||||
openApp: 'Open App',
|
||||
copyLink: 'Copy subscription link',
|
||||
copied: 'Copied!',
|
||||
tryAgain: 'Try again',
|
||||
backToCabinet: 'Back to cabinet',
|
||||
errorTitle: 'Error',
|
||||
errorDesc: 'Connection link is missing',
|
||||
goToSubscription: 'Go to subscription',
|
||||
howToAdd: 'How to add manually:',
|
||||
step1: 'Copy the link using the button above',
|
||||
step2: 'Open the app',
|
||||
step3: 'Find "+" or "Add subscription"',
|
||||
step4: 'Select "From clipboard" or "Paste link"',
|
||||
},
|
||||
ru: {
|
||||
connecting: 'Подключение к',
|
||||
redirecting: 'Перенаправление через',
|
||||
seconds: 'сек',
|
||||
manual: 'Если ничего не происходит, нажмите кнопку ниже.',
|
||||
openApp: 'Открыть приложение',
|
||||
copyLink: 'Скопировать ссылку подписки',
|
||||
copied: 'Скопировано!',
|
||||
tryAgain: 'Попробовать снова',
|
||||
backToCabinet: 'Вернуться в кабинет',
|
||||
errorTitle: 'Ошибка',
|
||||
errorDesc: 'Ссылка для подключения не найдена',
|
||||
goToSubscription: 'Перейти к подписке',
|
||||
howToAdd: 'Как добавить вручную:',
|
||||
step1: 'Скопируйте ссылку кнопкой выше',
|
||||
step2: 'Откройте приложение',
|
||||
step3: 'Найдите "+" или "Добавить подписку"',
|
||||
step4: 'Выберите "Из буфера" или "Вставить ссылку"',
|
||||
},
|
||||
};
|
||||
|
||||
const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en';
|
||||
const txt = texts[lang];
|
||||
|
||||
// Open deep link - same as miniapp, just window.location.href
|
||||
const openDeepLink = useCallback(() => {
|
||||
if (!deepLink || !isValidDeepLink(deepLink)) return;
|
||||
@@ -210,7 +165,7 @@ export default function DeepLinkRedirect() {
|
||||
|
||||
{status !== 'error' && (
|
||||
<p className="mb-6 text-dark-400">
|
||||
{txt.connecting} {appName}...
|
||||
{t('deepLink.connecting')} {appName}...
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -227,10 +182,10 @@ export default function DeepLinkRedirect() {
|
||||
|
||||
{/* Timer */}
|
||||
<div className="mb-4">
|
||||
<p className="mb-2 text-sm text-dark-500">{txt.redirecting}</p>
|
||||
<p className="mb-2 text-sm text-dark-500">{t('deepLink.redirecting')}</p>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="text-4xl font-bold text-accent-400">{countdown}</span>
|
||||
<span className="text-dark-400">{txt.seconds}</span>
|
||||
<span className="text-dark-400">{t('deepLink.seconds')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -242,7 +197,7 @@ export default function DeepLinkRedirect() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="mb-4 text-sm text-dark-500">{txt.manual}</p>
|
||||
<p className="mb-4 text-sm text-dark-500">{t('deepLink.manual')}</p>
|
||||
|
||||
{/* Open now button */}
|
||||
<button
|
||||
@@ -262,7 +217,7 @@ export default function DeepLinkRedirect() {
|
||||
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
|
||||
/>
|
||||
</svg>
|
||||
{txt.openApp}
|
||||
{t('deepLink.openApp')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -296,7 +251,7 @@ export default function DeepLinkRedirect() {
|
||||
d="M4.5 12.75l6 6 9-13.5"
|
||||
/>
|
||||
</svg>
|
||||
{txt.copied}
|
||||
{t('deepLink.copied')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -313,7 +268,7 @@ export default function DeepLinkRedirect() {
|
||||
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{txt.copyLink}
|
||||
{t('deepLink.copyLink')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
@@ -336,7 +291,7 @@ export default function DeepLinkRedirect() {
|
||||
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
|
||||
/>
|
||||
</svg>
|
||||
{txt.tryAgain}
|
||||
{t('deepLink.tryAgain')}
|
||||
</button>
|
||||
|
||||
{/* Back to cabinet */}
|
||||
@@ -344,20 +299,20 @@ export default function DeepLinkRedirect() {
|
||||
onClick={() => navigate('/subscription')}
|
||||
className="w-full py-2 text-sm text-dark-500 transition-colors hover:text-dark-300"
|
||||
>
|
||||
{txt.backToCabinet}
|
||||
{t('deepLink.backToCabinet')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="mt-6 rounded-xl border border-dark-700 bg-dark-900/50 p-4 text-left">
|
||||
<h3 className="mb-2 text-sm font-medium text-dark-200">{txt.howToAdd}</h3>
|
||||
<h3 className="mb-2 text-sm font-medium text-dark-200">{t('deepLink.howToAdd')}</h3>
|
||||
<ol className="list-inside list-decimal space-y-1.5 text-xs text-dark-400">
|
||||
<li>{txt.step1}</li>
|
||||
<li>{t('deepLink.step1')}</li>
|
||||
<li>
|
||||
{txt.step2} {appName}
|
||||
{t('deepLink.step2')} {appName}
|
||||
</li>
|
||||
<li>{txt.step3}</li>
|
||||
<li>{txt.step4}</li>
|
||||
<li>{t('deepLink.step3')}</li>
|
||||
<li>{t('deepLink.step4')}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
@@ -381,10 +336,10 @@ export default function DeepLinkRedirect() {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mb-2 font-medium text-dark-200">{txt.errorTitle}</p>
|
||||
<p className="mb-6 text-sm text-dark-400">{txt.errorDesc}</p>
|
||||
<p className="mb-2 font-medium text-dark-200">{t('deepLink.errorTitle')}</p>
|
||||
<p className="mb-6 text-sm text-dark-400">{t('deepLink.errorDesc')}</p>
|
||||
<button onClick={() => navigate('/subscription')} className="btn-primary w-full">
|
||||
{txt.goToSubscription}
|
||||
{t('deepLink.goToSubscription')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -222,7 +222,8 @@ export default function Polls() {
|
||||
)}
|
||||
<div className="mt-2 flex items-center gap-4 text-sm text-dark-400">
|
||||
<span>
|
||||
{poll.answered_questions}/{poll.total_questions} {t('polls.questions')}
|
||||
{poll.answered_questions}/
|
||||
{t('polls.questions', { count: poll.total_questions })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,16 +15,7 @@ import type {
|
||||
import ConnectionModal from '../components/ConnectionModal';
|
||||
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
|
||||
// Russian pluralization for "день/дня/дней"
|
||||
const pluralizeDays = (n: number): string => {
|
||||
const mod100 = n % 100;
|
||||
const mod10 = n % 10;
|
||||
if (mod100 >= 11 && mod100 <= 19) return 'дней';
|
||||
if (mod10 === 1) return 'день';
|
||||
if (mod10 >= 2 && mod10 <= 4) return 'дня';
|
||||
return 'дней';
|
||||
};
|
||||
import i18n from '../i18n';
|
||||
|
||||
// Helper to extract error message from axios/api errors
|
||||
const getErrorMessage = (error: unknown): string => {
|
||||
@@ -34,7 +25,7 @@ const getErrorMessage = (error: unknown): string => {
|
||||
if (typeof detail === 'object' && detail?.message) return detail.message;
|
||||
}
|
||||
if (error instanceof Error) return error.message;
|
||||
return 'Произошла ошибка';
|
||||
return i18n.t('common.error');
|
||||
};
|
||||
|
||||
// Helper to extract insufficient balance error details
|
||||
@@ -631,11 +622,11 @@ export default function Subscription() {
|
||||
<div className="mb-1 text-sm text-dark-500">{t('subscription.daysLeft')}</div>
|
||||
<div className="text-xl font-semibold text-dark-100">
|
||||
{subscription.days_left > 0 ? (
|
||||
`${subscription.days_left} ${t('subscription.days')}`
|
||||
t('subscription.days', { count: subscription.days_left })
|
||||
) : subscription.hours_left > 0 ? (
|
||||
`${subscription.hours_left}${t('subscription.hours')} ${subscription.minutes_left}${t('subscription.minutes')}`
|
||||
`${t('subscription.hours', { count: subscription.hours_left })} ${t('subscription.minutes', { count: subscription.minutes_left })}`
|
||||
) : subscription.minutes_left > 0 ? (
|
||||
`${subscription.minutes_left}${t('subscription.minutes')}`
|
||||
t('subscription.minutes', { count: subscription.minutes_left })
|
||||
) : (
|
||||
<span className="text-error-400">{t('subscription.expired')}</span>
|
||||
)}
|
||||
@@ -687,7 +678,7 @@ export default function Subscription() {
|
||||
{/* Servers */}
|
||||
{subscription.servers && subscription.servers.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<div className="mb-2 text-sm text-dark-500">{t('subscription.servers')}</div>
|
||||
<div className="mb-2 text-sm text-dark-500">{t('subscription.serversLabel')}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{subscription.servers.map((server) => (
|
||||
<span
|
||||
@@ -753,19 +744,16 @@ export default function Subscription() {
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-base font-semibold text-dark-100">
|
||||
{purchase.traffic_gb} ГБ
|
||||
{purchase.traffic_gb} {t('common.units.gb')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="text-sm text-dark-400">
|
||||
{purchase.days_remaining === 0 ? (
|
||||
<span className="text-orange-500">Истекает сегодня</span>
|
||||
) : purchase.days_remaining === 1 ? (
|
||||
<span className="text-orange-400">Остался 1 день</span>
|
||||
<span className="text-orange-500">{t('subscription.expired')}</span>
|
||||
) : (
|
||||
<span>
|
||||
Осталось {purchase.days_remaining}{' '}
|
||||
{pluralizeDays(purchase.days_remaining)}
|
||||
{t('subscription.days', { count: purchase.days_remaining })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -784,7 +772,7 @@ export default function Subscription() {
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{t('subscription.trafficResetAt', 'Сброс')}:{' '}
|
||||
{t('subscription.trafficResetAt')}:{' '}
|
||||
{new Date(purchase.expires_at).toLocaleDateString(undefined, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
@@ -816,7 +804,7 @@ export default function Subscription() {
|
||||
<div>
|
||||
<div className="font-medium text-dark-100">{t('subscription.autoRenewal')}</div>
|
||||
<div className="text-sm text-dark-500">
|
||||
{subscription.autopay_days_before} {t('subscription.daysBeforeExpiry')}
|
||||
{t('subscription.daysBeforeExpiry', { count: subscription.autopay_days_before })}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -927,13 +915,7 @@ export default function Subscription() {
|
||||
<div className="mt-1 text-sm text-dark-400">
|
||||
{t('subscription.pause.pausedDescription')}{' '}
|
||||
{new Date(subscription.end_date).toLocaleDateString()} (
|
||||
{subscription.days_left}{' '}
|
||||
{subscription.days_left === 1
|
||||
? t('subscription.pause.days_one')
|
||||
: subscription.days_left < 5
|
||||
? t('subscription.pause.days_few')
|
||||
: t('subscription.pause.days_many')}
|
||||
)
|
||||
{t('subscription.pause.days', { count: subscription.days_left })})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -988,7 +970,9 @@ export default function Subscription() {
|
||||
{/* Additional Options (Buy Devices) */}
|
||||
{subscription && subscription.is_active && !subscription.is_trial && (
|
||||
<div className="bento-card">
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark-100">Дополнительные опции</h2>
|
||||
<h2 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('subscription.additionalOptions.title')}
|
||||
</h2>
|
||||
|
||||
{/* Buy Devices */}
|
||||
{!showDeviceTopup ? (
|
||||
@@ -998,9 +982,13 @@ export default function Subscription() {
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-dark-100">Докупить устройства</div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{t('subscription.additionalOptions.buyDevices')}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-dark-400">
|
||||
Текущий лимит: {subscription.device_limit} устройств
|
||||
{t('subscription.additionalOptions.currentDeviceLimit', {
|
||||
count: subscription.device_limit,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
@@ -1017,7 +1005,7 @@ export default function Subscription() {
|
||||
) : (
|
||||
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-medium text-dark-100">Докупить устройства</h3>
|
||||
<h3 className="font-medium text-dark-100">{t('subscription.buyDevices')}</h3>
|
||||
<button
|
||||
onClick={() => setShowDeviceTopup(false)}
|
||||
className="text-sm text-dark-400 hover:text-dark-200"
|
||||
@@ -1028,7 +1016,7 @@ export default function Subscription() {
|
||||
|
||||
{devicePriceData?.available === false ? (
|
||||
<div className="py-4 text-center text-sm text-dark-400">
|
||||
{devicePriceData.reason || 'Докупка устройств недоступна'}
|
||||
{devicePriceData.reason || t('subscription.additionalOptions.devicesUnavailable')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -1042,7 +1030,9 @@ export default function Subscription() {
|
||||
</button>
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-bold text-dark-100">{devicesToAdd}</div>
|
||||
<div className="text-sm text-dark-500">устройств</div>
|
||||
<div className="text-sm text-dark-500">
|
||||
{t('subscription.additionalOptions.devicesUnit')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDevicesToAdd(devicesToAdd + 1)}
|
||||
@@ -1055,8 +1045,9 @@ export default function Subscription() {
|
||||
{devicePriceData && (
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-sm text-dark-400">
|
||||
{devicePriceData.price_per_device_label}/устройство (пропорционально{' '}
|
||||
{devicePriceData.days_left} дням)
|
||||
{devicePriceData.price_per_device_label}/
|
||||
{t('subscription.perDevice').replace('/ ', '')} (
|
||||
{t('subscription.days', { count: devicePriceData.days_left })})
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-accent-400">
|
||||
{devicePriceData.total_price_label}
|
||||
@@ -1093,7 +1084,7 @@ export default function Subscription() {
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
</span>
|
||||
) : (
|
||||
'Купить'
|
||||
t('subscription.additionalOptions.buy')
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -1117,10 +1108,14 @@ export default function Subscription() {
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-dark-100">Докупить трафик</div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{t('subscription.additionalOptions.buyTraffic')}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-dark-400">
|
||||
Текущий лимит: {subscription.traffic_limit_gb} ГБ (использовано{' '}
|
||||
{subscription.traffic_used_gb.toFixed(1)} ГБ)
|
||||
{t('subscription.additionalOptions.currentTrafficLimit', {
|
||||
limit: subscription.traffic_limit_gb,
|
||||
used: subscription.traffic_used_gb.toFixed(1),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
@@ -1137,7 +1132,9 @@ export default function Subscription() {
|
||||
) : (
|
||||
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-medium text-dark-100">Докупить трафик</h3>
|
||||
<h3 className="font-medium text-dark-100">
|
||||
{t('subscription.additionalOptions.buyTrafficTitle')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTrafficTopup(false);
|
||||
@@ -1150,13 +1147,12 @@ export default function Subscription() {
|
||||
</div>
|
||||
|
||||
<div className="mb-4 rounded-lg bg-dark-700/30 p-2 text-xs text-dark-500">
|
||||
⚠️ Докупленный трафик добавляется к текущему лимиту и не переносится на
|
||||
следующий период
|
||||
⚠️ {t('subscription.additionalOptions.trafficWarning')}
|
||||
</div>
|
||||
|
||||
{!trafficPackages || trafficPackages.length === 0 ? (
|
||||
<div className="py-4 text-center text-sm text-dark-400">
|
||||
Докупка трафика недоступна для вашего тарифа
|
||||
{t('subscription.additionalOptions.trafficUnavailable')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -1172,7 +1168,9 @@ export default function Subscription() {
|
||||
}`}
|
||||
>
|
||||
<div className="text-lg font-semibold text-dark-100">
|
||||
{pkg.is_unlimited ? '♾️ Безлимит' : `${pkg.gb} ГБ`}
|
||||
{pkg.is_unlimited
|
||||
? '♾️ ' + t('subscription.additionalOptions.unlimited')
|
||||
: `${pkg.gb} ${t('common.units.gb')}`}
|
||||
</div>
|
||||
<div className="font-medium text-accent-400">
|
||||
{formatPrice(pkg.price_kopeks)}
|
||||
@@ -1216,7 +1214,9 @@ export default function Subscription() {
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
</span>
|
||||
) : (
|
||||
`Купить ${selectedTrafficPackage} ГБ`
|
||||
t('subscription.additionalOptions.buyTrafficGb', {
|
||||
gb: selectedTrafficPackage,
|
||||
})
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
@@ -1245,9 +1245,11 @@ export default function Subscription() {
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium text-dark-100">Управление серверами</div>
|
||||
<div className="font-medium text-dark-100">
|
||||
{t('subscription.additionalOptions.manageServers')}
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-dark-400">
|
||||
Подключено серверов: {subscription.servers?.length || 0}
|
||||
{t('subscription.servers', { count: subscription.servers?.length || 0 })}
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
@@ -1264,7 +1266,9 @@ export default function Subscription() {
|
||||
) : (
|
||||
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="font-medium text-dark-100">Управление серверами</h3>
|
||||
<h3 className="font-medium text-dark-100">
|
||||
{t('subscription.additionalOptions.manageServersTitle')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowServerManagement(false);
|
||||
@@ -1283,12 +1287,15 @@ export default function Subscription() {
|
||||
) : countriesData && countriesData.countries.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-dark-700/30 p-2 text-xs text-dark-500">
|
||||
✅ — подключено • ➕ — будет добавлено (платно) • ➖ — будет отключено
|
||||
{t('subscription.serverManagement.statusLegend')}
|
||||
</div>
|
||||
|
||||
{countriesData.discount_percent > 0 && (
|
||||
<div className="rounded-lg border border-success-500/30 bg-success-500/10 p-2 text-xs text-success-400">
|
||||
🎁 Ваша скидка на серверы: -{countriesData.discount_percent}%
|
||||
🎁{' '}
|
||||
{t('subscription.serverManagement.discountBanner', {
|
||||
percent: countriesData.discount_percent,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1343,8 +1350,10 @@ export default function Subscription() {
|
||||
</div>
|
||||
{willBeAdded && (
|
||||
<div className="text-xs text-success-400">
|
||||
+{formatPrice(country.price_kopeks)} (за{' '}
|
||||
{countriesData.days_left} дн.)
|
||||
+{formatPrice(country.price_kopeks)}{' '}
|
||||
{t('subscription.serverManagement.forDays', {
|
||||
days: countriesData.days_left,
|
||||
})}
|
||||
{country.has_discount && (
|
||||
<span className="ml-1 text-dark-500 line-through">
|
||||
{formatPrice(
|
||||
@@ -1360,7 +1369,8 @@ export default function Subscription() {
|
||||
)}
|
||||
{!willBeAdded && !isCurrentlyConnected && (
|
||||
<div className="text-xs text-dark-500">
|
||||
{formatPrice(country.price_per_month_kopeks)}/мес
|
||||
{formatPrice(country.price_per_month_kopeks)}
|
||||
{t('subscription.serverManagement.perMonth')}
|
||||
{country.has_discount && (
|
||||
<span className="ml-1 text-dark-600 line-through">
|
||||
{formatPrice(country.base_price_kopeks)}
|
||||
@@ -1369,7 +1379,9 @@ export default function Subscription() {
|
||||
</div>
|
||||
)}
|
||||
{!country.is_available && !isCurrentlyConnected && (
|
||||
<div className="text-xs text-dark-500">Недоступен</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{t('subscription.serverManagement.unavailable')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1410,7 +1422,9 @@ export default function Subscription() {
|
||||
<div className="space-y-3 border-t border-dark-700/50 pt-3">
|
||||
{added.length > 0 && (
|
||||
<div className="text-sm">
|
||||
<span className="text-success-400">Добавить:</span>{' '}
|
||||
<span className="text-success-400">
|
||||
{t('subscription.serverManagement.toAdd')}
|
||||
</span>{' '}
|
||||
<span className="text-dark-300">
|
||||
{addedServers.map((s) => s.name).join(', ')}
|
||||
</span>
|
||||
@@ -1418,7 +1432,9 @@ export default function Subscription() {
|
||||
)}
|
||||
{removed.length > 0 && (
|
||||
<div className="text-sm">
|
||||
<span className="text-error-400">Отключить:</span>{' '}
|
||||
<span className="text-error-400">
|
||||
{t('subscription.serverManagement.toDisconnect')}
|
||||
</span>{' '}
|
||||
<span className="text-dark-300">
|
||||
{countriesData.countries
|
||||
.filter((c) => removed.includes(c.uuid))
|
||||
@@ -1430,7 +1446,7 @@ export default function Subscription() {
|
||||
{totalCost > 0 && (
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-dark-400">
|
||||
К оплате (пропорционально оставшимся дням):
|
||||
{t('subscription.serverManagement.paymentProrated')}
|
||||
</div>
|
||||
<div className="text-xl font-bold text-accent-400">
|
||||
{formatPrice(totalCost)}
|
||||
@@ -1461,13 +1477,13 @@ export default function Subscription() {
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
</span>
|
||||
) : (
|
||||
'Применить изменения'
|
||||
t('subscription.serverManagement.applyChanges')
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-2 text-center text-sm text-dark-500">
|
||||
Выберите серверы для подключения или отключения
|
||||
{t('subscription.serverManagement.selectServersHint')}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
@@ -1480,7 +1496,7 @@ export default function Subscription() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-sm text-dark-400">
|
||||
Нет доступных серверов для управления
|
||||
{t('subscription.serverManagement.noServersAvailable')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1517,7 +1533,8 @@ export default function Subscription() {
|
||||
) : devicesData && devicesData.devices.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="mb-2 text-sm text-dark-400">
|
||||
{devicesData.total} / {devicesData.device_limit} {t('subscription.devices')}
|
||||
{devicesData.total} /{' '}
|
||||
{t('subscription.devices', { count: devicesData.device_limit })}
|
||||
</div>
|
||||
{devicesData.devices.map((device) => (
|
||||
<div
|
||||
@@ -1597,14 +1614,13 @@ export default function Subscription() {
|
||||
{subscription && !subscription.is_trial && !subscription.tariff_id && (
|
||||
<div className="mb-6 rounded-xl border border-accent-500/30 bg-accent-500/10 p-4">
|
||||
<div className="mb-2 font-medium text-accent-400">
|
||||
📦 Выберите тариф для продления
|
||||
📦 {t('subscription.legacy.selectTariffTitle')}
|
||||
</div>
|
||||
<div className="text-sm text-dark-300">
|
||||
Ваша текущая подписка была создана до введения тарифов. Для продления необходимо
|
||||
выбрать один из доступных тарифов.
|
||||
{t('subscription.legacy.selectTariffDescription')}
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-dark-500">
|
||||
⚠️ Ваша текущая подписка продолжит действовать до окончания срока.
|
||||
⚠️ {t('subscription.legacy.currentSubContinues')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1661,12 +1677,14 @@ export default function Subscription() {
|
||||
{/* Daily tariff info */}
|
||||
{isDailyTariff && (
|
||||
<div className="rounded-lg border border-accent-500/30 bg-accent-500/10 p-3 text-center">
|
||||
<div className="text-sm text-dark-300">Оплата за день</div>
|
||||
<div className="text-sm text-dark-300">
|
||||
{t('subscription.switchTariff.dailyPayment')}
|
||||
</div>
|
||||
<div className="text-lg font-bold text-accent-400">
|
||||
{formatPrice(dailyPrice)}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-dark-400">
|
||||
Списывается ежедневно с баланса
|
||||
{t('subscription.switchTariff.dailyChargeDescription')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -1732,10 +1750,12 @@ export default function Subscription() {
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-success-400">
|
||||
Ваша группа: {tariffs.find((t) => t.promo_group_name)?.promo_group_name}
|
||||
{t('subscription.promoGroup.yourGroup', {
|
||||
name: tariffs.find((t) => t.promo_group_name)?.promo_group_name,
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-dark-400">
|
||||
Персональные скидки применены к ценам
|
||||
{t('subscription.promoGroup.personalDiscountsApplied')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1795,12 +1815,12 @@ export default function Subscription() {
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-dark-400">
|
||||
{tariff.device_limit} {t('subscription.devices')}
|
||||
{t('subscription.devices', { count: tariff.device_limit })}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-dark-400">
|
||||
{tariff.servers_count} {t('subscription.servers')}
|
||||
{t('subscription.servers', { count: tariff.servers_count })}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -1833,7 +1853,7 @@ export default function Subscription() {
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<span>/ день</span>
|
||||
<span>{t('subscription.tariff.perDay')}</span>
|
||||
{/* Show discount badge */}
|
||||
{tariff.daily_discount_percent &&
|
||||
tariff.daily_discount_percent > 0 ? (
|
||||
@@ -1895,7 +1915,9 @@ export default function Subscription() {
|
||||
}
|
||||
// Fallback
|
||||
return (
|
||||
<span className="font-medium text-accent-400">Гибкая оплата</span>
|
||||
<span className="font-medium text-accent-400">
|
||||
{t('subscription.tariff.flexiblePayment')}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
@@ -1930,7 +1952,7 @@ export default function Subscription() {
|
||||
}}
|
||||
className="btn-primary flex-1 py-2 text-sm"
|
||||
>
|
||||
Выбрать для продления
|
||||
{t('subscription.tariff.selectForRenewal')}
|
||||
</button>
|
||||
) : canSwitch ? (
|
||||
/* Other tariffs with existing tariff - switch button */
|
||||
@@ -1998,7 +2020,7 @@ export default function Subscription() {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-dark-500">{t('subscription.servers')}:</span>
|
||||
<span className="text-dark-500">{t('subscription.serversLabel')}:</span>
|
||||
<span className="ml-2 text-dark-200">{selectedTariff.servers_count}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2168,7 +2190,7 @@ export default function Subscription() {
|
||||
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="font-medium text-dark-200">
|
||||
Произвольное кол-во дней
|
||||
{t('subscription.customDays.title')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2230,8 +2252,9 @@ export default function Subscription() {
|
||||
return (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
{customDays} {pluralizeDays(customDays)} ×{' '}
|
||||
{formatPrice(selectedTariff.price_per_day_kopeks ?? 0)}/день
|
||||
{t('subscription.days', { count: customDays })} ×{' '}
|
||||
{formatPrice(selectedTariff.price_per_day_kopeks ?? 0)}/
|
||||
{t('subscription.customDays.perDay')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-accent-400">
|
||||
@@ -2261,11 +2284,13 @@ export default function Subscription() {
|
||||
{selectedTariff.custom_traffic_enabled &&
|
||||
(selectedTariff.traffic_price_per_gb_kopeks ?? 0) > 0 && (
|
||||
<div>
|
||||
<div className="mb-3 text-sm text-dark-400">Трафик</div>
|
||||
<div className="mb-3 text-sm text-dark-400">
|
||||
{t('subscription.customTraffic.label')}
|
||||
</div>
|
||||
<div className="rounded-xl border border-dark-700/50 bg-dark-800/30 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="font-medium text-dark-200">
|
||||
Выбрать объём трафика
|
||||
{t('subscription.customTraffic.selectVolume')}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -2283,7 +2308,9 @@ export default function Subscription() {
|
||||
</div>
|
||||
{!useCustomTraffic && (
|
||||
<div className="text-sm text-dark-400">
|
||||
По умолчанию: {selectedTariff.traffic_limit_label}
|
||||
{t('subscription.customTraffic.default', {
|
||||
label: selectedTariff.traffic_limit_label,
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{useCustomTraffic && (
|
||||
@@ -2317,14 +2344,14 @@ export default function Subscription() {
|
||||
}
|
||||
className="w-20 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-center text-dark-100"
|
||||
/>
|
||||
<span className="text-dark-400">ГБ</span>
|
||||
<span className="text-dark-400">{t('common.units.gb')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-dark-400">
|
||||
{customTrafficGb} ГБ ×{' '}
|
||||
{formatPrice(selectedTariff.traffic_price_per_gb_kopeks ?? 0)}
|
||||
/ГБ
|
||||
{customTrafficGb} {t('common.units.gb')} ×{' '}
|
||||
{formatPrice(selectedTariff.traffic_price_per_gb_kopeks ?? 0)}/
|
||||
{t('common.units.gb')}
|
||||
</span>
|
||||
<span className="font-medium text-accent-400">
|
||||
+
|
||||
@@ -2377,7 +2404,8 @@ export default function Subscription() {
|
||||
{useCustomDays ? (
|
||||
<div className="flex justify-between text-sm text-dark-300">
|
||||
<span>
|
||||
Период: {customDays} {pluralizeDays(customDays)}
|
||||
{t('subscription.stepPeriod')}:{' '}
|
||||
{t('subscription.days', { count: customDays })}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{formatPrice(promoPeriod.price)}</span>
|
||||
@@ -2421,7 +2449,11 @@ export default function Subscription() {
|
||||
</>
|
||||
) : (
|
||||
<div className="flex justify-between text-sm text-dark-300">
|
||||
<span>Период: {selectedTariffPeriod.label}</span>
|
||||
<span>
|
||||
{t('subscription.summary.period', {
|
||||
label: selectedTariffPeriod.label,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{formatPrice(promoPeriod.price)}</span>
|
||||
{(hasExistingPeriodDiscount ||
|
||||
@@ -2442,7 +2474,9 @@ export default function Subscription() {
|
||||
)}
|
||||
{useCustomTraffic && selectedTariff.custom_traffic_enabled && (
|
||||
<div className="flex justify-between text-sm text-dark-300">
|
||||
<span>Трафик: {customTrafficGb} ГБ</span>
|
||||
<span>
|
||||
{t('subscription.summary.traffic', { gb: customTrafficGb })}
|
||||
</span>
|
||||
<span>+{formatPrice(trafficPrice)}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -2765,7 +2799,7 @@ export default function Subscription() {
|
||||
</div>
|
||||
<div className="mt-4 space-y-1 text-center text-sm text-dark-500">
|
||||
<div className="text-accent-400">
|
||||
{selectedPeriod.devices.min} {t('subscription.devicesFree')}
|
||||
{t('subscription.devicesFree', { count: selectedPeriod.devices.min })}
|
||||
</div>
|
||||
{selectedPeriod.devices.max > selectedPeriod.devices.min && (
|
||||
<div>
|
||||
|
||||
@@ -189,7 +189,7 @@ export default function Support() {
|
||||
file,
|
||||
preview: '',
|
||||
uploading: false,
|
||||
error: t('support.invalidFileType') || 'Invalid file type. Use JPEG, PNG, GIF, or WebP.',
|
||||
error: t('support.invalidFileType'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -200,7 +200,7 @@ export default function Support() {
|
||||
file,
|
||||
preview: '',
|
||||
uploading: false,
|
||||
error: t('support.fileTooLarge') || 'File is too large. Maximum size is 10MB.',
|
||||
error: t('support.fileTooLarge'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -222,7 +222,7 @@ export default function Support() {
|
||||
file,
|
||||
preview,
|
||||
uploading: false,
|
||||
error: t('support.uploadFailed') || 'Failed to upload image',
|
||||
error: t('support.uploadFailed'),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -551,10 +551,7 @@ export default function Support() {
|
||||
// Rate limit: max 3 tickets per 60 seconds
|
||||
if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_CREATE, 3, 60000)) {
|
||||
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_CREATE);
|
||||
setRateLimitError(
|
||||
t('support.tooManyRequests', { seconds: resetTime }) ||
|
||||
`Слишком много запросов. Подождите ${resetTime} сек.`,
|
||||
);
|
||||
setRateLimitError(t('support.tooManyRequests', { seconds: resetTime }));
|
||||
return;
|
||||
}
|
||||
createMutation.mutate();
|
||||
@@ -716,10 +713,7 @@ export default function Support() {
|
||||
// Rate limit: max 5 replies per 30 seconds
|
||||
if (!checkRateLimit(RATE_LIMIT_KEYS.TICKET_REPLY, 5, 30000)) {
|
||||
const resetTime = getRateLimitResetTime(RATE_LIMIT_KEYS.TICKET_REPLY);
|
||||
setRateLimitError(
|
||||
t('support.tooManyRequests', { seconds: resetTime }) ||
|
||||
`Слишком много запросов. Подождите ${resetTime} сек.`,
|
||||
);
|
||||
setRateLimitError(t('support.tooManyRequests', { seconds: resetTime }));
|
||||
return;
|
||||
}
|
||||
replyMutation.mutate();
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function TelegramRedirect() {
|
||||
// Handle retry with limit to prevent infinite loops
|
||||
const handleRetry = () => {
|
||||
if (retryCount >= MAX_RETRY_ATTEMPTS) {
|
||||
setErrorMessage('Превышено количество попыток. Попробуйте позже.');
|
||||
setErrorMessage(t('telegramRedirect.maxRetries'));
|
||||
sessionStorage.removeItem(RETRY_COUNT_KEY);
|
||||
return;
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export default function TelegramRedirect() {
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-dark-200">{t('auth.loginSuccess')}</p>
|
||||
<p className="mt-2 text-sm text-dark-500">Перенаправление...</p>
|
||||
<p className="mt-2 text-sm text-dark-500">{t('telegramRedirect.redirecting')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -198,7 +198,7 @@ export default function TelegramRedirect() {
|
||||
{t('auth.tryAgain')}
|
||||
</button>
|
||||
<button onClick={() => navigate('/login')} className="btn-secondary w-full">
|
||||
Войти другим способом
|
||||
{t('telegramRedirect.loginAlternative')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,11 +222,9 @@ export default function TelegramRedirect() {
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="mb-2 text-dark-200">Откройте в Telegram</p>
|
||||
<p className="mb-6 text-sm text-dark-400">
|
||||
Для автоматического входа откройте это приложение через бота в Telegram
|
||||
</p>
|
||||
<p className="text-sm text-dark-500">Перенаправление на страницу входа...</p>
|
||||
<p className="mb-2 text-dark-200">{t('telegramRedirect.openInTelegram')}</p>
|
||||
<p className="mb-6 text-sm text-dark-400">{t('telegramRedirect.openInTelegramDesc')}</p>
|
||||
<p className="text-sm text-dark-500">{t('telegramRedirect.redirectToLogin')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -560,8 +560,8 @@ export default function Wheel() {
|
||||
>
|
||||
<CalendarIcon />
|
||||
<span className="font-semibold">
|
||||
{t('wheel.payWithDays')} ({config.spin_cost_days}{' '}
|
||||
{config.spin_cost_days === 1 ? t('wheel.day') : t('wheel.days')})
|
||||
{t('wheel.payWithDays')} (
|
||||
{t('wheel.days', { count: config.spin_cost_days ?? 0 })})
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
@@ -603,8 +603,7 @@ export default function Wheel() {
|
||||
>
|
||||
<CalendarIcon />
|
||||
<span className="font-semibold">
|
||||
{config.spin_cost_days}{' '}
|
||||
{config.spin_cost_days === 1 ? t('wheel.day') : t('wheel.days')}
|
||||
{t('wheel.days', { count: config.spin_cost_days ?? 0 })}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user