Merge pull request #406 from BEDOLAGA-DEV/dev

Dev
This commit is contained in:
Egor
2026-04-23 05:20:09 +03:00
committed by GitHub
13 changed files with 186 additions and 169 deletions

View File

@@ -228,7 +228,7 @@ export default function SubscriptionCardExpired({
>
<div className="flex items-center">
<div className="mb-0.5 font-mono text-[10px] font-medium uppercase tracking-wider text-dark-50/30">
{t('dashboard.expired.expiredDate')}
{isLimited ? t('dashboard.expired.activeUntil') : t('dashboard.expired.expiredDate')}
</div>
<div className="ml-3 text-base font-bold tracking-tight text-dark-50/50">
{formattedDate}
@@ -280,7 +280,7 @@ export default function SubscriptionCardExpired({
>
<path d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{t('subscription.trafficLimited')}
{t('subscription.buyTraffic')}
</Link>
) : (
<>

View File

@@ -15,7 +15,9 @@ export default function PurchaseCTAButton({
}: PurchaseCTAButtonProps) {
const { t } = useTranslation();
const isExpired = !subscription || (!subscription.is_active && !subscription.is_trial);
const isExpired =
!subscription ||
(!subscription.is_active && !subscription.is_trial && !subscription.is_limited);
const isTrial = subscription?.is_trial;
const isDaily = subscription?.is_daily;

View File

@@ -82,7 +82,10 @@ export default function SubscriptionListCard({
};
const isTrial = subscription.is_trial;
const isActive = subscription.status === 'active' || subscription.status === 'trial';
const isActive =
subscription.status === 'active' ||
subscription.status === 'trial' ||
subscription.status === 'limited';
const isExpired = subscription.status === 'expired' || subscription.status === 'disabled';
const trafficLimit = subscription.traffic_limit_gb;
const trafficUsed = subscription.traffic_used_gb;
@@ -95,21 +98,25 @@ export default function SubscriptionListCard({
const trafficColor =
trafficPercent >= 90 ? 'bg-red-400' : trafficPercent >= 70 ? 'bg-amber-400' : 'bg-emerald-400';
const borderColor = isTrial
? 'rgba(251,191,36,0.2)'
: isExpired
? 'rgba(255,59,92,0.15)'
: g.cardBorder;
const isLimitedStatus = subscription.status === 'limited';
const bgColor = isTrial
? isDark
? 'rgba(251,191,36,0.04)'
: 'rgba(251,191,36,0.03)'
: isExpired
const borderColor =
isTrial || isLimitedStatus
? 'rgba(251,191,36,0.2)'
: isExpired
? 'rgba(255,59,92,0.15)'
: g.cardBorder;
const bgColor =
isTrial || isLimitedStatus
? isDark
? 'rgba(255,59,92,0.04)'
: 'rgba(255,59,92,0.03)'
: g.cardBg;
? 'rgba(251,191,36,0.04)'
: 'rgba(251,191,36,0.03)'
: isExpired
? isDark
? 'rgba(255,59,92,0.04)'
: 'rgba(255,59,92,0.03)'
: g.cardBg;
return (
<button

View File

@@ -272,7 +272,8 @@
"tariffs": "Tariffs",
"traffic": "Traffic",
"devices": "Devices",
"expiredDate": "Expired"
"expiredDate": "Expired",
"activeUntil": "Active until"
},
"suspended": {
"title": "Subscription Suspended",
@@ -554,8 +555,8 @@
"unlimited": "Unlimited",
"buyTrafficGb": "Buy {{gb}} GB",
"buyUnlimited": "Buy Unlimited",
"manageServers": "Server management",
"manageServersTitle": "Server management"
"manageServers": "Squad management",
"manageServersTitle": "Squad management"
},
"serverManagement": {
"statusLegend": "✅ — connected • — will be added (paid) • — will be disconnected",
@@ -1099,7 +1100,7 @@
"apps": "Apps",
"wheel": "Wheel",
"tariffs": "Tariffs",
"servers": "Servers",
"servers": "Squads",
"banSystem": "Ban Monitoring",
"broadcasts": "Broadcasts",
"users": "Users",
@@ -2350,8 +2351,8 @@
}
},
"servers": {
"title": "Server Management",
"subtitle": "Configure VPN servers",
"title": "Squad Management",
"subtitle": "Configure squads",
"sync": "Sync",
"syncing": "Syncing...",
"syncNow": "Sync now",

View File

@@ -386,8 +386,8 @@
"unlimited": "نامحدود",
"buyTrafficGb": "خرید {{gb}} GB",
"buyUnlimited": "خرید نامحدود",
"manageServers": "مدیریت سرورها",
"manageServersTitle": "مدیریت سرورها"
"manageServers": "مدیریت اسکوادها",
"manageServersTitle": "مدیریت اسکوادها"
},
"serverManagement": {
"statusLegend": "✅ — متصل • — اضافه خواهد شد (پولی) • — قطع خواهد شد",
@@ -924,7 +924,7 @@
"apps": "برنامه‌ها",
"wheel": "چرخ",
"tariffs": "تعرفه‌ها",
"servers": "سرورها",
"servers": "اسکوادها",
"broadcasts": "پیام‌رسانی",
"emailTemplates": "قالب‌های ایمیل",
"paymentMethods": "روش‌های پرداخت",
@@ -1986,8 +1986,8 @@
}
},
"servers": {
"title": "مدیریت سرورها",
"subtitle": "پیکربندی سرورهای VPN",
"title": "مدیریت اسکوادها",
"subtitle": "پیکربندی اسکوادها",
"sync": "همگام‌سازی",
"syncing": "در حال همگام‌سازی...",
"syncNow": "همگام‌سازی الان",

View File

@@ -284,7 +284,8 @@
"tariffs": "Тарифы",
"traffic": "Трафик",
"devices": "Устройства",
"expiredDate": "Истекла"
"expiredDate": "Истекла",
"activeUntil": "Активна до"
},
"suspended": {
"title": "Подписка приостановлена",
@@ -582,8 +583,8 @@
"unlimited": "Безлимит",
"buyTrafficGb": "Купить {{gb}} ГБ",
"buyUnlimited": "Купить Безлимит",
"manageServers": "Управление серверами",
"manageServersTitle": "Управление серверами"
"manageServers": "Управление сквадами",
"manageServersTitle": "Управление сквадами"
},
"serverManagement": {
"statusLegend": "✅ — подключено • — будет добавлено (платно) • — будет отключено",
@@ -1120,7 +1121,7 @@
"apps": "Приложения",
"wheel": "Колесо",
"tariffs": "Тарифы",
"servers": "Серверы",
"servers": "Сквады",
"banSystem": "Мониторинг банов",
"broadcasts": "Рассылки",
"users": "Пользователи",
@@ -2856,8 +2857,8 @@
}
},
"servers": {
"title": "Управление серверами",
"subtitle": "Настройка серверов VPN",
"title": "Управление сквадами",
"subtitle": "Настройка сквадов",
"sync": "Синхронизировать",
"syncing": "Синхронизация...",
"syncNow": "Синхронизировать сейчас",

View File

@@ -386,8 +386,8 @@
"unlimited": "无限制",
"buyTrafficGb": "购买 {{gb}} GB",
"buyUnlimited": "购买无限流量",
"manageServers": "服务器管理",
"manageServersTitle": "服务器管理"
"manageServers": "小队管理",
"manageServersTitle": "小队管理"
},
"serverManagement": {
"statusLegend": "✅ — 已连接 • — 将添加(付费)• — 将断开",
@@ -924,7 +924,7 @@
"apps": "应用",
"wheel": "转盘",
"tariffs": "套餐",
"servers": "服务器",
"servers": "小队",
"broadcasts": "群发",
"emailTemplates": "邮件模板",
"paymentMethods": "支付方式",

View File

@@ -186,14 +186,14 @@ export default function AdminServers() {
onClick={() => toggleMutation.mutate(server.id)}
className={`rounded-lg p-2 transition-colors ${
server.is_available
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
? 'bg-error-500/20 text-error-400 hover:bg-error-500/30'
: 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
}`}
title={
server.is_available ? t('admin.servers.disable') : t('admin.servers.enable')
}
>
{server.is_available ? <CheckIcon /> : <XIcon />}
{server.is_available ? <XIcon /> : <CheckIcon />}
</button>
{/* Toggle Trial */}
@@ -201,7 +201,7 @@ export default function AdminServers() {
onClick={() => toggleTrialMutation.mutate(server.id)}
className={`rounded-lg p-2 transition-colors ${
server.is_trial_eligible
? 'bg-accent-500/20 text-accent-400 hover:bg-accent-500/30'
? 'bg-warning-500/20 text-warning-400 hover:bg-warning-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
}`}
title={t('admin.servers.toggleTrial')}

View File

@@ -1658,7 +1658,10 @@ export default function AdminUserDetail() {
.filter((tariffItem) => {
const purchasedIds = new Set(
userSubscriptions
.filter((s) => s.is_active || s.status === 'trial')
.filter(
(s) =>
s.is_active || s.status === 'trial' || s.status === 'limited',
)
.map((s) => s.tariff_id),
);
return !purchasedIds.has(tariffItem.id);

View File

@@ -516,35 +516,14 @@ function BuyTabContent({
const [selectedSubOption, setSelectedSubOption] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<string | null>(null);
// Collect ALL unique periods across ALL tariffs
const allPeriods = useMemo(() => {
const periodMap = new Map<number, GiftTariffPeriod>();
for (const tariff of config.tariffs) {
for (const period of tariff.periods) {
if (!periodMap.has(period.days)) {
periodMap.set(period.days, period);
}
}
}
return Array.from(periodMap.values()).sort((a, b) => a.days - b.days);
}, [config]);
// Filter tariffs to only those that have the selected period
const visibleTariffs = useMemo(() => {
if (!selectedPeriodDays) return config.tariffs;
return config.tariffs.filter((tariff) =>
tariff.periods.some((p) => p.days === selectedPeriodDays),
);
}, [config, selectedPeriodDays]);
// Auto-select first tariff, period, method on config load
useEffect(() => {
if (allPeriods.length > 0 && selectedPeriodDays === null) {
setSelectedPeriodDays(allPeriods[0].days);
}
if (visibleTariffs.length > 0 && selectedTariffId === null) {
setSelectedTariffId(visibleTariffs[0].id);
if (config.tariffs.length > 0 && selectedTariffId === null) {
const firstTariff = config.tariffs[0];
setSelectedTariffId(firstTariff.id);
if (firstTariff.periods.length > 0 && selectedPeriodDays === null) {
setSelectedPeriodDays(firstTariff.periods[0].days);
}
}
if (config.payment_methods.length > 0 && selectedMethod === null) {
@@ -556,16 +535,19 @@ function BuyTabContent({
setSelectedSubOption(null);
}
}
}, [config, allPeriods, visibleTariffs, selectedTariffId, selectedPeriodDays, selectedMethod]);
}, [config, selectedTariffId, selectedPeriodDays, selectedMethod]);
// When period changes, auto-select first visible tariff if current is hidden
// When tariff changes, auto-select its first period
useEffect(() => {
if (!visibleTariffs.length) return;
const currentVisible = visibleTariffs.find((tariff) => tariff.id === selectedTariffId);
if (!currentVisible) {
setSelectedTariffId(visibleTariffs[0].id);
if (!selectedTariffId) return;
const tariff = config.tariffs.find((t) => t.id === selectedTariffId);
if (tariff && tariff.periods.length > 0) {
const hasCurrent = tariff.periods.some((p) => p.days === selectedPeriodDays);
if (!hasCurrent) {
setSelectedPeriodDays(tariff.periods[0].days);
}
}
}, [visibleTariffs, selectedTariffId]);
}, [selectedTariffId, config.tariffs, selectedPeriodDays]);
// Derived data
const selectedTariff = useMemo(
@@ -661,15 +643,15 @@ function BuyTabContent({
return `${t('gift.fromBalance')} (${formatPrice(config.balance_kopeks)})`;
}, [config, t]);
const showTariffCards = visibleTariffs.length > 1;
const showTariffCards = config.tariffs.length > 1;
// Periods for the selected tariff (for period cards)
const periodsForDisplay = useMemo(() => {
if (selectedTariff) {
return [...selectedTariff.periods].sort((a, b) => a.days - b.days);
}
return allPeriods;
}, [selectedTariff, allPeriods]);
return [];
}, [selectedTariff]);
return (
<div className="space-y-6">
@@ -680,7 +662,7 @@ function BuyTabContent({
{t('gift.selectTariff')}
</h2>
<div role="radiogroup" aria-label={t('gift.chooseTariff')} className="space-y-2">
{visibleTariffs.map((tariff) => (
{config.tariffs.map((tariff) => (
<TariffCard
key={tariff.id}
tariff={tariff}

View File

@@ -957,7 +957,7 @@ export default function Subscription() {
<div className="mb-5">
<CountdownTimer
endDate={subscription.end_date}
isActive={subscription.is_active}
isActive={subscription.is_active || subscription.is_limited}
glassColors={g}
/>
</div>
@@ -1316,68 +1316,25 @@ export default function Subscription() {
<PurchaseCTAButton subscription={subscription} isMultiTariff={isMultiTariff} />
{/* Delete expired subscription */}
{isMultiTariff && subscription && !subscription.is_active && !subscription.is_trial && (
<div className="space-y-3">
{!showDeleteSheet ? (
<button
onClick={async () => {
if (platform === 'telegram') {
const confirmed = await destructiveConfirm(
t(
'subscription.deleteWarning',
'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны.',
),
t('subscription.confirmDelete', 'Да, удалить'),
t('subscription.deleteTitle', 'Удалить подписку?'),
);
if (!confirmed) return;
setDeleteLoading(true);
try {
await subscriptionApi.deleteSubscription(subscription.id);
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
navigate('/subscriptions', { replace: true });
} catch {
setDeleteLoading(false);
}
} else {
setShowDeleteSheet(true);
}
}}
disabled={deleteLoading}
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-red-400/20 bg-red-400/5 p-3.5 text-sm font-medium text-red-400 transition-colors hover:bg-red-400/10 disabled:opacity-50"
>
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
{t('subscription.delete', 'Удалить подписку')}
</button>
) : (
<div
className="rounded-2xl border border-red-400/20 p-4"
style={{ background: 'rgba(255,59,92,0.04)' }}
>
<div className="mb-3 text-sm font-semibold text-red-400">
{t('subscription.deleteTitle', 'Удалить подписку?')}
</div>
<div className="mb-4 text-xs" style={{ color: g.textSecondary }}>
{t(
'subscription.deleteWarning',
'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны. Это действие нельзя отменить.',
)}
</div>
<div className="flex gap-2">
<button
onClick={async () => {
{isMultiTariff &&
subscription &&
!subscription.is_active &&
!subscription.is_trial &&
!subscription.is_limited && (
<div className="space-y-3">
{!showDeleteSheet ? (
<button
onClick={async () => {
if (platform === 'telegram') {
const confirmed = await destructiveConfirm(
t(
'subscription.deleteWarning',
'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны.',
),
t('subscription.confirmDelete', 'Да, удалить'),
t('subscription.deleteTitle', 'Удалить подписку?'),
);
if (!confirmed) return;
setDeleteLoading(true);
try {
await subscriptionApi.deleteSubscription(subscription.id);
@@ -1385,28 +1342,75 @@ export default function Subscription() {
navigate('/subscriptions', { replace: true });
} catch {
setDeleteLoading(false);
setShowDeleteSheet(false);
}
}}
disabled={deleteLoading}
className="flex-1 rounded-xl bg-red-500 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-red-600 disabled:opacity-50"
} else {
setShowDeleteSheet(true);
}
}}
disabled={deleteLoading}
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-red-400/20 bg-red-400/5 p-3.5 text-sm font-medium text-red-400 transition-colors hover:bg-red-400/10 disabled:opacity-50"
>
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
{deleteLoading
? t('common.processing', 'Удаление...')
: t('subscription.confirmDelete', 'Да, удалить')}
</button>
<button
onClick={() => setShowDeleteSheet(false)}
className="flex-1 rounded-xl border border-dark-700 py-2.5 text-sm font-medium transition-colors hover:bg-dark-700"
style={{ color: g.textSecondary }}
>
{t('common.cancel', 'Отмена')}
</button>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
{t('subscription.delete', 'Удалить подписку')}
</button>
) : (
<div
className="rounded-2xl border border-red-400/20 p-4"
style={{ background: 'rgba(255,59,92,0.04)' }}
>
<div className="mb-3 text-sm font-semibold text-red-400">
{t('subscription.deleteTitle', 'Удалить подписку?')}
</div>
<div className="mb-4 text-xs" style={{ color: g.textSecondary }}>
{t(
'subscription.deleteWarning',
'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны. Это действие нельзя отменить.',
)}
</div>
<div className="flex gap-2">
<button
onClick={async () => {
setDeleteLoading(true);
try {
await subscriptionApi.deleteSubscription(subscription.id);
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
navigate('/subscriptions', { replace: true });
} catch {
setDeleteLoading(false);
setShowDeleteSheet(false);
}
}}
disabled={deleteLoading}
className="flex-1 rounded-xl bg-red-500 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-red-600 disabled:opacity-50"
>
{deleteLoading
? t('common.processing', 'Удаление...')
: t('subscription.confirmDelete', 'Да, удалить')}
</button>
<button
onClick={() => setShowDeleteSheet(false)}
className="flex-1 rounded-xl border border-dark-700 py-2.5 text-sm font-medium transition-colors hover:bg-dark-700"
style={{ color: g.textSecondary }}
>
{t('common.cancel', 'Отмена')}
</button>
</div>
</div>
</div>
)}
</div>
)}
)}
</div>
)}
{/* Additional Options (Buy Devices) */}
{subscription &&

View File

@@ -781,7 +781,7 @@ export default function SubscriptionPurchase() {
!isCurrentTariff &&
!subscription.is_trial &&
!isSubscriptionExpired &&
subscription.is_active;
(subscription.is_active || subscription.is_limited);
const isLegacySubscription =
subscription && !subscription.is_trial && !subscription.tariff_id;

View File

@@ -7,11 +7,28 @@ export function formatUptime(seconds: number): string {
return `${minutes}m`;
}
export function formatPrice(kopeks: number): string {
import i18next from 'i18next';
const LANG_CURRENCY_MAP: Record<string, { currency: string; locale: string; symbol: string }> = {
ru: { currency: 'RUB', locale: 'ru-RU', symbol: '₽' },
en: { currency: 'USD', locale: 'en-US', symbol: '$' },
zh: { currency: 'CNY', locale: 'zh-CN', symbol: '¥' },
fa: { currency: 'IRR', locale: 'fa-IR', symbol: '﷼' },
};
const DEFAULT_CURRENCY = { currency: 'RUB', locale: 'ru-RU', symbol: '₽' };
export function formatPrice(kopeks: number, lang?: string): string {
const resolvedLang = lang || i18next.language || 'ru';
const config = LANG_CURRENCY_MAP[resolvedLang] || DEFAULT_CURRENCY;
const rubles = kopeks / 100;
return new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
maximumFractionDigits: 0,
}).format(rubles);
try {
return new Intl.NumberFormat(config.locale, {
style: 'currency',
currency: config.currency,
maximumFractionDigits: 0,
}).format(rubles);
} catch {
return `${Math.round(rubles)} ${config.symbol}`;
}
}