From e6403eb7d0973f828d61f485fe98bc1a551b03c0 Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 23 Apr 2026 03:29:00 +0300 Subject: [PATCH 1/5] fix: gift page shows only 1 tariff when tariffs have different periods The gift page filtered tariffs by selected period, hiding tariffs whose single period didn't match. When each tariff has a unique period (30d, 60d, 180d, 360d), only the first tariff was visible. - Remove period-first filtering (visibleTariffs/allPeriods) - Show all tariffs directly from config.tariffs - Auto-select the chosen tariff's period when switching tariffs - Show tariff cards when more than 1 tariff exists --- src/pages/GiftSubscription.tsx | 58 ++++++++++++---------------------- 1 file changed, 20 insertions(+), 38 deletions(-) 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) => ( Date: Thu, 23 Apr 2026 03:56:07 +0300 Subject: [PATCH 2/5] fix: limited (traffic exhausted) subscription shows as expired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dashboard card: change date label from "Истекла" to "Активна до" for limited subscriptions (traffic exhausted but time remaining) - Dashboard button: change text from "Трафик исчерпан" (dead-end) to "Докупить трафик" (actionable) for limited status - Subscription page: CountdownTimer shows remaining time for limited subs instead of "Истекла" (was passing is_active=false) - PurchaseCTAButton: don't treat limited as expired — limited subs still have time, should show extend CTA not "buy new subscription" --- src/components/dashboard/SubscriptionCardExpired.tsx | 4 ++-- src/components/subscription/PurchaseCTAButton.tsx | 4 +++- src/locales/en.json | 3 ++- src/locales/ru.json | 3 ++- src/pages/Subscription.tsx | 2 +- 5 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx index 499ab92..361e727 100644 --- a/src/components/dashboard/SubscriptionCardExpired.tsx +++ b/src/components/dashboard/SubscriptionCardExpired.tsx @@ -228,7 +228,7 @@ export default function SubscriptionCardExpired({ >
- {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/locales/en.json b/src/locales/en.json index 9ccd9d8..4038228 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -272,7 +272,8 @@ "tariffs": "Tariffs", "traffic": "Traffic", "devices": "Devices", - "expiredDate": "Expired" + "expiredDate": "Expired", + "activeUntil": "Active until" }, "suspended": { "title": "Subscription Suspended", diff --git a/src/locales/ru.json b/src/locales/ru.json index 00f19e4..21dd916 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -284,7 +284,8 @@ "tariffs": "Тарифы", "traffic": "Трафик", "devices": "Устройства", - "expiredDate": "Истекла" + "expiredDate": "Истекла", + "activeUntil": "Активна до" }, "suspended": { "title": "Подписка приостановлена", diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 2be268d..5e2c5f3 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -957,7 +957,7 @@ export default function Subscription() {
From 1d5ce2d4eac02d51604ae853b776c9c393732f02 Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 23 Apr 2026 04:04:11 +0300 Subject: [PATCH 3/5] fix: complete is_limited status handling across all views - SubscriptionListCard: show traffic progress bar for limited subs in multi-tariff list (was hidden because isActive excluded limited) - SubscriptionListCard: amber border/background for limited status cards (was using default neutral style, inconsistent with rest of UI) - Subscription page: hide delete button for limited subs in multi-tariff mode (limited subs still have valid days remaining) - SubscriptionPurchase: allow tariff switch for limited subs (was blocked by is_active-only gate in canSwitch formula) - AdminUserDetail: include limited status in "already purchased" tariff filter to prevent duplicate subscription creation --- .../subscription/SubscriptionListCard.tsx | 35 ++-- src/pages/AdminUserDetail.tsx | 5 +- src/pages/Subscription.tsx | 166 +++++++++--------- src/pages/SubscriptionPurchase.tsx | 2 +- 4 files changed, 111 insertions(+), 97 deletions(-) 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 ( - ) : ( -
-
- {t('subscription.deleteTitle', 'Удалить подписку?')} -
-
- {t( - 'subscription.deleteWarning', - 'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны. Это действие нельзя отменить.', - )} -
-
- - + + + {t('subscription.delete', 'Удалить подписку')} + + ) : ( +
+
+ {t('subscription.deleteTitle', 'Удалить подписку?')} +
+
+ {t( + 'subscription.deleteWarning', + 'Подписка будет удалена безвозвратно. Все данные, устройства и настройки будут потеряны. Это действие нельзя отменить.', + )} +
+
+ + +
-
- )} -
- )} + )} +
+ )} {/* 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; From 2d6815a88e49b4a2f19a00d1e1003780ee93dad7 Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 23 Apr 2026 04:55:36 +0300 Subject: [PATCH 4/5] fix: landing page currency symbol not changing with locale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit formatPrice() was hardcoded to ru-RU locale and RUB currency. Now it reads the current language from i18next and maps it to the appropriate currency (ru→RUB, en→USD, zh→CNY, fa→IRR). All consumers (QuickPurchase landing, GiftSubscription, admin) automatically get locale-aware currency formatting without any component-level changes. --- src/utils/format.ts | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) 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}`; + } } From 33f32648f0c070a26ad5c63532465448fa4c8f5c Mon Sep 17 00:00:00 2001 From: Fringg Date: Thu, 23 Apr 2026 05:00:36 +0300 Subject: [PATCH 5/5] =?UTF-8?q?feat:=20improve=20squad=20management=20UX?= =?UTF-8?q?=20=E2=80=94=20rename,=20swap=20icons,=20color=20gift=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Rename "Управление серверами" → "Управление сквадами" across all 4 locales (ru, en, zh, fa) — title, subtitle, nav, subscription options. The page manages RemnaWave squads, not individual servers. 2. Swap toggle button semantics — the button now shows the ACTION that will happen on click, not the current state: - Active squad: red X icon (click to deactivate) - Inactive squad: green checkmark (click to activate) 3. Gift/trial button: amber/orange when functional (clearly clickable), gray when disabled. Previously always gray, making it look inactive even when it could be clicked. --- src/locales/en.json | 10 +++++----- src/locales/fa.json | 10 +++++----- src/locales/ru.json | 10 +++++----- src/locales/zh.json | 6 +++--- src/pages/AdminServers.tsx | 8 ++++---- 5 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 4038228..9fb58e4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -555,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", @@ -1100,7 +1100,7 @@ "apps": "Apps", "wheel": "Wheel", "tariffs": "Tariffs", - "servers": "Servers", + "servers": "Squads", "banSystem": "Ban Monitoring", "broadcasts": "Broadcasts", "users": "Users", @@ -2351,8 +2351,8 @@ } }, "servers": { - "title": "Server Management", - "subtitle": "Configure VPN servers", + "title": "Squad Management", + "subtitle": "Configure squads", "sync": "Sync", "syncing": "Syncing...", "syncNow": "Sync now", diff --git a/src/locales/fa.json b/src/locales/fa.json index d3ea298..fbe1cde 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -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": "همگام‌سازی الان", diff --git a/src/locales/ru.json b/src/locales/ru.json index 21dd916..2c69a25 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -583,8 +583,8 @@ "unlimited": "Безлимит", "buyTrafficGb": "Купить {{gb}} ГБ", "buyUnlimited": "Купить Безлимит", - "manageServers": "Управление серверами", - "manageServersTitle": "Управление серверами" + "manageServers": "Управление сквадами", + "manageServersTitle": "Управление сквадами" }, "serverManagement": { "statusLegend": "✅ — подключено • ➕ — будет добавлено (платно) • ➖ — будет отключено", @@ -1121,7 +1121,7 @@ "apps": "Приложения", "wheel": "Колесо", "tariffs": "Тарифы", - "servers": "Серверы", + "servers": "Сквады", "banSystem": "Мониторинг банов", "broadcasts": "Рассылки", "users": "Пользователи", @@ -2857,8 +2857,8 @@ } }, "servers": { - "title": "Управление серверами", - "subtitle": "Настройка серверов VPN", + "title": "Управление сквадами", + "subtitle": "Настройка сквадов", "sync": "Синхронизировать", "syncing": "Синхронизация...", "syncNow": "Синхронизировать сейчас", diff --git a/src/locales/zh.json b/src/locales/zh.json index 9ff3064..5d775b2 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -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": "支付方式", diff --git a/src/pages/AdminServers.tsx b/src/pages/AdminServers.tsx index be2ba1f..f04bead 100644 --- a/src/pages/AdminServers.tsx +++ b/src/pages/AdminServers.tsx @@ -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 ? : } + {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')}