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

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