feat(i18n): migrate all hardcoded Russian text to i18n

- Replace all hardcoded Russian strings with t() calls across 30+ files
- Add ~500 new translation keys to all 4 locales (ru, en, zh, fa)
- Convert module-level config objects to labelKey pattern
- Remove Russian fallbacks from t() calls (fallbackLng handles it)
- Replace DeepLinkRedirect custom i18n with standard t() calls
- Fix subscription.servers key collision (string vs object)
This commit is contained in:
c0mrade
2026-01-27 22:19:41 +03:00
parent d837f8050c
commit 9707ad97a4
34 changed files with 5275 additions and 1075 deletions

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'),
},
],
},

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
))}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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>

View File

@@ -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.servers.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.servers.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.servers.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.servers.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.servers.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.servers.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.servers.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.servers.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.servers.applyChanges')
)}
</button>
</div>
) : (
<div className="py-2 text-center text-sm text-dark-500">
Выберите серверы для подключения или отключения
{t('subscription.servers.selectServersHint')}
</div>
);
})()}
@@ -1480,7 +1496,7 @@ export default function Subscription() {
</div>
) : (
<div className="py-4 text-center text-sm text-dark-400">
Нет доступных серверов для управления
{t('subscription.servers.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>

View File

@@ -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();

View File

@@ -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>
)}

View File

@@ -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>
)}