diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx
index 9b13259..7bcfb4e 100644
--- a/src/components/dashboard/SubscriptionCardExpired.tsx
+++ b/src/components/dashboard/SubscriptionCardExpired.tsx
@@ -38,6 +38,9 @@ export default function SubscriptionCardExpired({
const formattedDate = new Date(subscription.end_date).toLocaleDateString();
+ // Detect limited (traffic exhausted) state
+ const isLimited = subscription.is_limited;
+
// Detect daily subscription (disabled or expired)
const isDaily = subscription.is_daily;
const isDisabledDaily = subscription.status === 'disabled' && isDaily;
@@ -92,19 +95,38 @@ export default function SubscriptionCardExpired({
navigate(`/balance/top-up?${params.toString()}`);
};
+ // Color scheme: amber for limited, red for expired/disabled
+ const accent = isLimited
+ ? {
+ r: 255,
+ g: 184,
+ b: 0,
+ hex: '#FFB800',
+ gradient: 'linear-gradient(135deg, #FFB800, #FF8C00)',
+ }
+ : {
+ r: 255,
+ g: 59,
+ b: 92,
+ hex: '#FF3B5C',
+ gradient: 'linear-gradient(135deg, #FF3B5C, #FF6B35)',
+ };
+
return (
- {/* Red glow */}
+ {/* Glow */}
@@ -134,44 +156,70 @@ export default function SubscriptionCardExpired({
{/* Header */}
- {/* Clock icon */}
-
+ {isLimited ? (
+
+ ) : (
+
+ )}
- {isDisabledDaily
- ? t('dashboard.suspended.title')
- : subscription.is_trial
- ? t('dashboard.expired.trialTitle')
- : t('dashboard.expired.title')}
+ {isLimited
+ ? t('subscription.trafficLimitedTitle')
+ : isDisabledDaily
+ ? t('dashboard.suspended.title')
+ : subscription.is_trial
+ ? t('dashboard.expired.trialTitle')
+ : t('dashboard.expired.title')}
+ {/* Limited description */}
+ {isLimited && (
+
+ {t('subscription.trafficLimitedDescription')}
+
+ )}
+
{/* Expired date + Balance row */}
@@ -207,95 +255,123 @@ export default function SubscriptionCardExpired({
{/* Action buttons */}
- {/* Quick Renew or Top Up button (hidden for expired trials) */}
- {!subscription.is_trial && (
+ {isLimited ? (
+
+
+ {t('subscription.trafficLimited')}
+
+ ) : (
<>
- {hasBalance ? (
-
+ ) : (
+
)}
- {isRenewing
- ? t('common.loading')
- : isDisabledDaily
- ? t('dashboard.suspended.resume')
- : t('dashboard.expired.quickRenew')}
-
- ) : (
-
+ >
)}
+
+ {/* Tariffs (go to purchase page) — full-width for trials */}
+
+ {t('dashboard.expired.tariffs')}
+
>
)}
-
- {/* Tariffs (go to purchase page) — full-width for trials */}
-
- {t('dashboard.expired.tariffs')}
-
);
diff --git a/src/i18n.ts b/src/i18n.ts
index 5c21382..1f803fd 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -2,25 +2,36 @@ import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
-import ru from './locales/ru.json';
-import en from './locales/en.json';
-import zh from './locales/zh.json';
-import fa from './locales/fa.json';
-
-const resources = {
- ru: { translation: ru },
- en: { translation: en },
- zh: { translation: zh },
- fa: { translation: fa },
+const localeLoaders: Record
Promise<{ default: Record }>> = {
+ ru: () => import('./locales/ru.json'),
+ en: () => import('./locales/en.json'),
+ zh: () => import('./locales/zh.json'),
+ fa: () => import('./locales/fa.json'),
};
+const SUPPORTED_LANGS = Object.keys(localeLoaders);
+const FALLBACK_LNG = 'ru';
+
+const loadedLanguages = new Set();
+
+async function loadLanguage(lng: string): Promise {
+ if (loadedLanguages.has(lng)) return;
+
+ const loader = localeLoaders[lng];
+ if (!loader) return;
+
+ const mod = await loader();
+ i18n.addResourceBundle(lng, 'translation', mod.default, true, true);
+ loadedLanguages.add(lng);
+}
+
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
- resources,
- fallbackLng: 'ru',
- supportedLngs: ['ru', 'en', 'zh', 'fa'],
+ fallbackLng: FALLBACK_LNG,
+ supportedLngs: SUPPORTED_LANGS,
+ partialBundledLanguages: true,
detection: {
order: ['localStorage', 'navigator'],
@@ -37,4 +48,15 @@ i18n
},
});
+// Load detected language + fallback on startup
+const detectedLng = i18n.language?.split('-')[0] || FALLBACK_LNG;
+const langsToLoad = [FALLBACK_LNG, ...(detectedLng !== FALLBACK_LNG ? [detectedLng] : [])];
+Promise.all(langsToLoad.map(loadLanguage));
+
+// Lazy-load on language change
+i18n.on('languageChanged', (lng: string) => {
+ const code = lng.split('-')[0];
+ loadLanguage(code);
+});
+
export default i18n;
diff --git a/src/locales/en.json b/src/locales/en.json
index 601f0a3..2462568 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -171,6 +171,7 @@
"telegramRetryFailed": "Authorization failed. Close the app and try again.",
"telegramReopenHint": "If the problem persists, close and reopen the app",
"telegramNotConfigured": "Telegram bot is not configured",
+ "telegramUnavailable": "Telegram login is temporarily unavailable",
"authenticating": "Authenticating...",
"orOpenInApp": "Or open the bot in the app",
"loginFailed": "Login Failed",
@@ -290,6 +291,9 @@
"inactive": "Inactive",
"trialStatus": "Trial",
"expired": "Expired",
+ "trafficLimited": "Traffic Exhausted",
+ "trafficLimitedTitle": "Traffic Limit Reached",
+ "trafficLimitedDescription": "Your traffic has been exhausted. Purchase additional traffic below to continue using VPN.",
"expiresAt": "Expires at",
"daysLeft": "Days left",
"devices": "Devices",
diff --git a/src/locales/fa.json b/src/locales/fa.json
index 401bef2..5e04133 100644
--- a/src/locales/fa.json
+++ b/src/locales/fa.json
@@ -163,6 +163,7 @@
"telegramRetryFailed": "تایید هویت ناموفق بود. برنامه را ببندید و دوباره باز کنید.",
"telegramReopenHint": "اگر مشکل ادامه دارد، برنامه را ببندید و دوباره باز کنید",
"telegramNotConfigured": "ربات تلگرام پیکربندی نشده",
+ "telegramUnavailable": "ورود از طریق تلگرام موقتاً در دسترس نیست",
"authenticating": "در حال تایید هویت...",
"orOpenInApp": "یا ربات را در برنامه باز کنید",
"loginFailed": "ورود ناموفق",
@@ -250,6 +251,9 @@
"inactive": "غیرفعال",
"trialStatus": "دوره آزمایشی",
"expired": "منقضی شده",
+ "trafficLimited": "ترافیک تمام شده",
+ "trafficLimitedTitle": "محدودیت ترافیک",
+ "trafficLimitedDescription": "ترافیک شما تمام شده است. برای ادامه استفاده از VPN، ترافیک اضافی خریداری کنید.",
"expiresAt": "تاریخ انقضا",
"daysLeft": "روز باقیمانده",
"devices": "دستگاهها",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index ff68633..9d7d773 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -174,6 +174,7 @@
"telegramRetryFailed": "Авторизация не удалась. Закройте приложение и откройте заново.",
"telegramReopenHint": "Если проблема повторяется — закройте и откройте приложение заново",
"telegramNotConfigured": "Telegram бот не настроен",
+ "telegramUnavailable": "Вход через Telegram временно недоступен",
"authenticating": "Авторизация...",
"orOpenInApp": "Или откройте бота в приложении",
"loginFailed": "Ошибка входа",
@@ -302,6 +303,9 @@
"inactive": "Неактивна",
"trialStatus": "Пробный период",
"expired": "Истекла",
+ "trafficLimited": "Трафик исчерпан",
+ "trafficLimitedTitle": "Трафик закончился",
+ "trafficLimitedDescription": "Ваш трафик исчерпан. Докупите дополнительный трафик ниже, чтобы продолжить пользоваться VPN.",
"expiresAt": "Действует до",
"daysLeft": "Осталось дней",
"devices": "Устройства",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 639f69f..091c92f 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -163,6 +163,7 @@
"telegramRetryFailed": "授权失败。请关闭应用后重新打开。",
"telegramReopenHint": "如果问题持续存在,请关闭并重新打开应用",
"telegramNotConfigured": "Telegram机器人未配置",
+ "telegramUnavailable": "Telegram登录暂时不可用",
"authenticating": "正在验证...",
"orOpenInApp": "或在应用中打开机器人",
"loginFailed": "登录失败",
@@ -250,6 +251,9 @@
"inactive": "无效",
"trialStatus": "试用期",
"expired": "已过期",
+ "trafficLimited": "流量已用尽",
+ "trafficLimitedTitle": "流量已达上限",
+ "trafficLimitedDescription": "您的流量已用尽。请在下方购买额外流量以继续使用VPN。",
"expiresAt": "到期时间",
"daysLeft": "剩余天数",
"devices": "设备",
diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx
index 35c14d1..202c96e 100644
--- a/src/pages/AdminUserDetail.tsx
+++ b/src/pages/AdminUserDetail.tsx
@@ -119,6 +119,7 @@ function StatusBadge({ status }: { status: string }) {
deleted: 'bg-dark-600 text-dark-400 border-dark-500',
trial: 'bg-accent-500/20 text-accent-400 border-accent-500/30',
expired: 'bg-warning-500/20 text-warning-400 border-warning-500/30',
+ limited: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30',
disabled: 'bg-dark-600 text-dark-400 border-dark-500',
};
diff --git a/src/pages/AdminUsers.tsx b/src/pages/AdminUsers.tsx
index ef01046..d869e53 100644
--- a/src/pages/AdminUsers.tsx
+++ b/src/pages/AdminUsers.tsx
@@ -147,14 +147,18 @@ function UserRow({ user, onClick, formatAmount }: UserRowProps) {
? 'border-success-500/30 bg-success-500/20 text-success-400'
: user.subscription_status === 'trial'
? 'border-accent-500/30 bg-accent-500/20 text-accent-400'
- : 'border-warning-500/30 bg-warning-500/20 text-warning-400'
+ : user.subscription_status === 'limited'
+ ? 'border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
+ : 'border-warning-500/30 bg-warning-500/20 text-warning-400'
}`}
>
{user.subscription_status === 'active'
? t('admin.users.status.subscription')
: user.subscription_status === 'trial'
? t('admin.users.status.trial')
- : t('admin.users.status.expired')}
+ : user.subscription_status === 'limited'
+ ? t('subscription.trafficLimited')
+ : t('admin.users.status.expired')}
)}
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx
index 7564778..d98767f 100644
--- a/src/pages/Dashboard.tsx
+++ b/src/pages/Dashboard.tsx
@@ -247,7 +247,9 @@ export default function Dashboard() {
- ) : subscription?.is_expired || subscription?.status === 'disabled' ? (
+ ) : subscription?.is_expired ||
+ subscription?.status === 'disabled' ||
+ subscription?.is_limited ? (
{subscription.is_active
? subscription.is_trial
? t('subscription.trialStatus')
: t('subscription.active')
- : subscription.status === 'disabled'
- ? t('subscription.pause.suspended')
- : t('subscription.expired')}
+ : subscription.is_limited
+ ? t('subscription.trafficLimited')
+ : subscription.status === 'disabled'
+ ? t('subscription.pause.suspended')
+ : t('subscription.expired')}
+ {/* ─── Traffic Limited Banner ─── */}
+ {subscription.is_limited && (
+
+
+
+
+
+ {t('subscription.trafficLimitedTitle')}
+
+
+ {t('subscription.trafficLimitedDescription')}
+
+
+
+
+ )}
+
{/* ─── Trial Info Banner ─── */}
{subscription.is_trial && subscription.is_active && (
- {subscription.status === 'disabled'
- ? t('subscription.pause.suspended')
- : subscription.is_daily_paused
- ? t('subscription.pause.paused')
- : t('subscription.pause.active')}
+ {subscription.is_limited
+ ? t('subscription.trafficLimited')
+ : subscription.status === 'disabled'
+ ? t('subscription.pause.suspended')
+ : subscription.is_daily_paused
+ ? t('subscription.pause.paused')
+ : t('subscription.pause.active')}