- {t('dashboard.expired.expiredDate')}
+ {isLimited ? t('dashboard.expired.activeUntil') : t('dashboard.expired.expiredDate')}
{formattedDate}
@@ -280,7 +280,7 @@ export default function SubscriptionCardExpired({
>
- {t('subscription.trafficLimited')}
+ {t('subscription.buyTraffic')}
) : (
<>
diff --git a/src/components/subscription/PurchaseCTAButton.tsx b/src/components/subscription/PurchaseCTAButton.tsx
index 1fa7b0f..a4c816a 100644
--- a/src/components/subscription/PurchaseCTAButton.tsx
+++ b/src/components/subscription/PurchaseCTAButton.tsx
@@ -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;
diff --git a/src/components/subscription/SubscriptionListCard.tsx b/src/components/subscription/SubscriptionListCard.tsx
index d92f7ff..48315af 100644
--- a/src/components/subscription/SubscriptionListCard.tsx
+++ b/src/components/subscription/SubscriptionListCard.tsx
@@ -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 (
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 ? : }
+ {server.is_available ? : }
{/* 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')}
diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx
index 0455580..a5a80ae 100644
--- a/src/pages/AdminUserDetail.tsx
+++ b/src/pages/AdminUserDetail.tsx
@@ -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);
diff --git a/src/pages/GiftSubscription.tsx b/src/pages/GiftSubscription.tsx
index 781e36d..b929905 100644
--- a/src/pages/GiftSubscription.tsx
+++ b/src/pages/GiftSubscription.tsx
@@ -516,35 +516,14 @@ function BuyTabContent({
const [selectedSubOption, setSelectedSubOption] = useState
(null);
const [submitError, setSubmitError] = useState(null);
- // Collect ALL unique periods across ALL tariffs
- const allPeriods = useMemo(() => {
- const periodMap = new Map();
- 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 (
@@ -680,7 +662,7 @@ function BuyTabContent({
{t('gift.selectTariff')}
- {visibleTariffs.map((tariff) => (
+ {config.tariffs.map((tariff) => (
@@ -1316,68 +1316,25 @@ export default function Subscription() {
{/* Delete expired subscription */}
- {isMultiTariff && subscription && !subscription.is_active && !subscription.is_trial && (
-
- {!showDeleteSheet ? (
-
{
- 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"
- >
-
-
-
- {t('subscription.delete', 'Удалить подписку')}
-
- ) : (
-
-
- {t('subscription.deleteTitle', 'Удалить подписку?')}
-
-
- {t(
- 'subscription.deleteWarning',
- 'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны. Это действие нельзя отменить.',
- )}
-
-
-
{
+ {isMultiTariff &&
+ subscription &&
+ !subscription.is_active &&
+ !subscription.is_trial &&
+ !subscription.is_limited && (
+
+ {!showDeleteSheet ? (
+
{
+ 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"
+ >
+
- {deleteLoading
- ? t('common.processing', 'Удаление...')
- : t('subscription.confirmDelete', 'Да, удалить')}
-
-
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', 'Отмена')}
-
+
+
+ {t('subscription.delete', 'Удалить подписку')}
+
+ ) : (
+
+
+ {t('subscription.deleteTitle', 'Удалить подписку?')}
+
+
+ {t(
+ 'subscription.deleteWarning',
+ 'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны. Это действие нельзя отменить.',
+ )}
+
+
+ {
+ 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', 'Да, удалить')}
+
+ 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', 'Отмена')}
+
+
-
- )}
-
- )}
+ )}
+
+ )}
{/* Additional Options (Buy Devices) */}
{subscription &&
diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx
index b3a888c..8221ece 100644
--- a/src/pages/SubscriptionPurchase.tsx
+++ b/src/pages/SubscriptionPurchase.tsx
@@ -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;
diff --git a/src/utils/format.ts b/src/utils/format.ts
index f084109..ee42e2f 100644
--- a/src/utils/format.ts
+++ b/src/utils/format.ts
@@ -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
= {
+ 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}`;
+ }
}