From 3c2f650c28a7a47b12dcc19197cf1a6dd46f2b03 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Fri, 29 May 2026 11:22:49 +0300 Subject: [PATCH 01/37] fix(navigation): break BackButton loop on single-tariff /subscriptions/:id deep-link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User flow that hung: 1. Bot deep-link opens MiniApp directly on /subscriptions/:id (idx=0). 2. Telegram shows BackButton. 3. Tap → TelegramBackButton handler sees no in-app history and falls back to getFallbackParentPath('/subscriptions/:id') = '/subscriptions'. 4. Subscriptions page renders, sees single-tariff + 1 subscription, redirects right back to /subscriptions/:id. 5. BackButton looks dead — taps do nothing visible. Fix: only redirect when there is in-app history. When idx=0 (deep-link or just-arrived-via-back), render the list page instead. The user then sees their single subscription as a card and can tap into it, and the next BackButton tap exits the MiniApp as expected. Reported by Серёжа in bugs topic #599679. --- src/pages/Subscriptions.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/pages/Subscriptions.tsx b/src/pages/Subscriptions.tsx index 9fb032f..0b9a0bd 100644 --- a/src/pages/Subscriptions.tsx +++ b/src/pages/Subscriptions.tsx @@ -9,6 +9,7 @@ import { getGlassColors } from '../utils/glassTheme'; import { useAuthStore } from '../store/auth'; import SubscriptionListCard from '../components/subscription/SubscriptionListCard'; import TrialOfferCard from '../components/dashboard/TrialOfferCard'; +import { hasInAppHistory } from '../utils/navigation'; function EmptyState({ onBuy }: { onBuy: () => void }) { const { t } = useTranslation(); @@ -106,8 +107,11 @@ export default function Subscriptions() { }, }); - // Single-tariff mode with one subscription: skip list, go directly to detail - if (data && !isMultiTariff && subscriptions.length === 1) { + // Single-tariff mode with one subscription: skip list, go directly to detail. + // Skip the redirect when the user just hit the Telegram BackButton from + // /subscriptions/:id — that lands us here with idx=0, and redirecting back + // to the detail page creates a Back-button loop the user can't escape (#599679). + if (data && !isMultiTariff && subscriptions.length === 1 && hasInAppHistory()) { return ; } From 0ed8bb1d48918d7a441043b035c8cd0721f6c515 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Fri, 29 May 2026 11:36:22 +0300 Subject: [PATCH 02/37] fix(navigation): single-tariff /subscriptions/:id deep-link shows Close, not Back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refines 3c2f650 — that commit broke the loop by suppressing the single-tariff redirect on idx=0, but left the BackButton visible. The user still saw Back on the detail page and needed two taps (Back → Close) to exit. Серёжа's report was specifically that Back should not appear at all when you land on your only subscription via a bot button. Move the decision up to TelegramBackButton: - Subscribe to the existing ['subscriptions-list'] query (deduped by React Query, so no extra fetch). - On /subscriptions/:id, when multi_tariff_enabled is false and there is no in-app history (idx=0 deep-link entry), hide the BackButton. Telegram then surfaces its native Close (X) in the header. - Multi-tariff users still see BackButton on /subscriptions/:id deep-links because their /subscriptions list is meaningful (they have many subs). The 2bcba3b fallback-to-parent behaviour remains intact for unrelated deep-links like /balance/top-up, /admin/*, etc. Revert the defensive guard in Subscriptions.tsx — the loop can no longer be triggered because no BackButton is shown to trigger it. Behaviour summary: flow BackButton exit cost -------------------------------------- ---------- --------- deep-link → /subscriptions/:id (1T) hidden Close, 1 tap / → /subscriptions → /subscriptions/:id shown Back → /, 1 tap deep-link → /balance/top-up shown Back → /balance ✓ deep-link → /subscriptions/:id (multi) shown Back → /subscriptions list Reported by Серёжа, bugs topic #599679. --- src/AppWithNavigator.tsx | 27 +++++++++++++++++++++++++-- src/pages/Subscriptions.tsx | 8 ++------ 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/AppWithNavigator.tsx b/src/AppWithNavigator.tsx index f0f20cc..d43e4b8 100644 --- a/src/AppWithNavigator.tsx +++ b/src/AppWithNavigator.tsx @@ -6,6 +6,7 @@ import { onBackButtonClick, offBackButtonClick, } from '@telegram-apps/sdk-react'; +import { useQuery } from '@tanstack/react-query'; import Twemoji from 'react-twemoji'; import App from './App'; import { ErrorBoundary } from './components/ErrorBoundary'; @@ -16,6 +17,7 @@ import { ToastProvider } from './components/Toast'; import { TooltipProvider } from './components/primitives/Tooltip'; import { isInTelegramWebApp } from './hooks/useTelegramSDK'; import { hasInAppHistory, getFallbackParentPath } from './utils/navigation'; +import { subscriptionApi } from './api/subscription'; const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as const; @@ -26,6 +28,13 @@ const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as /** Pages reachable from bottom nav — treat as top-level (no back button). */ const BOTTOM_NAV_PATHS = ['/', '/subscriptions', '/balance', '/referral', '/support', '/wheel']; +/** Matches /subscriptions/:numericId — single-tariff users land here from + * bot deep-links, but their /subscriptions list is empty (the list view + * auto-redirects them straight back to this page). Pressing Back would loop + * back to detail, so on a deep-link entry (idx=0) we hide the back button + * and let Telegram surface its native Close (X) button instead. */ +const SUBSCRIPTION_DETAIL_RE = /^\/subscriptions\/\d+\/?$/; + function TelegramBackButton() { const location = useLocation(); const navigate = useNavigate(); @@ -34,16 +43,30 @@ function TelegramBackButton() { const pathnameRef = useRef(location.pathname); pathnameRef.current = location.pathname; + // Share the subscriptions-list query with the page-level components. + // React Query dedupes by key so this does not cause an extra fetch when + // Subscriptions/Subscription/Dashboard pages mount. + const { data: subData } = useQuery({ + queryKey: ['subscriptions-list'], + queryFn: () => subscriptionApi.getSubscriptions(), + staleTime: 30_000, + // Don't fetch outside Telegram — the cabinet still loads on the web. + enabled: isInTelegramWebApp(), + }); + const isMultiTariff = subData?.multi_tariff_enabled ?? false; + useEffect(() => { const isTopLevel = location.pathname === '' || BOTTOM_NAV_PATHS.includes(location.pathname); + const isSingleTariffDetailDeepLink = + !isMultiTariff && SUBSCRIPTION_DETAIL_RE.test(location.pathname) && !hasInAppHistory(); try { - if (isTopLevel) { + if (isTopLevel || isSingleTariffDetailDeepLink) { hideBackButton(); } else { showBackButton(); } } catch {} - }, [location]); + }, [location, isMultiTariff]); // Stable handler — ref prevents re-subscription on every render const handler = useCallback(() => { diff --git a/src/pages/Subscriptions.tsx b/src/pages/Subscriptions.tsx index 0b9a0bd..9fb032f 100644 --- a/src/pages/Subscriptions.tsx +++ b/src/pages/Subscriptions.tsx @@ -9,7 +9,6 @@ import { getGlassColors } from '../utils/glassTheme'; import { useAuthStore } from '../store/auth'; import SubscriptionListCard from '../components/subscription/SubscriptionListCard'; import TrialOfferCard from '../components/dashboard/TrialOfferCard'; -import { hasInAppHistory } from '../utils/navigation'; function EmptyState({ onBuy }: { onBuy: () => void }) { const { t } = useTranslation(); @@ -107,11 +106,8 @@ export default function Subscriptions() { }, }); - // Single-tariff mode with one subscription: skip list, go directly to detail. - // Skip the redirect when the user just hit the Telegram BackButton from - // /subscriptions/:id — that lands us here with idx=0, and redirecting back - // to the detail page creates a Back-button loop the user can't escape (#599679). - if (data && !isMultiTariff && subscriptions.length === 1 && hasInAppHistory()) { + // Single-tariff mode with one subscription: skip list, go directly to detail + if (data && !isMultiTariff && subscriptions.length === 1) { return ; } From c24ae0039cc554176aef2085606748d8e21ec3e5 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Sun, 31 May 2026 15:52:07 +0300 Subject: [PATCH 03/37] =?UTF-8?q?fix(broadcasts):=20=D0=BF=D0=BE=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=C2=AB=D0=9F=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=BD=D1=91=D1=80=D0=BA=D0=B0=C2=BB=20=D0=B2=D0=BC?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D0=BE=20=D1=81=D1=8B=D1=80=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=20referrals=20=D0=B2=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D0=B2=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В превью конструктора рассылки кнопка реферала отображалась сырым ключом «referrals»: presetLabels имел ключ «partners», а бот отдаёт «referrals» (каталог BROADCAST_BUTTONS), и presetLabels[id] || id фолбэчил на сам id. Ключ переименован partners → referrals (метка «Партнёрка» сохранена). Реальная отправка не затронута — бот строит клавиатуру из своего каталога. Telegram-баг #602989. --- src/pages/AdminBroadcastCreate.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/pages/AdminBroadcastCreate.tsx b/src/pages/AdminBroadcastCreate.tsx index c175cee..583a385 100644 --- a/src/pages/AdminBroadcastCreate.tsx +++ b/src/pages/AdminBroadcastCreate.tsx @@ -173,7 +173,10 @@ export default function AdminBroadcastCreate() { if (selectedButtons.length > 0) { const presetLabels: Record = { balance: t('admin.broadcasts.btnBalance', 'Пополнить баланс'), - partners: t('admin.broadcasts.btnPartners', 'Партнёрка'), + // Бот отдаёт ключ кнопки как 'referrals' (см. BROADCAST_BUTTONS в admin.py), + // раньше тут был 'partners' — из-за рассинхрона кнопка показывалась сырым + // ключом 'referrals' вместо «Партнёрка» (Telegram-баг #602989). + referrals: t('admin.broadcasts.btnPartners', 'Партнёрка'), promocode: t('admin.broadcasts.btnPromocode', 'Промокод'), connect: t('admin.broadcasts.btnConnect', 'Подключиться'), subscription: t('admin.broadcasts.btnSubscription', 'Подписка'), From 2d5982d82bf8c47f13e07b230a800653d1eeed60 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Sun, 31 May 2026 15:52:08 +0300 Subject: [PATCH 04/37] =?UTF-8?q?fix(subscriptions):=20=D0=B4=D0=B0=D1=82?= =?UTF-8?q?=D1=8C=20=D0=BD=D0=BE=D0=B2=D1=8B=D0=BC=20=D1=8E=D0=B7=D0=B5?= =?UTF-8?q?=D1=80=D0=B0=D0=BC=20=D0=BF=D1=83=D1=82=D1=8C=20=D0=B2=20=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=80=D0=B8=D0=BD=D1=83=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=B0=D1=86=D0=B8=D0=B8=20=D1=82?= =?UTF-8?q?=D1=80=D0=B8=D0=B0=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Новый пользователь с доступным триалом видел только TrialOfferCard (CTA активации), без пути в витрину тарифов — приходилось сперва активировать пробную. Добавлена вторичная кнопка «Посмотреть тарифы и купить подписку» → /subscription/purchase под карточкой триала на обеих страницах, где она рендерится: Subscriptions и Dashboard (вход по умолчанию). Telegram-баг #605056/#605063. --- src/pages/Dashboard.tsx | 26 +++++++++++++++++++------- src/pages/Subscriptions.tsx | 27 ++++++++++++++++++++------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 1ba9ff6..5047d2e 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -360,13 +360,25 @@ export default function Dashboard() { {/* Trial Activation */} {hasNoSubscription && !trialLoading && trialInfo?.is_available && ( - +
+ + {/* Новый пользователь не обязан активировать триал, чтобы попасть в + витрину — даём явный путь к покупке подписки. Раньше при доступном + триале это был единственный экран без кнопки покупки, и на дашборде + (вход по умолчанию) юзер оставался заперт (Telegram-баг #605056/#605063). */} + + {t('subscriptions.browsePlans', 'Посмотреть тарифы и купить подписку')} + +
)} {/* Promo Offers */} diff --git a/src/pages/Subscriptions.tsx b/src/pages/Subscriptions.tsx index 9fb032f..1f27cd9 100644 --- a/src/pages/Subscriptions.tsx +++ b/src/pages/Subscriptions.tsx @@ -157,13 +157,26 @@ export default function Subscriptions() { {/* Empty state: показываем триал, если доступен; иначе — обычный empty */} {hasNoSubscriptions && !trialLoading && trialInfo?.is_available && ( - +
+ + {/* Новый пользователь не обязан активировать триал, чтобы попасть + в витрину — даём явный путь к покупке подписки. Раньше при + доступном триале это был единственный экран без кнопки «Купить» + (Telegram-баг #605056/#605063). */} + +
)} {hasNoSubscriptions && !trialLoading && !trialInfo?.is_available && ( navigate('/subscription/purchase')} /> From a62d689fd39bea727b1bc9b13421775ca3a9c9a8 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Sun, 31 May 2026 15:52:09 +0300 Subject: [PATCH 05/37] =?UTF-8?q?fix(navigation):=20=D0=BD=D0=B0=D0=B4?= =?UTF-8?q?=D1=91=D0=B6=D0=BD=D0=B0=D1=8F=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA?= =?UTF-8?q?=D0=B0=20=C2=AB=D0=9D=D0=B0=D0=B7=D0=B0=D0=B4=C2=BB=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20=D1=82=D0=B8=D0=BF=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8=D0=B8,=20=D0=B1=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=BF=D0=B5=D1=82=D0=BB=D0=B8=20(#436)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit На iOS Telegram кнопка «Назад» на /subscriptions/:id казалась мёртвой: fallback вёл на /subscriptions, который авто-редиректит обратно на деталь (single-tariff, 1 подписка) — молчаливая петля. Причина — ненадёжный window.history.state.idx, который мутируют собственные redirect'ы. idx заменён на надёжный счётчик глубины по useNavigationType (PUSH +1, POP −1, REPLACE без изменений, дедуп по location.key). Кнопка корректно показывается/прячется, navigate(-1) берётся только при реальной истории. Плюс fail-closed fallback: с детали уходим в '/' если список не доказанно безопасен (multi-tariff или >1 подписки) — петля невозможна. GitHub-issue #436. --- src/AppWithNavigator.tsx | 70 ++++++++++++++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 14 deletions(-) diff --git a/src/AppWithNavigator.tsx b/src/AppWithNavigator.tsx index d43e4b8..ba3a78b 100644 --- a/src/AppWithNavigator.tsx +++ b/src/AppWithNavigator.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useCallback } from 'react'; -import { BrowserRouter, useLocation, useNavigate } from 'react-router'; +import { BrowserRouter, useLocation, useNavigate, useNavigationType } from 'react-router'; import { showBackButton, hideBackButton, @@ -16,7 +16,7 @@ import { WebSocketProvider } from './providers/WebSocketProvider'; import { ToastProvider } from './components/Toast'; import { TooltipProvider } from './components/primitives/Tooltip'; import { isInTelegramWebApp } from './hooks/useTelegramSDK'; -import { hasInAppHistory, getFallbackParentPath } from './utils/navigation'; +import { getFallbackParentPath } from './utils/navigation'; import { subscriptionApi } from './api/subscription'; const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as const; @@ -28,21 +28,38 @@ const TWEMOJI_OPTIONS = { className: 'twemoji', folder: 'svg', ext: '.svg' } as /** Pages reachable from bottom nav — treat as top-level (no back button). */ const BOTTOM_NAV_PATHS = ['/', '/subscriptions', '/balance', '/referral', '/support', '/wheel']; -/** Matches /subscriptions/:numericId — single-tariff users land here from - * bot deep-links, but their /subscriptions list is empty (the list view - * auto-redirects them straight back to this page). Pressing Back would loop - * back to detail, so on a deep-link entry (idx=0) we hide the back button - * and let Telegram surface its native Close (X) button instead. */ +/** Matches /subscriptions/:numericId. Single-tariff users land here straight + * from bot deep-links, and their /subscriptions list auto-redirects right back + * to this page (Subscriptions.tsx). So on a genuine deep-link entry (in-app + * navigation depth 0) we hide the back button and let Telegram surface its + * native Close (X); when there IS in-app history we show it and navigate back. */ const SUBSCRIPTION_DETAIL_RE = /^\/subscriptions\/\d+\/?$/; function TelegramBackButton() { const location = useLocation(); const navigate = useNavigate(); + const navType = useNavigationType(); const navigateRef = useRef(navigate); navigateRef.current = navigate; const pathnameRef = useRef(location.pathname); pathnameRef.current = location.pathname; + // Reliable in-app navigation depth (the app's entry point is 0). Driven by + // React Router's navigation TYPE — NOT window.history.state.idx, which the + // app's own redirects mutate unpredictably and which is the root flake behind + // issue #436 (the back button shows/acts on the wrong state). PUSH goes + // deeper, POP unwinds, REPLACE (e.g. the Subscriptions.tsx auto-redirect) is + // flat. De-duped by location.key so StrictMode's double-effect can't miscount. + const depthRef = useRef(0); + const lastKeyRef = useRef(null); + useEffect(() => { + if (lastKeyRef.current === location.key) return; + lastKeyRef.current = location.key; + if (navType === 'PUSH') depthRef.current += 1; + else if (navType === 'POP') depthRef.current = Math.max(0, depthRef.current - 1); + // REPLACE: depth unchanged (replaces the current entry, adds no history) + }, [location.key, navType]); + // Share the subscriptions-list query with the page-level components. // React Query dedupes by key so this does not cause an extra fetch when // Subscriptions/Subscription/Dashboard pages mount. @@ -55,10 +72,18 @@ function TelegramBackButton() { }); const isMultiTariff = subData?.multi_tariff_enabled ?? false; + // Refs so the stable back handler (memoised with []) reads fresh values + // without re-subscribing — re-subscription lets a component's local handler + // overwrite ours via Telegram's singleton onBackButtonClick (issue #436). + const isMultiTariffRef = useRef(isMultiTariff); + isMultiTariffRef.current = isMultiTariff; + const subsCountRef = useRef(subData?.subscriptions?.length ?? 0); + subsCountRef.current = subData?.subscriptions?.length ?? 0; + useEffect(() => { const isTopLevel = location.pathname === '' || BOTTOM_NAV_PATHS.includes(location.pathname); const isSingleTariffDetailDeepLink = - !isMultiTariff && SUBSCRIPTION_DETAIL_RE.test(location.pathname) && !hasInAppHistory(); + !isMultiTariff && SUBSCRIPTION_DETAIL_RE.test(location.pathname) && depthRef.current === 0; try { if (isTopLevel || isSingleTariffDetailDeepLink) { hideBackButton(); @@ -70,14 +95,31 @@ function TelegramBackButton() { // Stable handler — ref prevents re-subscription on every render const handler = useCallback(() => { - // When opened via a bot deep-link directly on a nested route, there is no - // in-app history and navigate(-1) is a no-op — the back button looks dead. - // Fall back to the parent route so it always navigates somewhere sensible. - if (hasInAppHistory()) { + // Real in-app history (depth > 0): a normal back. Otherwise we were opened + // directly on this route via a deep-link — navigate(-1) is a no-op, so fall + // back to a sensible parent route instead. + if (depthRef.current > 0) { navigateRef.current(-1); - } else { - navigateRef.current(getFallbackParentPath(pathnameRef.current), { replace: true }); + return; } + // /subscriptions/:id is special: the /subscriptions list auto-redirects + // straight back to a detail page when single-tariff with exactly one + // subscription (Subscriptions.tsx), so falling back there loops silently and + // the back button looks dead (issue #436). Land on the list ONLY when it is + // PROVABLY safe (multi-tariff, or more than one subscription — neither of + // which auto-redirects); otherwise escape to root. + // + // Fail-closed on purpose: `subsCount <= 1` is treated as not-safe, which + // also covers the stale default 0 before the shared subscriptions query + // resolves — so a fast tap on a cold cache can never route into the + // redirecting list and re-open the loop. + const pathname = pathnameRef.current; + const listIsSafe = isMultiTariffRef.current || subsCountRef.current > 1; + const fallback = + SUBSCRIPTION_DETAIL_RE.test(pathname) && !listIsSafe + ? '/' + : getFallbackParentPath(pathnameRef.current); + navigateRef.current(fallback, { replace: true }); }, []); useEffect(() => { From d0e0b6b7e3fd29a533636b397ed5021a75074615 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Sun, 31 May 2026 18:50:50 +0300 Subject: [PATCH 06/37] feat(cabinet): migrate all icons to the panel's Phosphor set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the cabinet's hand-written heroicons-style SVG icon components with the Remnawave panel's own icon family — Phosphor via react-icons/pi (Duotone). All icons now live in a single central barrel (src/components/icons: index + extended-icons + editor-icons, 144 icons); every feature file imports from it instead of redefining inline SVGs (~440 icon defs removed). - Add react-icons dependency - Desktop nav (AppShell) now uses the central Phosphor icons, removing a stroke-vs-duotone inconsistency the partial migration introduced - Icons with custom props (SortIcon's direction, expandable chevrons) kept as thin Phosphor wrappers; the RemnawaveIcon brand logo and animated SVGs (checkmarks, spinners) are intentionally left as-is - Restore the original per-call-site icon sizes wherever the centralized default differed (211 call sites) --- package-lock.json | 10 + package.json | 1 + src/components/PromoOffersSection.tsx | 15 +- src/components/SuccessNotificationModal.tsx | 93 +-- src/components/TicketNotificationBell.tsx | 17 +- src/components/admin/MenuEditorTab.tsx | 101 +-- .../admin/SortableSelectedMethodCard.tsx | 11 +- .../admin/bulkActions/ActionModal.tsx | 23 +- .../admin/bulkActions/DropdownSelect.tsx | 12 +- .../admin/trafficUsage/TrafficIcons.tsx | 189 +---- .../trafficUsage/filters/CountryFilter.tsx | 4 +- .../admin/trafficUsage/filters/NodeFilter.tsx | 4 +- .../trafficUsage/filters/PeriodSelector.tsx | 6 +- .../trafficUsage/filters/StatusFilter.tsx | 4 +- .../trafficUsage/filters/TariffFilter.tsx | 4 +- .../admin/userDetail/BalanceTab.tsx | 21 +- .../admin/userDetail/SubscriptionTab.tsx | 27 +- src/components/admin/userDetail/SyncTab.tsx | 17 +- .../connection/InstallationGuide.tsx | 9 +- .../connection/blocks/BlockButtons.tsx | 17 +- src/components/dashboard/StatsGrid.tsx | 18 +- .../dashboard/SubscriptionCardActive.tsx | 18 +- src/components/icons/editor-icons.tsx | 85 +++ src/components/icons/extended-icons.tsx | 415 +++++++++++ src/components/icons/index.tsx | 694 ++++-------------- src/components/layout/AppShell/AppShell.tsx | 176 +---- src/components/news/NewsSection.tsx | 14 +- src/components/partner/CampaignCard.tsx | 28 +- src/components/primitives/Command/Command.tsx | 14 +- src/components/primitives/Dialog/Dialog.tsx | 14 +- .../primitives/DropdownMenu/DropdownMenu.tsx | 32 +- src/components/primitives/Popover/Popover.tsx | 14 +- src/components/primitives/Select/Select.tsx | 26 +- src/components/primitives/Sheet/Sheet.tsx | 14 +- src/pages/AdminAuditLog.tsx | 80 +- src/pages/AdminBanSystem.tsx | 162 +--- src/pages/AdminBroadcastCreate.tsx | 111 +-- src/pages/AdminBroadcastDetail.tsx | 79 +- src/pages/AdminBroadcasts.tsx | 81 +- src/pages/AdminBulkActions.tsx | 89 +-- src/pages/AdminCampaignCreate.tsx | 31 +- src/pages/AdminCampaignStats.tsx | 48 +- src/pages/AdminCampaigns.tsx | 23 +- src/pages/AdminChannelSubscriptions.tsx | 99 +-- src/pages/AdminDashboard.tsx | 171 +---- src/pages/AdminEmailTemplates.tsx | 60 +- src/pages/AdminInfoPageEditor.tsx | 182 ++--- src/pages/AdminInfoPages.tsx | 87 +-- src/pages/AdminLandingEditor.tsx | 11 +- src/pages/AdminLandingStats.tsx | 96 +-- src/pages/AdminLandings.tsx | 78 +- src/pages/AdminNews.tsx | 113 +-- src/pages/AdminNewsCreate.tsx | 150 +--- src/pages/AdminPanel.tsx | 446 +++-------- src/pages/AdminPartnerSettings.tsx | 14 +- src/pages/AdminPaymentMethodEdit.tsx | 30 +- src/pages/AdminPaymentMethods.tsx | 40 +- src/pages/AdminPayments.tsx | 38 +- src/pages/AdminPinnedMessageCreate.tsx | 59 +- src/pages/AdminPinnedMessages.tsx | 151 +--- src/pages/AdminPolicies.tsx | 109 +-- src/pages/AdminPromoGroupCreate.tsx | 38 +- src/pages/AdminPromoGroups.tsx | 52 +- src/pages/AdminPromoOfferSend.tsx | 65 +- src/pages/AdminPromoOffers.tsx | 58 +- src/pages/AdminPromocodeCreate.tsx | 32 +- src/pages/AdminPromocodeStats.tsx | 36 +- src/pages/AdminPromocodes.tsx | 77 +- src/pages/AdminRoleAssign.tsx | 81 +- src/pages/AdminRoleEdit.tsx | 15 +- src/pages/AdminRoles.tsx | 51 +- src/pages/AdminServers.tsx | 23 +- src/pages/AdminSettings.tsx | 29 +- src/pages/AdminTariffCreate.tsx | 92 +-- src/pages/AdminTariffs.tsx | 96 +-- src/pages/AdminTicketSettings.tsx | 14 +- src/pages/AdminTickets.tsx | 14 +- src/pages/AdminTrafficUsage.tsx | 8 +- src/pages/AdminUpdates.tsx | 102 +-- src/pages/AdminUserDetail.tsx | 18 +- src/pages/AdminUsers.tsx | 58 +- src/pages/AdminWheel.tsx | 164 +---- src/pages/Balance.tsx | 18 +- src/pages/Contests.tsx | 25 +- src/pages/Dashboard.tsx | 7 +- src/pages/Info.tsx | 64 +- src/pages/InfoPageView.tsx | 43 +- src/pages/NewsArticle.tsx | 38 +- src/pages/Polls.tsx | 37 +- src/pages/Profile.tsx | 45 +- src/pages/Referral.tsx | 80 +- src/pages/Support.tsx | 39 +- src/pages/TopUpAmount.tsx | 74 +- src/pages/Wheel.tsx | 49 +- 94 files changed, 1368 insertions(+), 5059 deletions(-) create mode 100644 src/components/icons/editor-icons.tsx create mode 100644 src/components/icons/extended-icons.tsx diff --git a/package-lock.json b/package-lock.json index bb32dd9..867b7de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-i18next": "^16.5.4", + "react-icons": "^5.6.0", "react-router": "^7.13.0", "react-twemoji": "^0.7.2", "recharts": "^3.7.0", @@ -6908,6 +6909,15 @@ } } }, + "node_modules/react-icons": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", diff --git a/package.json b/package.json index e91c2f8..c3c0f2a 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-i18next": "^16.5.4", + "react-icons": "^5.6.0", "react-router": "^7.13.0", "react-twemoji": "^0.7.2", "recharts": "^3.7.0", diff --git a/src/components/PromoOffersSection.tsx b/src/components/PromoOffersSection.tsx index 6f7558f..00f22df 100644 --- a/src/components/PromoOffersSection.tsx +++ b/src/components/PromoOffersSection.tsx @@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router'; import { promoApi, PromoOffer } from '../api/promo'; -import { ClockIcon, CheckIcon } from './icons'; +import { ClockIcon, CheckIcon, XCircleIcon } from './icons'; import { useDestructiveConfirm } from '@/platform/hooks/useNativeDialog'; // Helper functions @@ -69,17 +69,6 @@ const getOfferDescription = ( return t('promo.offers.activateDiscountHint'); }; -// Icons for deactivation -const XCircleIcon = () => ( - - - -); - interface PromoOffersSectionProps { className?: string; } @@ -237,7 +226,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio onClick={handleDeactivateClick} className="flex items-center justify-center gap-1.5 rounded-xl border border-dark-600/50 bg-dark-900/50 px-4 py-2.5 text-sm text-dark-400 transition-colors hover:border-error-500/30 hover:bg-error-500/10 hover:text-error-400" > - + {t('promo.deactivate.button')} diff --git a/src/components/SuccessNotificationModal.tsx b/src/components/SuccessNotificationModal.tsx index 72e78f2..4d656de 100644 --- a/src/components/SuccessNotificationModal.tsx +++ b/src/components/SuccessNotificationModal.tsx @@ -12,69 +12,14 @@ import { useCurrency } from '../hooks/useCurrency'; import { useTelegramSDK } from '../hooks/useTelegramSDK'; import { useFocusTrap } from '../hooks/useFocusTrap'; import { useHaptic } from '@/platform'; - -// Icons -const CheckCircleIcon = () => ( - - - -); - -const WalletIcon = () => ( - - - -); - -const RocketIcon = () => ( - - - -); - -const DevicesIcon = () => ( - - - -); - -const TrafficIcon = () => ( - - - -); - -const CloseIcon = () => ( - - - -); +import { + CheckCircleIcon, + CloseIcon, + DevicesIcon, + RocketIcon, + TrafficIcon, + WalletIcon, +} from '@/components/icons'; export default function SuccessNotificationModal() { const { t } = useTranslation(); @@ -161,33 +106,33 @@ export default function SuccessNotificationModal() { // Determine title and message let title = data.title; const message = data.message; - let icon = ; + let icon = ; let gradientClass = 'from-success-500 to-success-600'; if (!title) { if (isBalanceTopup) { title = t('successNotification.balanceTopup.title', 'Balance topped up!'); - icon = ; + icon = ; gradientClass = 'from-success-500 to-success-600'; } else if (data.type === 'subscription_activated') { title = t('successNotification.subscriptionActivated.title', 'Subscription activated!'); - icon = ; + icon = ; gradientClass = 'from-accent-500 to-purple-600'; } else if (data.type === 'subscription_renewed') { title = t('successNotification.subscriptionRenewed.title', 'Subscription renewed!'); - icon = ; + icon = ; gradientClass = 'from-accent-500 to-purple-600'; } else if (data.type === 'subscription_purchased') { title = t('successNotification.subscriptionPurchased.title', 'Subscription purchased!'); - icon = ; + icon = ; gradientClass = 'from-accent-500 to-purple-600'; } else if (data.type === 'devices_purchased') { title = t('successNotification.devicesPurchased.title', 'Devices added!'); - icon = ; + icon = ; gradientClass = 'from-blue-500 to-cyan-600'; } else if (data.type === 'traffic_purchased') { title = t('successNotification.trafficPurchased.title', 'Traffic added!'); - icon = ; + icon = ; gradientClass = 'from-success-500 to-success-600'; } } @@ -334,7 +279,7 @@ export default function SuccessNotificationModal() { onClick={handleGoToSubscription} className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 py-3.5 font-bold text-white shadow-lg shadow-accent-500/25 transition-colors hover:bg-accent-400 active:bg-accent-600" > - + {t('successNotification.goToSubscription', 'Go to Subscription')} )} @@ -344,7 +289,7 @@ export default function SuccessNotificationModal() { onClick={handleGoToBalance} className="flex w-full items-center justify-center gap-2 rounded-xl bg-success-500 py-3.5 font-bold text-white shadow-lg shadow-success-500/25 transition-colors hover:bg-success-400 active:bg-success-600" > - + {t('successNotification.goToBalance', 'Go to Balance')} )} @@ -354,7 +299,7 @@ export default function SuccessNotificationModal() { onClick={handleGoToSubscription} className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 py-3.5 font-bold text-white shadow-lg shadow-accent-500/25 transition-colors hover:bg-accent-400 active:bg-accent-600" > - + {t('successNotification.goToSubscription', 'Go to Subscription')} )} @@ -364,7 +309,7 @@ export default function SuccessNotificationModal() { onClick={handleGoToSubscription} className="flex w-full items-center justify-center gap-2 rounded-xl bg-success-500 py-3.5 font-bold text-white shadow-lg shadow-success-500/25 transition-colors hover:bg-success-400 active:bg-success-600" > - + {t('successNotification.goToSubscription', 'Go to Subscription')} )} diff --git a/src/components/TicketNotificationBell.tsx b/src/components/TicketNotificationBell.tsx index 88ef9b4..44b78f0 100644 --- a/src/components/TicketNotificationBell.tsx +++ b/src/components/TicketNotificationBell.tsx @@ -8,22 +8,7 @@ import { useToast } from './Toast'; import { useWebSocket, WSMessage } from '../hooks/useWebSocket'; import { useHeaderHeight } from '../hooks/useHeaderHeight'; import type { TicketNotification } from '../types'; - -const BellIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); +import { BellIcon, CheckIcon } from '@/components/icons'; interface TicketNotificationBellProps { isAdmin?: boolean; diff --git a/src/components/admin/MenuEditorTab.tsx b/src/components/admin/MenuEditorTab.tsx index 80e532e..6fbd92f 100644 --- a/src/components/admin/MenuEditorTab.tsx +++ b/src/components/admin/MenuEditorTab.tsx @@ -18,6 +18,15 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; +import { PiCaretDownDuotone } from 'react-icons/pi'; +import { + GripIcon, + TrashIcon, + PlusIcon, + LinkIcon, + ArrowUpIcon, + ArrowDownIcon, +} from '@/components/icons'; import { menuLayoutApi, type MenuConfig, @@ -31,82 +40,10 @@ import { Toggle } from './Toggle'; import { useNotify } from '../../platform/hooks/useNotify'; import { useNativeDialog } from '../../platform/hooks/useNativeDialog'; -const GripIcon = () => ( - - - -); - -const TrashIcon = () => ( - - - -); - -const PlusIcon = () => ( - - - -); - const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( - - - -); - -const LinkIcon = () => ( - - - -); - -const ArrowUpIcon = () => ( - - - -); - -const ArrowDownIcon = () => ( - - - + /> ); function generateId(): string { @@ -200,7 +137,7 @@ function ButtonChip({ : 'cursor-default text-dark-700' }`} > - + @@ -221,7 +158,7 @@ function ButtonChip({ {!isBuiltin && ( - + )} onUpdate({ enabled: !button.enabled })} /> @@ -236,7 +173,7 @@ function ButtonChip({ onClick={onRemove} className="rounded-lg p-1 text-dark-500 transition-colors hover:bg-error-500/10 hover:text-error-400" > - + )} @@ -425,7 +362,7 @@ function SortableRow({ onClick={() => onRemoveRow(row.id)} className="rounded-lg p-1.5 text-dark-500 transition-colors hover:bg-error-500/10 hover:text-error-400" > - + )} @@ -481,7 +418,7 @@ function InlineAddPanel({ rowId, usedBuiltinIds, onAddBuiltin, onAddCustom }: In onClick={() => setIsOpen(true)} className="flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-dark-700/50 py-2.5 text-sm text-dark-500 transition-colors hover:border-dark-600 hover:text-dark-400" > - + {t('admin.menuEditor.addButton')} ); @@ -516,7 +453,7 @@ function InlineAddPanel({ rowId, usedBuiltinIds, onAddBuiltin, onAddCustom }: In }} className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm text-dark-200 transition-colors hover:bg-dark-700/50" > - + {t('admin.menuEditor.addUrlButton')} diff --git a/src/components/admin/SortableSelectedMethodCard.tsx b/src/components/admin/SortableSelectedMethodCard.tsx index e8063d8..a00ae63 100644 --- a/src/components/admin/SortableSelectedMethodCard.tsx +++ b/src/components/admin/SortableSelectedMethodCard.tsx @@ -1,6 +1,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useSortable } from '@dnd-kit/sortable'; +import { PiCaretDownDuotone } from 'react-icons/pi'; import { CSS } from '@dnd-kit/utilities'; import { cn } from '../../lib/utils'; import { GripIcon, TrashIcon } from '../icons/LandingIcons'; @@ -10,15 +11,7 @@ import type { PaymentMethodSubOptionInfo } from '../../types'; export type MethodWithId = AdminLandingPaymentMethod & { _id: string }; const ChevronDownIcon = ({ open }: { open: boolean }) => ( - - - + ); interface SortableSelectedMethodProps { diff --git a/src/components/admin/bulkActions/ActionModal.tsx b/src/components/admin/bulkActions/ActionModal.tsx index e9d442d..8c14c51 100644 --- a/src/components/admin/bulkActions/ActionModal.tsx +++ b/src/components/admin/bulkActions/ActionModal.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; +import { CheckIcon, XCloseIcon } from '@/components/icons'; import { useFocusTrap } from '@/hooks/useFocusTrap'; import { DropdownSelect } from './DropdownSelect'; import type { UserListItem } from '../../../api/adminUsers'; @@ -48,24 +49,6 @@ export interface ModalState { progress: ProgressState | null; } -const CheckIcon = () => ( - - - -); - -const XCloseIcon = () => ( - - - -); - function ProgressView({ progress }: { progress: ProgressState }) { const { t } = useTranslation(); const logEndRef = useRef(null); @@ -520,7 +503,7 @@ export function ActionModal({ )} aria-pressed={forceDeleteActivePaid} > - {forceDeleteActivePaid && } + {forceDeleteActivePaid && } - {deleteFromPanel && } + {deleteFromPanel && } ( - - - -); +// Re-exported so the sibling FloatingActionBar / MultiSelectDropdown keep +// importing the chevron from this module while the glyph itself now comes +// from the central Phosphor barrel instead of a hand-written SVG. +export { ChevronDownIcon }; export interface DropdownOption { value: string; @@ -40,7 +40,7 @@ export function DropdownSelect({ value, options, onChange, className }: Dropdown ))}
- +
); diff --git a/src/components/admin/trafficUsage/TrafficIcons.tsx b/src/components/admin/trafficUsage/TrafficIcons.tsx index 78bf843..619b7ee 100644 --- a/src/components/admin/trafficUsage/TrafficIcons.tsx +++ b/src/components/admin/trafficUsage/TrafficIcons.tsx @@ -1,164 +1,35 @@ // ────────────────────────────────────────────────────────────────── -// TrafficIcons — the inline SVG set used across AdminTrafficUsage -// (header controls, filter buttons, sort indicator, etc.). Page-local -// in spirit; co-located here so the parent page imports a single -// flat barrel rather than redefining 15 inline icons. +// TrafficIcons — the icon set used across AdminTrafficUsage (header +// controls, filter buttons, sort indicator, etc.). The glyphs now come +// from the central Phosphor barrel (`@/components/icons`); only the +// sort indicator keeps its custom `direction` prop and is therefore a +// thin local wrapper over the panel's Phosphor caret icons. // ────────────────────────────────────────────────────────────────── -export const SearchIcon = () => ( - - - -); +import { PiCaretDownDuotone, PiCaretUpDownDuotone, PiCaretUpDuotone } from 'react-icons/pi'; -export const ChevronLeftIcon = () => ( - - - -); +export { + CalendarIcon, + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, + DownloadIcon, + FilterIcon, + GlobeIcon, + RefreshIcon, + SearchIcon, + ServerIcon, + ServerSmallIcon, + ShieldIcon, + StatusIcon, + XIcon, +} from '@/components/icons'; -export const ChevronRightIcon = () => ( - - - -); - -export const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( - - - -); - -export const DownloadIcon = () => ( - - - -); - -export const SortIcon = ({ direction }: { direction: false | 'asc' | 'desc' }) => ( - - {direction === 'asc' ? ( - - ) : direction === 'desc' ? ( - - ) : ( - - )} - -); - -export const FilterIcon = () => ( - - - -); - -export const ChevronDownIcon = () => ( - - - -); - -export const ServerIcon = () => ( - - - -); - -export const CalendarIcon = () => ( - - - -); - -export const XIcon = () => ( - - - -); - -export const StatusIcon = () => ( - - - -); - -export const GlobeIcon = () => ( - - - -); - -export const ShieldIcon = () => ( - - - -); - -export const ServerSmallIcon = () => ( - - - -); +export const SortIcon = ({ direction }: { direction: false | 'asc' | 'desc' }) => + direction === 'asc' ? ( + + ) : direction === 'desc' ? ( + + ) : ( + + ); diff --git a/src/components/admin/trafficUsage/filters/CountryFilter.tsx b/src/components/admin/trafficUsage/filters/CountryFilter.tsx index 10977f7..391897a 100644 --- a/src/components/admin/trafficUsage/filters/CountryFilter.tsx +++ b/src/components/admin/trafficUsage/filters/CountryFilter.tsx @@ -49,13 +49,13 @@ export function CountryFilter({ : 'border-dark-700 bg-dark-800 text-dark-200 hover:border-dark-600 hover:bg-dark-700' }`} > - + {activeCount > 0 && ( {activeCount} )} - + {open && ( diff --git a/src/components/admin/trafficUsage/filters/NodeFilter.tsx b/src/components/admin/trafficUsage/filters/NodeFilter.tsx index c3cb2b4..1a23e39 100644 --- a/src/components/admin/trafficUsage/filters/NodeFilter.tsx +++ b/src/components/admin/trafficUsage/filters/NodeFilter.tsx @@ -52,14 +52,14 @@ export function NodeFilter({ : 'border-dark-700 bg-dark-800 text-dark-200 hover:border-dark-600 hover:bg-dark-700' }`} > - + {t('admin.trafficUsage.nodes')} {activeCount > 0 && ( {activeCount} )} - + {open && ( diff --git a/src/components/admin/trafficUsage/filters/PeriodSelector.tsx b/src/components/admin/trafficUsage/filters/PeriodSelector.tsx index 7b2c0c5..da00ca9 100644 --- a/src/components/admin/trafficUsage/filters/PeriodSelector.tsx +++ b/src/components/admin/trafficUsage/filters/PeriodSelector.tsx @@ -42,7 +42,7 @@ export function PeriodSelector({ if (dateMode) { return (
- + {t('admin.trafficUsage.dateFrom')} - +
); @@ -96,7 +96,7 @@ export function PeriodSelector({ className="rounded-lg border border-dark-700 bg-dark-800 p-1.5 text-dark-400 transition-colors hover:border-dark-600 hover:bg-dark-700 hover:text-dark-200" title={t('admin.trafficUsage.customDates')} > - + ); diff --git a/src/components/admin/trafficUsage/filters/StatusFilter.tsx b/src/components/admin/trafficUsage/filters/StatusFilter.tsx index f87df56..dc00641 100644 --- a/src/components/admin/trafficUsage/filters/StatusFilter.tsx +++ b/src/components/admin/trafficUsage/filters/StatusFilter.tsx @@ -63,14 +63,14 @@ export function StatusFilter({ : 'border-dark-700 bg-dark-800 text-dark-200 hover:border-dark-600 hover:bg-dark-700' }`} > - + {t('admin.trafficUsage.status')} {activeCount > 0 && ( {activeCount} )} - + {open && ( diff --git a/src/components/admin/trafficUsage/filters/TariffFilter.tsx b/src/components/admin/trafficUsage/filters/TariffFilter.tsx index 5f4173c..ccdaaee 100644 --- a/src/components/admin/trafficUsage/filters/TariffFilter.tsx +++ b/src/components/admin/trafficUsage/filters/TariffFilter.tsx @@ -50,14 +50,14 @@ export function TariffFilter({ : 'border-dark-700 bg-dark-800 text-dark-200 hover:border-dark-600 hover:bg-dark-700' }`} > - + {t('admin.trafficUsage.tariff')} {activeCount > 0 && ( {activeCount} )} - + {open && ( diff --git a/src/components/admin/userDetail/BalanceTab.tsx b/src/components/admin/userDetail/BalanceTab.tsx index af5d49a..c0538bd 100644 --- a/src/components/admin/userDetail/BalanceTab.tsx +++ b/src/components/admin/userDetail/BalanceTab.tsx @@ -6,22 +6,7 @@ import { adminUsersApi, type UserDetailResponse } from '../../../api/adminUsers' import { promocodesApi } from '../../../api/promocodes'; import { promoOffersApi } from '../../../api/promoOffers'; import { createNumberInputHandler, toNumber } from '../../../utils/inputHelpers'; - -// ────────────────────────────────────────────────────────────────── -// Icons — local; balance is the only consumer. -// ────────────────────────────────────────────────────────────────── - -const PlusIcon = () => ( - - - -); - -const MinusIcon = () => ( - - - -); +import { PlusIcon, MinusIcon } from '@/components/icons'; // ────────────────────────────────────────────────────────────────── // Balance tab — current balance, add/subtract form, active promo @@ -168,14 +153,14 @@ export function BalanceTab({ disabled={actionLoading || balanceAmount === ''} className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-success-500 py-2 text-white transition-colors hover:bg-success-600 disabled:opacity-50" > - {t('admin.users.detail.balance.add')} + {t('admin.users.detail.balance.add')} diff --git a/src/components/admin/userDetail/SubscriptionTab.tsx b/src/components/admin/userDetail/SubscriptionTab.tsx index c45c52a..b382c1d 100644 --- a/src/components/admin/userDetail/SubscriptionTab.tsx +++ b/src/components/admin/userDetail/SubscriptionTab.tsx @@ -1,4 +1,5 @@ import { useTranslation } from 'react-i18next'; +import { MinusIcon, PlusIcon, RefreshIcon } from '@/components/icons'; import { DEVICE_ALIAS_MAX_LENGTH } from '../../../constants/devices'; import { createNumberInputHandler } from '../../../utils/inputHelpers'; import { getFlagEmoji } from '../../../utils/subscriptionHelpers'; @@ -37,28 +38,6 @@ function StatusBadge({ status }: { status: string }) { ); } -const PlusIcon = () => ( - - - -); - -const MinusIcon = () => ( - - - -); - -const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( - - - -); - // Local device row type (matches the parent's inline type) type DeviceRow = { hwid: string; @@ -390,7 +369,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) { disabled={actionLoading || selectedSub.device_limit <= 1} className="flex h-6 w-6 items-center justify-center rounded-md bg-dark-700 text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-30" > - + {selectedSub.device_limit} @@ -404,7 +383,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) { } className="flex h-6 w-6 items-center justify-center rounded-md bg-dark-700 text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-30" > - + diff --git a/src/components/admin/userDetail/SyncTab.tsx b/src/components/admin/userDetail/SyncTab.tsx index 2721c1e..d88565d 100644 --- a/src/components/admin/userDetail/SyncTab.tsx +++ b/src/components/admin/userDetail/SyncTab.tsx @@ -1,26 +1,11 @@ import { useTranslation } from 'react-i18next'; +import { ArrowDownIcon, ArrowUpIcon } from '@/components/icons'; import type { UserDetailResponse, UserSubscriptionInfo, PanelSyncStatusResponse, } from '../../../api/adminUsers'; -// ────────────────────────────────────────────────────────────────── -// Icons (sync-tab-local — not used outside this view) -// ────────────────────────────────────────────────────────────────── - -const ArrowDownIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( - - - -); - -const ArrowUpIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( - - - -); - // ────────────────────────────────────────────────────────────────── // Sync tab — compares bot DB vs panel data, offers a 2-way push // ────────────────────────────────────────────────────────────────── diff --git a/src/components/connection/InstallationGuide.tsx b/src/components/connection/InstallationGuide.tsx index 055b761..73d4e19 100644 --- a/src/components/connection/InstallationGuide.tsx +++ b/src/components/connection/InstallationGuide.tsx @@ -12,6 +12,7 @@ import { useTheme } from '@/hooks/useTheme'; import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock, BlockButtons } from './blocks'; import type { BlockRendererProps } from './blocks'; import TvQuickConnect from './TvQuickConnect'; +import { BackIcon } from '@/components/icons'; const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']; @@ -33,12 +34,6 @@ const RENDERERS: Record> = { minimal: MinimalBlock, }; -const BackIcon = () => ( - - - -); - interface Props { appConfig: AppConfig; onOpenDeepLink: (url: string) => void; @@ -200,7 +195,7 @@ export default function InstallationGuide({ aria-label={t('common.back', 'Back')} className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600" > - + )}

diff --git a/src/components/connection/blocks/BlockButtons.tsx b/src/components/connection/blocks/BlockButtons.tsx index dad4273..6f5341b 100644 --- a/src/components/connection/blocks/BlockButtons.tsx +++ b/src/components/connection/blocks/BlockButtons.tsx @@ -1,5 +1,6 @@ import { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { CheckIcon, CopyIcon } from '@/components/icons'; import type { RemnawaveButtonClient, LocalizedText } from '@/types'; import { copyToClipboard } from '@/utils/clipboard'; @@ -20,22 +21,6 @@ function isValidExternalUrl(url: string | undefined): boolean { return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://'); } -const CopyIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - interface BlockButtonsProps { buttons: RemnawaveButtonClient[] | undefined; variant: 'light' | 'subtle'; diff --git a/src/components/dashboard/StatsGrid.tsx b/src/components/dashboard/StatsGrid.tsx index 791ffd1..6463ee9 100644 --- a/src/components/dashboard/StatsGrid.tsx +++ b/src/components/dashboard/StatsGrid.tsx @@ -1,3 +1,4 @@ +import { PiCaretRightDuotone } from 'react-icons/pi'; import { useTranslation } from 'react-i18next'; import { Link } from 'react-router'; import { useCurrency } from '../../hooks/useCurrency'; @@ -12,22 +13,7 @@ interface StatsGridProps { } const ChevronIcon = ({ color }: { color: string }) => ( - +

-
+
{t('admin.remnawave.overview.server', 'Server')} -
+
} + icon={} color="accent" /> } + icon={} color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'} /> } + icon={} color="blue" />
@@ -468,7 +464,7 @@ function OverviewTab({ {t('admin.remnawave.overview.traffic', 'Traffic Statistics')} -
+
{t('admin.remnawave.overview.usersByStatus', 'Users by Status')} -
+
{Object.entries(stats.users_by_status).map(([status, count]) => ( {t('admin.remnawave.overview.panel', 'Панель')} -
+
0 || devicesStats.by_app.length > 0) && (

- + {t('admin.remnawave.overview.devices', 'Устройства')} ·{' '} {devicesStats.total_hwid_devices} ({devicesStats.average_devices_per_user.toFixed(1)}/ {t('admin.remnawave.overview.perUser', 'юзер')}) @@ -629,11 +625,11 @@ function OverviewTab({ {t('admin.remnawave.overview.panelHealth', 'Здоровье панели')} {health.instances > 1 ? ` · ${health.instances}` : ''}

-
+
} + icon={} color="blue" /> } + icon={} color={health.event_loop_p99_ms > 50 ? 'red' : 'green'} /> } + icon={} color="accent" />
@@ -664,14 +660,14 @@ function OverviewTab({

- {t('admin.remnawave.overview.subRequests', 'Запросы подписки (по клиентам)')} + {t('admin.remnawave.overview.subRequests', 'Запросы подписки (по клиентам)')} ·{' '} + {subRequests.by_app.reduce((acc, a) => acc + a.count, 0)}

-
- ({ label: a.app, count: a.count }))} - /> -
+ ({ label: a.app, count: a.count }))} + />
)}
@@ -719,7 +715,7 @@ function NodesTab({ return (
{/* Stats */} -
+
{/* Stats */} -
+
adminRemnawaveApi.getTopConsumers(7, 10), enabled: activeTab === 'overview', + refetchInterval: 60000, staleTime: 60000, }); @@ -1222,6 +1221,7 @@ export default function AdminRemnawave() { queryKey: ['admin-remnawave-sub-requests'], queryFn: adminRemnawaveApi.getSubscriptionRequests, enabled: activeTab === 'overview', + refetchInterval: 60000, staleTime: 60000, }); From 493de23a1dc678f33af7878dbc5cd981daebb3c5 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Sun, 31 May 2026 19:38:56 +0300 Subject: [PATCH 11/37] fix(admin-remnawave): scope the mobile orphan-fill to small screens only The `nth-child(odd)` orphan rule outweighed the desktop reset (higher specificity), so a lone trailing stat card was stretched full-width on desktop too. Use the `max-lg`/`max-sm` variants so the fill applies only below the multi-column breakpoint; desktop keeps the normal grid. --- src/pages/AdminRemnawave.tsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index ea66de4..d28b578 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -373,7 +373,7 @@ function OverviewTab({ {t('admin.remnawave.overview.system', 'System')} -
+
{t('admin.remnawave.overview.bandwidth', 'Inbound Traffic')} -
+
{t('admin.remnawave.overview.server', 'Server')} -
+
{t('admin.remnawave.overview.traffic', 'Traffic Statistics')} -
+
{t('admin.remnawave.overview.usersByStatus', 'Users by Status')} -
+
{Object.entries(stats.users_by_status).map(([status, count]) => ( {t('admin.remnawave.overview.panel', 'Панель')} -
+
1 ? ` · ${health.instances}` : ''} -
+
{/* Stats */} -
+
{/* Stats */} -
+
Date: Sun, 31 May 2026 19:38:57 +0300 Subject: [PATCH 12/37] fix(subscription): use the nav SubscriptionIcon on the quick-renew button The inline single sparkle didn't match the two-star sparkle shown in the nav. Render the same SubscriptionIcon component so the button matches the nav exactly. --- .../dashboard/SubscriptionCardExpired.tsx | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx index c03ff70..8fec23c 100644 --- a/src/components/dashboard/SubscriptionCardExpired.tsx +++ b/src/components/dashboard/SubscriptionCardExpired.tsx @@ -10,6 +10,7 @@ import { useCurrency } from '../../hooks/useCurrency'; import { useHapticFeedback } from '../../platform/hooks/useHaptic'; import { getGlassColors } from '../../utils/glassTheme'; import { getInsufficientBalanceError } from '../../utils/subscriptionHelpers'; +import { SubscriptionIcon } from '@/components/icons'; interface SubscriptionCardExpiredProps { subscription: Subscription; @@ -311,19 +312,7 @@ export default function SubscriptionCardExpired({ aria-hidden="true" /> ) : ( - + )} {isRenewing ? t('common.loading') From 8613cb20aa585c24e5d5e99f64e8b82efbbd8915 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Sun, 31 May 2026 19:43:54 +0300 Subject: [PATCH 13/37] fix(subscription): use the nav sparkle icon on the renew CTA button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PurchaseCTAButton (the "Продлить подписку" CTA) rendered a single inline sparkle; swap it for the SubscriptionIcon component so it matches the two-star sparkle in the navigation. Colour is inherited from the badge container. --- .../subscription/PurchaseCTAButton.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/components/subscription/PurchaseCTAButton.tsx b/src/components/subscription/PurchaseCTAButton.tsx index 779d367..1ca1402 100644 --- a/src/components/subscription/PurchaseCTAButton.tsx +++ b/src/components/subscription/PurchaseCTAButton.tsx @@ -1,6 +1,7 @@ import { Link } from 'react-router'; import { useTranslation } from 'react-i18next'; import { HoverBorderGradient } from '../ui/hover-border-gradient'; +import { SubscriptionIcon } from '@/components/icons'; import type { Subscription } from '../../types'; interface PurchaseCTAButtonProps { @@ -73,21 +74,10 @@ export default function PurchaseCTAButton({ background: isExpired ? 'rgba(255,59,92,0.12)' : 'rgba(var(--color-accent-400), 0.12)', + color: accentColor, }} > - +
{buttonText}
From 08f12daa400b0117d1502d260a405f4e9bd40f13 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Sun, 31 May 2026 22:53:33 +0300 Subject: [PATCH 14/37] feat(admin-remnawave): panel-style node rows with live metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign the Nodes tab to match the Remnawave panel's node list: - per-node row: status dot, online users, flag + name, provider badge, IP, traffic used with a progress bar (∞ when unlimited), uptime - live metrics strip: RAM %, load average (1/5/15m), realtime ↓/↑ throughput and the node + xray versions, sourced from system.stats (memory, loadAvg, interface rx/txBytesPerSec) already returned by /api/nodes/ - provider name comes from the realtime stats (joined by node uuid), which is now loaded on the Nodes tab too - poll the nodes endpoint every 5s so the live metrics stay fresh, like the panel - formatSpeed mirrors the panel's bits-with-1024-step formatting --- src/pages/AdminRemnawave.tsx | 203 +++++++++++++++++++++++++++-------- 1 file changed, 156 insertions(+), 47 deletions(-) diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index d28b578..b34308e 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -50,6 +50,18 @@ const formatBytes = (bytes: number): string => { // сохранён для случая пустого кода (важно для UI-плейсхолдеров). const getCountryFlag = (code: string | null | undefined): string => getFlagEmoji(code) || '🌍'; +// Realtime interface throughput (bytes/s) → network-style speed, matching the +// panel exactly: the unit step is by 1024 bytes, but the value is shown in bits +// (×8 / 1000), e.g. 341 KB/s → "2728 Kb/s", 1.34 MB/s → "10.72 Mb/s". +const formatSpeed = (bytesPerSec: number): string => { + const bps = bytesPerSec || 0; + const bits = bps * 8; + if (bps < 1024) return `${bits.toFixed(0)} b/s`; + if (bps < 1024 ** 2) return `${(bits / 1000).toFixed(2)} Kb/s`; + if (bps < 1024 ** 3) return `${(bits / 1e6).toFixed(2)} Mb/s`; + return `${(bits / 1e9).toFixed(2)} Gb/s`; +}; + interface StatCardProps { label: string; value: string | number; @@ -84,73 +96,75 @@ function StatCard({ label, value, icon, color = 'accent', subValue }: StatCardPr interface NodeCardProps { node: NodeInfo; + providerName?: string; onAction: (uuid: string, action: 'enable' | 'disable' | 'restart') => void; isLoading?: boolean; } -function NodeCard({ node, onAction, isLoading }: NodeCardProps) { +function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) { const { t } = useTranslation(); - const statusColor = node.is_disabled - ? 'text-dark-500' - : node.is_connected && node.is_node_online - ? 'text-success-400' - : 'text-error-400'; - + const isUp = node.is_connected && node.is_node_online && !node.is_disabled; + const dotColor = node.is_disabled ? 'bg-dark-500' : isUp ? 'bg-success-400' : 'bg-error-400'; const statusText = node.is_disabled ? t('admin.remnawave.nodes.disabled', 'Disabled') - : node.is_connected && node.is_node_online + : isUp ? t('admin.remnawave.nodes.online', 'Online') : t('admin.remnawave.nodes.offline', 'Offline'); - return ( -
-
-
-
- {getCountryFlag(node.country_code)} -

{node.name}

- - {statusText} - -
-

{node.address}

+ const s = node.system?.stats; + const memTotal = s ? s.memoryUsed + s.memoryFree : 0; + const ramPct = memTotal > 0 && s ? Math.round((s.memoryUsed / memTotal) * 100) : null; + const loadAvg = s?.loadAvg?.length + ? s.loadAvg + .slice(0, 3) + .map((n) => n.toFixed(2)) + .join(' ') + : null; + const rx = s?.interface?.rxBytesPerSec ?? 0; + const tx = s?.interface?.txBytesPerSec ?? 0; -
- - - {t('admin.remnawave.nodes.usersOnlineCount', '{{count}} online', { - count: node.users_online ?? 0, - })} + const used = node.traffic_used_bytes ?? 0; + const limit = node.traffic_limit_bytes ?? 0; + const trafficPct = limit > 0 ? Math.min(100, (used / limit) * 100) : null; + + return ( +
+ {/* Identity + actions */} +
+
+ + + + {node.users_online ?? 0} + + + {getCountryFlag(node.country_code)} + +

{node.name}

+ {providerName && ( + + {providerName} - {node.traffic_used_bytes !== undefined && ( - - {formatBytes(node.traffic_used_bytes)}{' '} - {t('admin.remnawave.nodes.trafficUsed', 'used')} - - )} - {node.xray_uptime > 0 && ( - - {t('admin.remnawave.nodes.uptimeLabel', 'Uptime')}: {formatUptime(node.xray_uptime)} - - )} - {node.versions?.xray && Xray {node.versions.xray}} -
+ )}
-
+
+ + {/* Address + traffic + uptime */} +
+ + + {node.address} + + + {formatBytes(used)} + {trafficPct !== null && ( + + + + )} + {limit > 0 ? formatBytes(limit) : '∞'} + + {node.xray_uptime > 0 && ( + + + {formatUptime(node.xray_uptime)} + + )} +
+ + {/* Live metrics: RAM · load · speeds · versions */} + {(ramPct !== null || loadAvg || rx > 0 || tx > 0 || node.versions) && ( +
+ {ramPct !== null && ( + + 85 + ? 'text-error-400' + : ramPct > 65 + ? 'text-warning-400' + : 'text-dark-400' + } + > + {ramPct}% + + + + + + )} + {loadAvg && {loadAvg}} + + + + {formatSpeed(rx)} + + + + {formatSpeed(tx)} + + + {(node.versions?.node || node.versions?.xray) && ( + + {node.versions?.node && {node.versions.node}} + {node.versions?.xray && xray {node.versions.xray}} + + )} +
+ )}
); } @@ -676,6 +764,7 @@ function OverviewTab({ interface NodesTabProps { nodes: NodeInfo[]; + providerByUuid: Record; isLoading: boolean; onRefresh: () => void; onAction: (uuid: string, action: 'enable' | 'disable' | 'restart') => void; @@ -685,6 +774,7 @@ interface NodesTabProps { function NodesTab({ nodes, + providerByUuid, isLoading, onRefresh, onAction, @@ -775,7 +865,13 @@ function NodesTab({

) : ( nodes.map((node) => ( - + )) )}
@@ -1233,7 +1329,8 @@ export default function AdminRemnawave() { queryKey: ['admin-remnawave-nodes'], queryFn: adminRemnawaveApi.getNodes, enabled: activeTab === 'nodes', - refetchInterval: 15000, + // Fast poll so realtime metrics (RAM, load, speeds) stay live like the panel. + refetchInterval: 5000, }); const { @@ -1253,10 +1350,21 @@ export default function AdminRemnawave() { } = useQuery({ queryKey: ['admin-remnawave-realtime'], queryFn: adminRemnawaveApi.getNodesRealtime, - enabled: activeTab === 'traffic', + // Loaded on the Nodes tab too — realtime carries the provider name per node. + enabled: activeTab === 'nodes' || activeTab === 'traffic', refetchInterval: 10000, }); + // Provider name (e.g. "WAICORE") only comes through the realtime stats; map it + // by node uuid so the Nodes tab can show the provider badge like the panel. + const providerByUuid = useMemo(() => { + const map: Record = {}; + for (const r of realtimeData ?? []) { + if (r.providerName) map[r.nodeUuid] = r.providerName; + } + return map; + }, [realtimeData]); + const { data: autoSyncStatus, isLoading: isLoadingAutoSync } = useQuery({ queryKey: ['admin-remnawave-autosync'], queryFn: adminRemnawaveApi.getAutoSyncStatus, @@ -1430,6 +1538,7 @@ export default function AdminRemnawave() { {activeTab === 'nodes' && ( refetchNodes()} onAction={handleNodeAction} From b5088c70a1c07b6ddfe3104ccf750e903e230b99 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Sun, 31 May 2026 23:37:12 +0300 Subject: [PATCH 15/37] refactor(cabinet): migrate inline SVG icons to the central react-icons set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit-driven sweep: replaced 168 hand-written inline icons across 77 files with the central Phosphor (react-icons/pi) components from @/components/icons, preserving each icon's size classes and colour (dynamic stroke colours via the parent's currentColor, RefreshIcon's spinning state, conditional rotate-180 chevrons). Verified: tsc + vite build + eslint clean; an adversarial diff review of all changed files found the replacements correct (70 files clean, 0 blockers). Remaining inline dropped from 262 to ~95 — the survivors are legitimate non-icons (brand/provider logos, loading spinners & framer-motion animations, charts/sparklines, background decoration) plus a few ambiguous glyphs. --- src/components/InsufficientBalancePrompt.tsx | 39 +------ src/components/LanguageSwitcher.tsx | 10 +- src/components/ProviderIcon.tsx | 18 +-- src/components/Toast.tsx | 58 +--------- src/components/admin/AnalyticsTab.tsx | 44 +------- src/components/admin/ButtonsTab.tsx | 11 +- src/components/admin/ColoredItemCombobox.tsx | 29 ++--- .../admin/SortableSelectedMethodCard.tsx | 13 +-- .../admin/bulkActions/ActionModal.tsx | 46 +------- .../admin/bulkActions/FloatingActionBar.tsx | 17 +-- .../admin/bulkActions/MultiSelectDropdown.tsx | 17 +-- .../admin/bulkActions/SubscriptionSubRow.tsx | 13 +-- .../trafficUsage/filters/CountryFilter.tsx | 29 +---- .../admin/trafficUsage/filters/NodeFilter.tsx | 29 +---- .../trafficUsage/filters/StatusFilter.tsx | 29 +---- .../trafficUsage/filters/TariffFilter.tsx | 29 +---- src/components/admin/userDetail/GiftsTab.tsx | 15 +-- src/components/admin/userDetail/InfoTab.tsx | 15 +-- .../admin/userDetail/ReferralsTab.tsx | 11 +- .../admin/userDetail/SubscriptionTab.tsx | 92 +++------------- .../admin/userDetail/TicketsTab.tsx | 39 +------ .../blocking/AccountDeletedScreen.tsx | 16 +-- src/components/blocking/BlacklistedScreen.tsx | 15 +-- .../blocking/ChannelSubscriptionScreen.tsx | 28 +---- src/components/blocking/MaintenanceScreen.tsx | 15 +-- .../connection/InstallationGuide.tsx | 26 +---- .../connection/blocks/AccordionBlock.tsx | 11 +- src/components/dashboard/PendingGiftCard.tsx | 15 +-- .../dashboard/SubscriptionCardActive.tsx | 20 ++-- .../dashboard/SubscriptionCardExpired.tsx | 62 +---------- src/components/dashboard/TrialOfferCard.tsx | 37 ++----- src/components/news/NewsSection.tsx | 32 +----- .../subscription/PurchaseCTAButton.tsx | 17 +-- .../subscription/SubscriptionListCard.tsx | 57 +++------- .../purchase/TariffPickerGrid.tsx | 43 +------- .../sheets/DeleteSubscriptionSheet.tsx | 15 +-- src/components/tickets/MessageMediaGrid.tsx | 47 ++------ src/pages/AdminApps.tsx | 11 +- src/pages/AdminBanSystem.tsx | 54 ++------- src/pages/AdminCampaignCreate.tsx | 16 +-- src/pages/AdminCampaignStats.tsx | 12 +- src/pages/AdminPartnerDetail.tsx | 15 +-- src/pages/AdminPartners.tsx | 34 +----- src/pages/AdminPayments.tsx | 36 ++---- src/pages/AdminPolicyEdit.tsx | 11 +- src/pages/AdminPromoOfferSend.tsx | 21 +--- src/pages/AdminTariffs.tsx | 14 +-- src/pages/AdminTickets.tsx | 49 +-------- src/pages/AdminWithdrawalDetail.tsx | 15 +-- src/pages/AdminWithdrawals.tsx | 15 +-- src/pages/AutoLogin.tsx | 11 +- src/pages/Connection.tsx | 20 +--- src/pages/Contests.tsx | 11 +- src/pages/Dashboard.tsx | 13 +-- src/pages/DeepLinkRedirect.tsx | 91 +++------------ src/pages/GiftResult.tsx | 81 ++------------ src/pages/GiftSubscription.tsx | 25 +---- src/pages/Login.tsx | 81 ++------------ src/pages/OAuthCallback.tsx | 25 +---- src/pages/Polls.tsx | 11 +- src/pages/Profile.tsx | 10 +- src/pages/PurchaseSuccess.tsx | 53 +-------- src/pages/QuickPurchase.tsx | 51 +-------- src/pages/Referral.tsx | 63 ++--------- .../components/CampaignDetailPanel.tsx | 11 +- .../components/NetworkControls.tsx | 25 +---- .../components/NetworkFilters.tsx | 35 +----- .../components/ScopeSelector.tsx | 49 +-------- .../components/UserDetailPanel.tsx | 11 +- src/pages/ResetPassword.tsx | 11 +- src/pages/Subscription.tsx | 104 +++++------------- src/pages/SubscriptionPurchase.tsx | 43 ++------ src/pages/Subscriptions.tsx | 25 +---- src/pages/Support.tsx | 44 +------- src/pages/TelegramRedirect.tsx | 35 +----- src/pages/TopUpAmount.tsx | 15 +-- 76 files changed, 331 insertions(+), 1985 deletions(-) diff --git a/src/components/InsufficientBalancePrompt.tsx b/src/components/InsufficientBalancePrompt.tsx index 75ebae1..1f4de9e 100644 --- a/src/components/InsufficientBalancePrompt.tsx +++ b/src/components/InsufficientBalancePrompt.tsx @@ -2,6 +2,7 @@ import { useState } from 'react'; import { useNavigate, useLocation } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useCurrency } from '../hooks/useCurrency'; +import { InfoIcon, WalletIcon, PlusIcon } from '@/components/icons'; interface InsufficientBalancePromptProps { /** Amount missing in kopeks */ @@ -55,19 +56,7 @@ export default function InsufficientBalancePrompt({ className={`flex items-center justify-between gap-3 rounded-xl border border-error-500/30 bg-error-500/10 p-3 ${className}`} >
- - - + {message || t('balance.insufficientFunds')}:{' '} @@ -96,19 +85,7 @@ export default function InsufficientBalancePrompt({ >
- - - +
{t('balance.insufficientFunds')}
@@ -132,15 +109,7 @@ export default function InsufficientBalancePrompt({ ) : ( <> - - - + {t('balance.topUpBalance')} )} diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx index 218b9f6..45585f3 100644 --- a/src/components/LanguageSwitcher.tsx +++ b/src/components/LanguageSwitcher.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import { useState, useRef, useEffect } from 'react'; import { infoApi, type LanguageInfo } from '@/api/info'; +import { ChevronDownIcon } from '@/components/icons'; export default function LanguageSwitcher() { const { i18n } = useTranslation(); @@ -57,14 +58,9 @@ export default function LanguageSwitcher() { > {currentLang.flag} {currentLang.code.toUpperCase()} - - - + /> {isOpen && ( diff --git a/src/components/ProviderIcon.tsx b/src/components/ProviderIcon.tsx index 758f0b3..ca7a98f 100644 --- a/src/components/ProviderIcon.tsx +++ b/src/components/ProviderIcon.tsx @@ -1,4 +1,5 @@ import { cn } from '@/lib/utils'; +import { EmailIcon as CentralEmailIcon } from '@/components/icons'; import OAuthProviderIcon from './OAuthProviderIcon'; export function TelegramIcon({ className = 'h-5 w-5' }: { className?: string }) { @@ -13,22 +14,7 @@ export function TelegramIcon({ className = 'h-5 w-5' }: { className?: string }) } export function EmailIcon({ className = 'h-5 w-5' }: { className?: string }) { - return ( - - ); + return ; } export default function ProviderIcon({ diff --git a/src/components/Toast.tsx b/src/components/Toast.tsx index 06f551f..dfd0493 100644 --- a/src/components/Toast.tsx +++ b/src/components/Toast.tsx @@ -8,6 +8,8 @@ import { ReactNode, } from 'react'; +import { CheckIcon, XIcon, ExclamationIcon, InfoIcon } from '@/components/icons'; + interface ToastOptions { type?: 'success' | 'error' | 'info' | 'warning'; message: string; @@ -163,58 +165,10 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { }; const defaultIcons = { - success: ( - - - - ), - error: ( - - - - ), - warning: ( - - - - ), - info: ( - - - - ), + success: , + error: , + warning: , + info: , }; return ( diff --git a/src/components/admin/AnalyticsTab.tsx b/src/components/admin/AnalyticsTab.tsx index 4065eea..0a1ba98 100644 --- a/src/components/admin/AnalyticsTab.tsx +++ b/src/components/admin/AnalyticsTab.tsx @@ -2,7 +2,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { brandingApi } from '../../api/branding'; -import { CheckIcon, CloseIcon } from './icons'; +import { CheckIcon, CloseIcon, PencilIcon } from './icons'; export function AnalyticsTab() { const { t } = useTranslation(); @@ -143,19 +143,7 @@ export function AnalyticsTab() { }} className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200" > - - - +
)} @@ -294,19 +282,7 @@ export function AnalyticsTab() { }} className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200" > - - - +
)} @@ -360,19 +336,7 @@ export function AnalyticsTab() { }} className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200" > - - - +
)} diff --git a/src/components/admin/ButtonsTab.tsx b/src/components/admin/ButtonsTab.tsx index 0d3eefe..5d1ec29 100644 --- a/src/components/admin/ButtonsTab.tsx +++ b/src/components/admin/ButtonsTab.tsx @@ -9,6 +9,7 @@ import { ButtonSection, BOT_LOCALES, } from '../../api/buttonStyles'; +import { ChevronDownIcon } from '@/components/icons'; import { Toggle } from './Toggle'; import { useNotify } from '../../platform/hooks/useNotify'; import { useNativeDialog } from '../../platform/hooks/useNativeDialog'; @@ -284,15 +285,9 @@ export function ButtonsTab() { {t('admin.buttons.customLabels')} {hasCustomLabels && } - - - + /> {isExpanded && (
diff --git a/src/components/admin/ColoredItemCombobox.tsx b/src/components/admin/ColoredItemCombobox.tsx index 2c05260..d023f7b 100644 --- a/src/components/admin/ColoredItemCombobox.tsx +++ b/src/components/admin/ColoredItemCombobox.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { CheckIcon, ChevronDownIcon, CloseIcon, PlusIcon } from '@/components/icons'; import { cn } from '../../lib/utils'; import { useHapticFeedback } from '../../platform/hooks/useHaptic'; @@ -209,9 +210,7 @@ export function ColoredItemCombobox({ className="shrink-0 rounded p-0.5 text-dark-500 transition-colors hover:text-dark-300" aria-label={t('news.admin.combobox.clear')} > - - - + ) : ( @@ -222,16 +221,12 @@ export function ColoredItemCombobox({ )} - - - + /> {/* Dropdown */} @@ -279,13 +274,7 @@ export function ColoredItemCombobox({ /> {item.name} {value?.id === item.id && ( - - - + )} {onDelete && ( )} @@ -365,9 +352,7 @@ export function ColoredItemCombobox({
) : ( <> - - - + {t('news.admin.combobox.create')} )} diff --git a/src/components/admin/SortableSelectedMethodCard.tsx b/src/components/admin/SortableSelectedMethodCard.tsx index e0bc956..90af4a3 100644 --- a/src/components/admin/SortableSelectedMethodCard.tsx +++ b/src/components/admin/SortableSelectedMethodCard.tsx @@ -4,6 +4,7 @@ import { useSortable } from '@dnd-kit/sortable'; import { PiCaretDown } from 'react-icons/pi'; import { CSS } from '@dnd-kit/utilities'; import { cn } from '../../lib/utils'; +import { CheckIcon } from '@/components/icons'; import { GripIcon, TrashIcon } from '../icons/LandingIcons'; import type { AdminLandingPaymentMethod, EditableMethodField } from '../../api/landings'; import type { PaymentMethodSubOptionInfo } from '../../types'; @@ -218,17 +219,7 @@ export function SortableSelectedMethodCard({ : 'border border-dark-600 bg-dark-700', )} > - {enabled && ( - - - - )} + {enabled && }
{opt.name} diff --git a/src/components/admin/bulkActions/ActionModal.tsx b/src/components/admin/bulkActions/ActionModal.tsx index 8c14c51..3371b36 100644 --- a/src/components/admin/bulkActions/ActionModal.tsx +++ b/src/components/admin/bulkActions/ActionModal.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; -import { CheckIcon, XCloseIcon } from '@/components/icons'; +import { CheckIcon, ChevronDownIcon, XCloseIcon, XIcon } from '@/components/icons'; import { useFocusTrap } from '@/hooks/useFocusTrap'; import { DropdownSelect } from './DropdownSelect'; import type { UserListItem } from '../../../api/adminUsers'; @@ -98,31 +98,11 @@ function ProgressView({ progress }: { progress: ProgressState }) {
{entry.success ? ( ) : ( )} @@ -159,18 +139,12 @@ function ErrorDetails({ result }: { result: BulkActionResult }) { {t('admin.bulkActions.errors.title', { count: result.error_count })} - - - + /> {expanded && (
@@ -178,15 +152,7 @@ function ErrorDetails({ result }: { result: BulkActionResult }) { {result.errors.map((err, idx) => (
{err.username ? `@${err.username}` : `#${err.user_id}`} diff --git a/src/components/admin/bulkActions/FloatingActionBar.tsx b/src/components/admin/bulkActions/FloatingActionBar.tsx index cbc7df0..3808c06 100644 --- a/src/components/admin/bulkActions/FloatingActionBar.tsx +++ b/src/components/admin/bulkActions/FloatingActionBar.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; +import { TrashIcon } from '@/components/icons'; import { ChevronDownIcon } from './DropdownSelect'; import { isSubscriptionLevelAction } from './actionTargets'; import type { BulkActionType } from '../../../api/adminBulkActions'; @@ -112,21 +113,7 @@ export function FloatingActionBar({ { type: 'delete_subscription', labelKey: 'admin.bulkActions.actions.deleteSubscription', - icon: ( - - - - ), + icon: , colorClass: 'text-error-400 hover:bg-error-500/10', }, { diff --git a/src/components/admin/bulkActions/MultiSelectDropdown.tsx b/src/components/admin/bulkActions/MultiSelectDropdown.tsx index e2fc165..b4db3da 100644 --- a/src/components/admin/bulkActions/MultiSelectDropdown.tsx +++ b/src/components/admin/bulkActions/MultiSelectDropdown.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; +import { CheckIcon } from '@/components/icons'; import { ChevronDownIcon } from './DropdownSelect'; // ────────────────────────────────────────────────────────────────── @@ -135,21 +136,7 @@ export function MultiSelectDropdown({ : 'border-dark-500 bg-dark-700/60', )} > - {isChecked && ( - - - - )} + {isChecked && }
{option.label} diff --git a/src/components/admin/bulkActions/SubscriptionSubRow.tsx b/src/components/admin/bulkActions/SubscriptionSubRow.tsx index c03cae3..387ff02 100644 --- a/src/components/admin/bulkActions/SubscriptionSubRow.tsx +++ b/src/components/admin/bulkActions/SubscriptionSubRow.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; +import { CheckIcon } from '@/components/icons'; import type { UserListItemSubscription } from '../../../api/adminUsers'; // ────────────────────────────────────────────────────────────────── @@ -68,17 +69,7 @@ export function SubscriptionSubRow({ : t('admin.bulkActions.selectUser', { name: subscription.tariff_name || '' }) } > - {isSelected && ( - - - - )} + {isSelected && }
)} diff --git a/src/components/admin/trafficUsage/filters/CountryFilter.tsx b/src/components/admin/trafficUsage/filters/CountryFilter.tsx index 391897a..97f9b43 100644 --- a/src/components/admin/trafficUsage/filters/CountryFilter.tsx +++ b/src/components/admin/trafficUsage/filters/CountryFilter.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react'; +import { CheckIcon } from '@/components/icons'; import { ChevronDownIcon, GlobeIcon } from '../TrafficIcons'; import { getFlagEmoji } from '../trafficUsageHelpers'; @@ -71,17 +72,7 @@ export function CountryFilter({ allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600' }`} > - {allSelected && ( - - - - )} + {allSelected && }
All @@ -102,21 +93,7 @@ export function CountryFilter({ checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600' }`} > - {checked && ( - - - - )} + {checked && } {getFlagEmoji(code)} {code.toUpperCase()} {count} diff --git a/src/components/admin/trafficUsage/filters/NodeFilter.tsx b/src/components/admin/trafficUsage/filters/NodeFilter.tsx index 1a23e39..a690110 100644 --- a/src/components/admin/trafficUsage/filters/NodeFilter.tsx +++ b/src/components/admin/trafficUsage/filters/NodeFilter.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ChevronDownIcon, ServerIcon } from '../TrafficIcons'; +import { CheckIcon } from '@/components/icons'; import { getFlagEmoji } from '../trafficUsageHelpers'; import type { TrafficNodeInfo } from '../../../../api/adminTraffic'; @@ -75,17 +76,7 @@ export function NodeFilter({ allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600' }`} > - {allSelected && ( - - - - )} + {allSelected && } {t('admin.trafficUsage.allNodes')} @@ -106,21 +97,7 @@ export function NodeFilter({ checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600' }`} > - {checked && ( - - - - )} + {checked && } {getFlagEmoji(node.country_code)} {node.node_name} diff --git a/src/components/admin/trafficUsage/filters/StatusFilter.tsx b/src/components/admin/trafficUsage/filters/StatusFilter.tsx index dc00641..78e2350 100644 --- a/src/components/admin/trafficUsage/filters/StatusFilter.tsx +++ b/src/components/admin/trafficUsage/filters/StatusFilter.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { CheckIcon } from '@/components/icons'; import { ChevronDownIcon, StatusIcon } from '../TrafficIcons'; // Status colour pills shared with the StatusFilter dropdown. @@ -86,17 +87,7 @@ export function StatusFilter({ allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600' }`} > - {allSelected && ( - - - - )} + {allSelected && } {t('admin.trafficUsage.allStatuses')} @@ -117,21 +108,7 @@ export function StatusFilter({ checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600' }`} > - {checked && ( - - - - )} + {checked && } {statusLabel(s)} diff --git a/src/components/admin/trafficUsage/filters/TariffFilter.tsx b/src/components/admin/trafficUsage/filters/TariffFilter.tsx index ccdaaee..e67627b 100644 --- a/src/components/admin/trafficUsage/filters/TariffFilter.tsx +++ b/src/components/admin/trafficUsage/filters/TariffFilter.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ChevronDownIcon, FilterIcon } from '../TrafficIcons'; +import { CheckIcon } from '@/components/icons'; export function TariffFilter({ available, @@ -73,17 +74,7 @@ export function TariffFilter({ allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600' }`} > - {allSelected && ( - - - - )} + {allSelected && } {t('admin.trafficUsage.allTariffs')} @@ -104,21 +95,7 @@ export function TariffFilter({ checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600' }`} > - {checked && ( - - - - )} + {checked && } {tariff} diff --git a/src/components/admin/userDetail/GiftsTab.tsx b/src/components/admin/userDetail/GiftsTab.tsx index 2247089..b053aff 100644 --- a/src/components/admin/userDetail/GiftsTab.tsx +++ b/src/components/admin/userDetail/GiftsTab.tsx @@ -1,4 +1,5 @@ import { useTranslation } from 'react-i18next'; +import { SendIcon } from '@/components/icons'; import { useCurrency } from '../../../hooks/useCurrency'; import type { AdminUserGiftItem, AdminUserGiftsResponse } from '../../../api/adminUsers'; @@ -224,19 +225,7 @@ export function GiftsTab({ giftsLoading, giftsData, locale, onNavigateToUser }: {/* Sent Gifts */}

- - - + {t('admin.users.detail.gifts.sentTitle')} ({giftsData.sent_total})

diff --git a/src/components/admin/userDetail/InfoTab.tsx b/src/components/admin/userDetail/InfoTab.tsx index 358afb5..226687d 100644 --- a/src/components/admin/userDetail/InfoTab.tsx +++ b/src/components/admin/userDetail/InfoTab.tsx @@ -9,6 +9,7 @@ import type { UserSubscriptionInfo, } from '../../../api/adminUsers'; import type { PromoGroup } from '../../../api/promocodes'; +import { ServerIcon } from '@/components/icons'; // ────────────────────────────────────────────────────────────────── // Local status badge (parent has its own — duplicating here to keep @@ -260,19 +261,7 @@ export function InfoTab(props: InfoTabProps) { {t('admin.users.detail.lastNode')}
- - - + {panelInfo.last_connected_node_name}
diff --git a/src/components/admin/userDetail/ReferralsTab.tsx b/src/components/admin/userDetail/ReferralsTab.tsx index a745df3..05acd29 100644 --- a/src/components/admin/userDetail/ReferralsTab.tsx +++ b/src/components/admin/userDetail/ReferralsTab.tsx @@ -5,6 +5,7 @@ import { useNavigate } from 'react-router'; import { useCurrency } from '../../../hooks/useCurrency'; import { useNotify } from '../../../platform/hooks/useNotify'; import { adminUsersApi, type UserDetailResponse, type UserListItem } from '../../../api/adminUsers'; +import { XIcon } from '@/components/icons'; // ────────────────────────────────────────────────────────────────── // Referrals tab — top-of-graph referrer + stats + referrals list, @@ -481,15 +482,7 @@ export function ReferralsTab({ user, userId, onUserRefresh }: ReferralsTabProps) className="rounded-lg p-2 text-dark-500 transition-colors hover:bg-error-500/10 hover:text-error-400 disabled:opacity-50" title={t('admin.users.detail.referrals.removeReferral')} > - - - +
))} diff --git a/src/components/admin/userDetail/SubscriptionTab.tsx b/src/components/admin/userDetail/SubscriptionTab.tsx index b382c1d..b6fec90 100644 --- a/src/components/admin/userDetail/SubscriptionTab.tsx +++ b/src/components/admin/userDetail/SubscriptionTab.tsx @@ -1,5 +1,15 @@ import { useTranslation } from 'react-i18next'; -import { MinusIcon, PlusIcon, RefreshIcon } from '@/components/icons'; +import { + BackIcon, + CheckIcon, + ChevronDownIcon, + ChevronRightIcon, + EditIcon, + MinusIcon, + PlusIcon, + RefreshIcon, + XIcon, +} from '@/components/icons'; import { DEVICE_ALIAS_MAX_LENGTH } from '../../../constants/devices'; import { createNumberInputHandler } from '../../../utils/inputHelpers'; import { getFlagEmoji } from '../../../utils/subscriptionHelpers'; @@ -217,19 +227,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
- - - +
@@ -309,15 +307,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) { onClick={() => onSubscriptionDetailViewChange(false)} className="flex items-center gap-1.5 text-sm text-dark-400 transition-colors hover:text-dark-200" > - - - + {t('admin.users.detail.subscription.backToList', 'Все подписки')} )} @@ -943,19 +933,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) { )} aria-label={t('admin.users.detail.devices.renameSave', 'Сохранить')} > - + ) : ( @@ -995,19 +961,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) { title={t('admin.users.detail.devices.rename', 'Переименовать')} aria-label={t('admin.users.detail.devices.rename', 'Переименовать')} > - +
- - - + /> {requestHistoryExpanded && ( diff --git a/src/components/admin/userDetail/TicketsTab.tsx b/src/components/admin/userDetail/TicketsTab.tsx index 3a16b8d..0dbd4f0 100644 --- a/src/components/admin/userDetail/TicketsTab.tsx +++ b/src/components/admin/userDetail/TicketsTab.tsx @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'; import { adminApi, type AdminTicket, type AdminTicketDetail } from '../../../api/admin'; import { MessageMediaGrid } from '../../tickets/MessageMediaGrid'; import { linkifyText } from '../../../utils/linkify'; +import { ChatIcon, BackIcon, SendIcon } from '@/components/icons'; // ────────────────────────────────────────────────────────────────── // Tickets tab — list view + chat view (selected ticket replaces list). @@ -155,19 +156,7 @@ function EmptyState() { const { t } = useTranslation(); return (
- - - +

{t('admin.users.detail.noTickets')}

); @@ -265,15 +254,7 @@ function ChatView({ aria-label={t('common.back', 'Back')} className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-dark-800 transition-colors hover:bg-dark-700 sm:h-8 sm:w-8" > - - - +
@@ -372,19 +353,7 @@ function ChatView({ {replySending ? (
) : ( - - - + )}
diff --git a/src/components/blocking/AccountDeletedScreen.tsx b/src/components/blocking/AccountDeletedScreen.tsx index d94faf5..fb2e319 100644 --- a/src/components/blocking/AccountDeletedScreen.tsx +++ b/src/components/blocking/AccountDeletedScreen.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next'; import { usePlatform } from '@/platform'; +import { InfoIcon } from '@/components/icons'; import { useBlockingStore } from '../../store/blocking'; import { useFocusTrap } from '../../hooks/useFocusTrap'; @@ -55,20 +56,7 @@ export default function AccountDeletedScreen() {
- +
diff --git a/src/components/blocking/BlacklistedScreen.tsx b/src/components/blocking/BlacklistedScreen.tsx index 2bb99e7..de533f5 100644 --- a/src/components/blocking/BlacklistedScreen.tsx +++ b/src/components/blocking/BlacklistedScreen.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import { useBlockingStore } from '../../store/blocking'; import { useFocusTrap } from '../../hooks/useFocusTrap'; +import { BanIcon } from '@/components/icons'; export default function BlacklistedScreen() { const { t } = useTranslation(); @@ -20,19 +21,7 @@ export default function BlacklistedScreen() { {/* Icon */}
- - - +
diff --git a/src/components/blocking/ChannelSubscriptionScreen.tsx b/src/components/blocking/ChannelSubscriptionScreen.tsx index a4c78e8..e213c6c 100644 --- a/src/components/blocking/ChannelSubscriptionScreen.tsx +++ b/src/components/blocking/ChannelSubscriptionScreen.tsx @@ -4,6 +4,7 @@ import { useBlockingStore } from '../../store/blocking'; import { apiClient, isChannelSubscriptionError } from '../../api/client'; import { usePlatform } from '../../platform'; import { useFocusTrap } from '../../hooks/useFocusTrap'; +import { TelegramIcon, ClockIcon, CheckIcon } from '@/components/icons'; const CHECK_COOLDOWN_SECONDS = 5; @@ -96,9 +97,7 @@ export default function ChannelSubscriptionScreen() { {/* Icon */}
- - - +
@@ -183,31 +182,12 @@ export default function ChannelSubscriptionScreen() { ) : cooldown > 0 ? ( <> - - - + {t('blocking.channel.waitSeconds', { seconds: cooldown })} ) : ( <> - - - + {t('blocking.channel.checkSubscription')} )} diff --git a/src/components/blocking/MaintenanceScreen.tsx b/src/components/blocking/MaintenanceScreen.tsx index 6adc56f..2326a33 100644 --- a/src/components/blocking/MaintenanceScreen.tsx +++ b/src/components/blocking/MaintenanceScreen.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import { useBlockingStore } from '../../store/blocking'; import { useFocusTrap } from '../../hooks/useFocusTrap'; +import { AdjustmentsIcon } from '@/components/icons'; export default function MaintenanceScreen() { const { t } = useTranslation(); @@ -20,19 +21,7 @@ export default function MaintenanceScreen() { {/* Icon */}
- - - +
diff --git a/src/components/connection/InstallationGuide.tsx b/src/components/connection/InstallationGuide.tsx index 73d4e19..8e79c74 100644 --- a/src/components/connection/InstallationGuide.tsx +++ b/src/components/connection/InstallationGuide.tsx @@ -12,7 +12,7 @@ import { useTheme } from '@/hooks/useTheme'; import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock, BlockButtons } from './blocks'; import type { BlockRendererProps } from './blocks'; import TvQuickConnect from './TvQuickConnect'; -import { BackIcon } from '@/components/icons'; +import { BackIcon, ChevronIcon, DocumentIcon } from '@/components/icons'; const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']; @@ -259,15 +259,7 @@ export default function InstallationGuide({ ))}
- - - +
)} @@ -315,19 +307,7 @@ export default function InstallationGuide({ rel="noopener noreferrer" className="btn-secondary w-full justify-center" > - - - + {getBaseTranslation('tutorial', 'subscription.connection.tutorial')} )} diff --git a/src/components/connection/blocks/AccordionBlock.tsx b/src/components/connection/blocks/AccordionBlock.tsx index fd46c36..d07bcad 100644 --- a/src/components/connection/blocks/AccordionBlock.tsx +++ b/src/components/connection/blocks/AccordionBlock.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { ChevronDownIcon } from '@/components/icons'; import { getColorGradient } from '@/utils/colorParser'; import { ThemeIcon } from './ThemeIcon'; import type { BlockRendererProps } from './types'; @@ -52,15 +53,9 @@ export function AccordionBlock({ {getLocalizedText(block.title)} - - - + /> {/* Panel */}
{/* Gift icon */}
- - - +
{/* Content */} diff --git a/src/components/dashboard/SubscriptionCardActive.tsx b/src/components/dashboard/SubscriptionCardActive.tsx index 7f947f9..6aa7be5 100644 --- a/src/components/dashboard/SubscriptionCardActive.tsx +++ b/src/components/dashboard/SubscriptionCardActive.tsx @@ -10,7 +10,7 @@ import { useTrafficZone } from '../../hooks/useTrafficZone'; import { formatTraffic } from '../../utils/formatTraffic'; import { getGlassColors } from '../../utils/glassTheme'; import { HoverBorderGradient } from '../ui/hover-border-gradient'; -import { RefreshIcon } from '@/components/icons'; +import { CalendarIcon, RefreshIcon } from '@/components/icons'; import { useHaptic } from '../../platform'; import type { Subscription } from '../../types'; @@ -309,20 +309,14 @@ export default function SubscriptionCardActive({ background: daysLeft <= 3 ? 'rgba(var(--color-warning-400), 0.1)' : g.hoverBg, }} > - + +
{t('dashboard.remaining')}
diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx index 8fec23c..5431944 100644 --- a/src/components/dashboard/SubscriptionCardExpired.tsx +++ b/src/components/dashboard/SubscriptionCardExpired.tsx @@ -10,7 +10,7 @@ import { useCurrency } from '../../hooks/useCurrency'; import { useHapticFeedback } from '../../platform/hooks/useHaptic'; import { getGlassColors } from '../../utils/glassTheme'; import { getInsufficientBalanceError } from '../../utils/subscriptionHelpers'; -import { SubscriptionIcon } from '@/components/icons'; +import { ClockIcon, ExclamationIcon, PlusIcon, SubscriptionIcon } from '@/components/icons'; interface SubscriptionCardExpiredProps { subscription: Subscription; @@ -168,39 +168,13 @@ export default function SubscriptionCardExpired({ style={{ background: `rgba(${accent.r},${accent.g},${accent.b},0.1)`, border: `1px solid rgba(${accent.r},${accent.g},${accent.b},0.15)`, + color: accent.hex, }} > {isLimited ? ( - + ) : ( - + )}

@@ -275,19 +249,7 @@ export default function SubscriptionCardExpired({ boxShadow: `0 4px 20px rgba(${accent.r},${accent.g},${accent.b},0.2)`, }} > - + {t('subscription.buyTraffic')} ) : ( @@ -330,19 +292,7 @@ export default function SubscriptionCardExpired({ boxShadow: `0 4px 20px rgba(${accent.r},${accent.g},${accent.b},0.2)`, }} > - + {t('dashboard.expired.topUp')} )} diff --git a/src/components/dashboard/TrialOfferCard.tsx b/src/components/dashboard/TrialOfferCard.tsx index 4afba16..025ca01 100644 --- a/src/components/dashboard/TrialOfferCard.tsx +++ b/src/components/dashboard/TrialOfferCard.tsx @@ -5,6 +5,7 @@ import type { TrialInfo } from '../../types'; import { useCurrency } from '../../hooks/useCurrency'; import { useTheme } from '../../hooks/useTheme'; import { getGlassColors } from '../../utils/glassTheme'; +import { BoltIcon, SparklesIcon } from '@/components/icons'; interface TrialOfferCardProps { trialInfo: TrialInfo; @@ -94,37 +95,21 @@ export default function TrialOfferCard({ }} > {isFree ? ( - + + ) : ( - + + )} {/* Glow effect */}
- +
{t('news.title')} diff --git a/src/components/subscription/PurchaseCTAButton.tsx b/src/components/subscription/PurchaseCTAButton.tsx index 1ca1402..3d3b515 100644 --- a/src/components/subscription/PurchaseCTAButton.tsx +++ b/src/components/subscription/PurchaseCTAButton.tsx @@ -1,7 +1,7 @@ import { Link } from 'react-router'; import { useTranslation } from 'react-i18next'; import { HoverBorderGradient } from '../ui/hover-border-gradient'; -import { SubscriptionIcon } from '@/components/icons'; +import { ChevronRightIcon, SubscriptionIcon } from '@/components/icons'; import type { Subscription } from '../../types'; interface PurchaseCTAButtonProps { @@ -86,20 +86,7 @@ export default function PurchaseCTAButton({
{/* Right: chevron */} - +
diff --git a/src/components/subscription/SubscriptionListCard.tsx b/src/components/subscription/SubscriptionListCard.tsx index 1f6bb14..3d4b101 100644 --- a/src/components/subscription/SubscriptionListCard.tsx +++ b/src/components/subscription/SubscriptionListCard.tsx @@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next'; import { useTheme } from '../../hooks/useTheme'; import { getGlassColors } from '../../utils/glassTheme'; import { useHaptic } from '../../platform'; +import { CalendarIcon, CheckIcon, ChevronRightIcon, DevicesIcon } from '@/components/icons'; import type { SubscriptionListItem } from '../../types'; function formatDate(iso: string | null, locale?: string): string { @@ -136,15 +137,7 @@ export default function SubscriptionListCard({

- - - +
{/* Traffic mini progress bar */} @@ -177,29 +170,11 @@ export default function SubscriptionListCard({ style={{ color: g.textSecondary }} > - - - - + {subscription.device_limit} - - - - + {formatDate(subscription.end_date, i18n.language)} {!isTrial && @@ -213,19 +188,19 @@ export default function SubscriptionListCard({ - - {enabled ? ( - - ) : ( + {enabled ? ( + + ) : ( + - )} - + + )} {label} ); diff --git a/src/components/subscription/purchase/TariffPickerGrid.tsx b/src/components/subscription/purchase/TariffPickerGrid.tsx index a22ade2..b1204ad 100644 --- a/src/components/subscription/purchase/TariffPickerGrid.tsx +++ b/src/components/subscription/purchase/TariffPickerGrid.tsx @@ -4,6 +4,7 @@ import { useTheme } from '../../../hooks/useTheme'; import { useCurrency } from '../../../hooks/useCurrency'; import { usePromoDiscount } from '../../../hooks/usePromoDiscount'; import { getGlassColors } from '../../../utils/glassTheme'; +import { ArrowDownIcon, DevicesIcon, RestartIcon } from '@/components/icons'; import type { Tariff, Subscription, PurchaseOptions } from '../../../types'; // ────────────────────────────────────────────────────────────────── @@ -169,35 +170,11 @@ export function TariffPickerGrid({
- - - + {tariff.traffic_limit_label}
- - - + {tariff.device_limit === 0 ? '∞' @@ -206,19 +183,7 @@ export function TariffPickerGrid({
{tariff.traffic_reset_mode && tariff.traffic_reset_mode !== 'NO_RESET' && (
- - - + {t(`subscription.trafficReset.${tariff.traffic_reset_mode}`)} diff --git a/src/components/subscription/sheets/DeleteSubscriptionSheet.tsx b/src/components/subscription/sheets/DeleteSubscriptionSheet.tsx index b00c46f..23243f9 100644 --- a/src/components/subscription/sheets/DeleteSubscriptionSheet.tsx +++ b/src/components/subscription/sheets/DeleteSubscriptionSheet.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { TrashIcon } from '@/components/icons'; import { subscriptionApi } from '../../../api/subscription'; import { usePlatform } from '../../../platform'; import { useDestructiveConfirm } from '../../../platform/hooks/useNativeDialog'; @@ -72,19 +73,7 @@ export function DeleteSubscriptionSheet({ disabled={deleteLoading} className="flex w-full items-center justify-center gap-2 rounded-2xl border border-error-400/20 bg-error-400/5 p-3.5 text-sm font-medium text-error-400 transition-colors hover:bg-error-400/10 disabled:opacity-50" > - - - + {t('subscription.delete', 'Удалить подписку')} ); diff --git a/src/components/tickets/MessageMediaGrid.tsx b/src/components/tickets/MessageMediaGrid.tsx index 267454c..df3496f 100644 --- a/src/components/tickets/MessageMediaGrid.tsx +++ b/src/components/tickets/MessageMediaGrid.tsx @@ -1,5 +1,8 @@ import { useState, useCallback, useEffect } from 'react'; import { createPortal } from 'react-dom'; + +import { ChevronLeftIcon, ChevronRightIcon, DocumentIcon, XIcon } from '@/components/icons'; + import { ticketsApi } from '../../api/tickets'; export interface MediaItem { @@ -150,19 +153,7 @@ export function MessageMediaGrid({ rel="noopener noreferrer" className="inline-flex items-center gap-2 rounded-lg bg-dark-700 px-3 py-2 text-sm text-dark-200 transition-colors hover:bg-dark-600" > - - - + {item.caption || `Download ${item.type}`} ); @@ -180,15 +171,7 @@ export function MessageMediaGrid({ className="absolute right-4 top-4 z-10 flex h-12 w-12 items-center justify-center rounded-full bg-white text-black shadow-xl transition-colors hover:bg-gray-200" onClick={closeFullscreen} > - - - + {photoItems.length > 1 && ( @@ -199,15 +182,7 @@ export function MessageMediaGrid({ onClick={() => setFullscreenIndex(fullscreenIndex - 1)} className="absolute left-4 top-1/2 z-10 flex h-12 w-12 -translate-y-1/2 items-center justify-center rounded-full bg-white/90 text-black shadow-xl transition-colors hover:bg-white disabled:opacity-30" > - - - +
{fullscreenIndex + 1} / {photoItems.length} diff --git a/src/pages/AdminApps.tsx b/src/pages/AdminApps.tsx index fb7abe2..a69c416 100644 --- a/src/pages/AdminApps.tsx +++ b/src/pages/AdminApps.tsx @@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { adminAppsApi } from '../api/adminApps'; import { usePlatform } from '../platform/hooks/usePlatform'; +import { BackIcon } from '@/components/icons'; export default function AdminApps() { const { t } = useTranslation(); @@ -45,15 +46,7 @@ export default function AdminApps() { onClick={() => navigate('/admin')} className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600" > - - - + )}

{t('admin.apps.title')}

diff --git a/src/pages/AdminBanSystem.tsx b/src/pages/AdminBanSystem.tsx index 62a1cbf..46d9842 100644 --- a/src/pages/AdminBanSystem.tsx +++ b/src/pages/AdminBanSystem.tsx @@ -34,6 +34,9 @@ import { TrafficIcon, ReportIcon, HealthIcon, + ExclamationIcon, + BackIcon, + XIcon, } from '@/components/icons'; type TabType = @@ -393,34 +396,10 @@ export default function AdminBanSystem() {
- - - +
- - - +
@@ -454,19 +433,7 @@ export default function AdminBanSystem() { onClick={() => window.history.back()} className="flex w-full items-center justify-center gap-2 rounded-lg border border-dark-600 bg-dark-700 px-4 py-2 text-sm font-medium text-dark-200 transition-all duration-200 hover:border-dark-500 hover:bg-dark-600 hover:text-dark-100" > - - - + {t('common.back')}
@@ -1444,14 +1411,7 @@ export default function AdminBanSystem() { aria-label={t('common.close')} className="text-dark-400 hover:text-dark-200" > - - - +
diff --git a/src/pages/AdminCampaignCreate.tsx b/src/pages/AdminCampaignCreate.tsx index a88d75b..3195831 100644 --- a/src/pages/AdminCampaignCreate.tsx +++ b/src/pages/AdminCampaignCreate.tsx @@ -13,7 +13,7 @@ import { partnerApi } from '../api/partners'; import { AdminBackButton } from '../components/admin'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; import Twemoji from 'react-twemoji'; -import { CampaignIcon, CheckIcon, RefreshIcon } from '@/components/icons'; +import { CampaignIcon, CheckIcon, LinkIcon, RefreshIcon } from '@/components/icons'; // Bonus type config const bonusTypeConfig: Record< @@ -273,19 +273,7 @@ export default function AdminCampaignCreate() { {partnerId && partner && (
- - - +

{t('admin.campaigns.form.partnerAutoAssign', { name: partner.first_name || partner.username || `#${partnerId}`, diff --git a/src/pages/AdminCampaignStats.tsx b/src/pages/AdminCampaignStats.tsx index cb2644c..e4eb79b 100644 --- a/src/pages/AdminCampaignStats.tsx +++ b/src/pages/AdminCampaignStats.tsx @@ -10,7 +10,7 @@ import { PARTNER_STATS } from '../constants/partner'; import { useCurrency } from '../hooks/useCurrency'; import { copyToClipboard } from '../utils/clipboard'; import { useHaptic } from '../platform'; -import { ChartIcon, CopyIcon, LinkIcon, UsersIcon } from '@/components/icons'; +import { ChartIcon, ChevronDownIcon, CopyIcon, LinkIcon, UsersIcon } from '@/components/icons'; // Bonus type config const bonusTypeConfig: Record< @@ -449,15 +449,9 @@ export default function AdminCampaignStats() { {t('admin.campaigns.stats.users')} ({stats.registrations})

- - - + /> {showUsers && ( diff --git a/src/pages/AdminPartnerDetail.tsx b/src/pages/AdminPartnerDetail.tsx index 20159b7..a50a50b 100644 --- a/src/pages/AdminPartnerDetail.tsx +++ b/src/pages/AdminPartnerDetail.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { partnerApi } from '../api/partners'; import { AdminBackButton } from '../components/admin'; import { useCurrency } from '../hooks/useCurrency'; +import { XIcon } from '@/components/icons'; // Status badge config — keys must match backend PartnerStatus enum values const statusConfig: Record = { @@ -259,19 +260,7 @@ export default function AdminPartnerDetail() { className="rounded p-1 text-dark-500 transition-colors hover:bg-error-500/10 hover:text-error-400" title={t('admin.partnerDetail.campaigns.unassign')} > - - - +
diff --git a/src/pages/AdminPartners.tsx b/src/pages/AdminPartners.tsx index 7aa063e..b1d07fe 100644 --- a/src/pages/AdminPartners.tsx +++ b/src/pages/AdminPartners.tsx @@ -9,6 +9,7 @@ import { } from '../api/partners'; import { AdminBackButton } from '../components/admin'; import { useCurrency } from '../hooks/useCurrency'; +import { ChevronRightIcon, SettingsIcon } from '@/components/icons'; export default function AdminPartners() { const { t } = useTranslation(); @@ -50,24 +51,7 @@ export default function AdminPartners() { className="rounded-lg bg-dark-800 p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200" title={t('admin.partners.settings')} > - - - - +
@@ -167,19 +151,7 @@ export default function AdminPartners() {
- - - +
))} diff --git a/src/pages/AdminPayments.tsx b/src/pages/AdminPayments.tsx index 7630e20..76daaab 100644 --- a/src/pages/AdminPayments.tsx +++ b/src/pages/AdminPayments.tsx @@ -6,7 +6,13 @@ import { adminPaymentsApi, type SearchStats } from '../api/adminPayments'; import { useCurrency } from '../hooks/useCurrency'; import type { PendingPayment, PaginatedResponse } from '../types'; import { usePlatform } from '../platform/hooks/usePlatform'; -import { BackIcon, SearchIcon, CalendarIcon } from '@/components/icons'; +import { + BackIcon, + SearchIcon, + CalendarIcon, + RefreshIcon, + CheckCircleIcon, +} from '@/components/icons'; interface StatusBadgeProps { status: string; @@ -210,19 +216,7 @@ export default function AdminPayments() {
@@ -574,19 +568,7 @@ export default function AdminPayments() { ) : (
- - - +
{t('admin.payments.noPayments')}
diff --git a/src/pages/AdminPolicyEdit.tsx b/src/pages/AdminPolicyEdit.tsx index e3f6504..87c8893 100644 --- a/src/pages/AdminPolicyEdit.tsx +++ b/src/pages/AdminPolicyEdit.tsx @@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { rbacApi, AccessPolicy, CreatePolicyPayload, UpdatePolicyPayload } from '@/api/rbac'; import { AdminBackButton } from '@/components/admin'; +import { XIcon } from '@/components/icons'; // === Types === @@ -150,15 +151,7 @@ function IpTagInput({ values, onChange }: IpTagInputProps) { className="text-dark-400 transition-colors hover:text-dark-200" aria-label={t('admin.policies.conditions.removeIp', { ip })} > - - - + ))} diff --git a/src/pages/AdminPromoOfferSend.tsx b/src/pages/AdminPromoOfferSend.tsx index 4e72e2e..2c8f5a2 100644 --- a/src/pages/AdminPromoOfferSend.tsx +++ b/src/pages/AdminPromoOfferSend.tsx @@ -19,6 +19,7 @@ import { UserIcon, SearchIcon, CloseIcon, + XIcon, } from '@/components/icons'; const getOfferTypeIcon = (offerType: string): string => { @@ -188,25 +189,9 @@ export default function AdminPromoOfferSend() { }`} > {result.isSuccess ? ( - - - + ) : ( - - - + )}

{result.title}

diff --git a/src/pages/AdminTariffs.tsx b/src/pages/AdminTariffs.tsx index f92fb2d..dc139c2 100644 --- a/src/pages/AdminTariffs.tsx +++ b/src/pages/AdminTariffs.tsx @@ -105,19 +105,7 @@ function SortableTariffCard({ )} {tariff.show_in_gift && ( - - - + {t('admin.tariffs.giftBadge')} )} diff --git a/src/pages/AdminTickets.tsx b/src/pages/AdminTickets.tsx index 73eaa58..d33bc9d 100644 --- a/src/pages/AdminTickets.tsx +++ b/src/pages/AdminTickets.tsx @@ -9,7 +9,7 @@ import { adminApi, AdminTicket, AdminTicketDetail } from '../api/admin'; import { ticketsApi } from '../api/tickets'; import { copyToClipboard as copyText } from '../utils/clipboard'; import { usePlatform } from '../platform/hooks/usePlatform'; -import { BackIcon } from '@/components/icons'; +import { BackIcon, SettingsIcon, TicketIcon, XIcon } from '@/components/icons'; interface MediaAttachment { id: string; @@ -270,24 +270,7 @@ export default function AdminTickets() { onClick={() => navigate('/admin/tickets/settings')} className="btn-secondary flex items-center gap-2" > - - - - + {t('admin.tickets.settings')}
@@ -433,19 +416,7 @@ export default function AdminTickets() { {!selectedTicketId ? (
- - - +
{t('admin.tickets.selectTicket')}
@@ -593,19 +564,7 @@ export default function AdminTickets() { onClick={() => removeAttachment(idx)} className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-full bg-dark-600 text-dark-300 hover:bg-error-500 hover:text-white" > - - - +
))} diff --git a/src/pages/AdminWithdrawalDetail.tsx b/src/pages/AdminWithdrawalDetail.tsx index 6dbb260..b292bc3 100644 --- a/src/pages/AdminWithdrawalDetail.tsx +++ b/src/pages/AdminWithdrawalDetail.tsx @@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { withdrawalApi } from '../api/withdrawals'; import { AdminBackButton } from '../components/admin'; +import { WarningIcon } from '@/components/icons'; import { useCurrency } from '../hooks/useCurrency'; import { formatDate, @@ -237,19 +238,7 @@ export default function AdminWithdrawalDetail() { key={index} className="flex items-start gap-2 rounded-lg bg-error-500/10 px-3 py-2" > - - - + {flag}
))} diff --git a/src/pages/AdminWithdrawals.tsx b/src/pages/AdminWithdrawals.tsx index a09814a..2af61d6 100644 --- a/src/pages/AdminWithdrawals.tsx +++ b/src/pages/AdminWithdrawals.tsx @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { withdrawalApi, AdminWithdrawalItem } from '../api/withdrawals'; import { AdminBackButton } from '../components/admin'; +import { ChevronRightIcon } from '@/components/icons'; import { useCurrency } from '../hooks/useCurrency'; import { formatDate, getWithdrawalStatusBadge, getRiskColor } from '../utils/withdrawalUtils'; @@ -150,19 +151,7 @@ export default function AdminWithdrawals() {
{/* Chevron right */} - - - +
); diff --git a/src/pages/AutoLogin.tsx b/src/pages/AutoLogin.tsx index 0587951..44731dd 100644 --- a/src/pages/AutoLogin.tsx +++ b/src/pages/AutoLogin.tsx @@ -3,6 +3,7 @@ import { useSearchParams, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { authApi } from '../api/auth'; import { useAuthStore } from '../store/auth'; +import { XIcon } from '@/components/icons'; export default function AutoLogin() { const { t } = useTranslation(); @@ -51,15 +52,7 @@ export default function AutoLogin() { {error ? (
- - - +

{t('landing.autoLoginFailed')}

diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index dcc1202..18db455 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -21,7 +21,7 @@ import { promoApi } from '../api/promo'; import PendingGiftCard from '../components/dashboard/PendingGiftCard'; import SubscriptionListCard from '../components/subscription/SubscriptionListCard'; import { API } from '../config/constants'; -import { ChevronRightIcon } from '@/components/icons'; +import { ChevronRightIcon, StarIcon } from '@/components/icons'; export default function Dashboard() { const { t } = useTranslation(); @@ -263,16 +263,7 @@ export default function Dashboard() { color: 'rgb(var(--color-accent-400))', }} > - + {promoGroupData.group_name} )} diff --git a/src/pages/DeepLinkRedirect.tsx b/src/pages/DeepLinkRedirect.tsx index f77317e..7422e35 100644 --- a/src/pages/DeepLinkRedirect.tsx +++ b/src/pages/DeepLinkRedirect.tsx @@ -4,6 +4,13 @@ import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { brandingApi } from '../api/branding'; import { copyToClipboard } from '../utils/clipboard'; +import { + CheckIcon, + CopyIcon, + ExclamationIcon, + ExternalLinkIcon, + LinkIcon, +} from '@/components/icons'; type Status = 'countdown' | 'fallback' | 'error'; @@ -209,19 +216,7 @@ export default function DeepLinkRedirect() { onClick={openDeepLink} className="btn-primary flex w-full items-center justify-center gap-2 py-3" > - - - + {t('deepLink.openApp')}
@@ -243,36 +238,12 @@ export default function DeepLinkRedirect() { > {copied ? ( <> - - - + {t('deepLink.copied')} ) : ( <> - - - + {t('deepLink.copyLink')} )} @@ -283,19 +254,7 @@ export default function DeepLinkRedirect() { onClick={openDeepLink} className="btn-secondary flex w-full items-center justify-center gap-2" > - - - + {t('deepLink.tryAgain')} @@ -327,19 +286,7 @@ export default function DeepLinkRedirect() { {status === 'error' && (
- - - +

{t('deepLink.errorTitle')}

{t('deepLink.errorDesc')}

@@ -351,19 +298,7 @@ export default function DeepLinkRedirect() { {/* Footer */}
- - - + VPN Config Redirect
diff --git a/src/pages/GiftResult.tsx b/src/pages/GiftResult.tsx index a9f2bb8..6b78350 100644 --- a/src/pages/GiftResult.tsx +++ b/src/pages/GiftResult.tsx @@ -9,6 +9,7 @@ import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark'; import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark'; import { cn } from '@/lib/utils'; import { copyToClipboard } from '@/utils/clipboard'; +import { CheckIcon, CopyIcon, InfoIcon, ExclamationIcon, ClockIcon } from '@/components/icons'; const MAX_POLL_MS = 10 * 60 * 1000; // 10 minutes @@ -147,32 +148,12 @@ function CodeOnlySuccessState({ > {copied ? ( <> - - - + {t('common.copied', 'Copied!')} ) : ( <> - - - + {t('gift.copyMessage', 'Copy message')} )} @@ -272,19 +253,7 @@ function PendingActivationState({ > {/* Info icon */}
- - - +
@@ -372,19 +341,7 @@ function PollErrorState() { className="flex flex-col items-center gap-6 text-center" >
- - - +
@@ -420,19 +377,7 @@ function PollTimedOutState({ onRetry }: { onRetry: () => void }) { className="flex flex-col items-center gap-6 text-center" >
- - - +

@@ -467,19 +412,7 @@ function NoTokenState() { className="flex flex-col items-center gap-6 text-center" >
- - - +

{t('gift.noToken', 'Invalid link')}

diff --git a/src/pages/GiftSubscription.tsx b/src/pages/GiftSubscription.tsx index 151d5cb..d1e4650 100644 --- a/src/pages/GiftSubscription.tsx +++ b/src/pages/GiftSubscription.tsx @@ -21,6 +21,7 @@ import { getApiErrorMessage } from '../utils/api-error'; import { formatPrice } from '../utils/format'; import { useCurrency } from '../hooks/useCurrency'; import { usePlatform, useHaptic } from '@/platform'; +import { SparklesIcon } from '@/components/icons'; function GiftIcon({ className }: { className?: string }) { return ( @@ -686,17 +687,7 @@ function BuyTabContent({ {config.promo_group_name && (
- - - +
@@ -713,17 +704,7 @@ function BuyTabContent({ {config.active_discount_percent != null && config.active_discount_percent > 0 && (
- - - +
{t('promo.discountApplied')} -{config.active_discount_percent}% diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 7cf03f9..4fa0b0d 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -23,6 +23,7 @@ import TelegramLoginButton from '../components/TelegramLoginButton'; import OAuthProviderIcon from '../components/OAuthProviderIcon'; import { saveOAuthState } from '../utils/oauth'; import { getPendingReferralCode } from '../utils/referral'; +import { UsersIcon, EmailIcon, RefreshIcon, ChevronDownIcon } from '@/components/icons'; export default function Login() { const { t } = useTranslation(); @@ -362,19 +363,7 @@ export default function Login() { {referralCode && isEmailAuthEnabled && (
- - - + {t('auth.referralInvite')}
@@ -385,19 +374,7 @@ export default function Login() { {registeredEmail ? (
- - - +

{t('auth.checkEmail', 'Check your email')} @@ -447,19 +424,7 @@ export default function Login() { onClick={handleRetryTelegramAuth} className="btn-primary mx-auto flex items-center gap-2 px-5 py-2.5" > - - - + {t('auth.tryAgain')}

@@ -516,29 +481,11 @@ export default function Login() { onClick={() => setShowEmailForm(!showEmailForm)} className="flex items-center gap-1.5 rounded-full border border-dark-700 bg-dark-800/60 px-3.5 py-1.5 text-xs font-medium text-dark-300 transition-all hover:border-dark-600 hover:bg-dark-700 hover:text-dark-200" > - - - + {t('auth.loginWithEmail')} - - - + />

@@ -557,19 +504,7 @@ export default function Login() { forgotPasswordSent ? (
- - - +

{t('auth.checkEmail', 'Check your email')} diff --git a/src/pages/OAuthCallback.tsx b/src/pages/OAuthCallback.tsx index 42aaa21..1f36b59 100644 --- a/src/pages/OAuthCallback.tsx +++ b/src/pages/OAuthCallback.tsx @@ -11,6 +11,7 @@ import { getErrorDetail, } from '../utils/oauth'; import type { ServerCompleteResponse } from '../types'; +import { CheckIcon, ExclamationIcon } from '@/components/icons'; type CallbackMode = 'login' | 'link-browser' | 'link-server'; @@ -145,15 +146,7 @@ export default function OAuthCallback() {

- - - +

{t('profile.accounts.linkSuccess')} @@ -209,19 +202,7 @@ export default function OAuthCallback() {
- - - +

{t('auth.loginFailed')}

{error}

diff --git a/src/pages/Polls.tsx b/src/pages/Polls.tsx index f71c4d1..2b46fba 100644 --- a/src/pages/Polls.tsx +++ b/src/pages/Polls.tsx @@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { pollsApi, PollInfo, PollQuestion } from '../api/polls'; import { useFocusTrap } from '../hooks/useFocusTrap'; -import { ClipboardIcon, GiftIcon, CheckIcon } from '@/components/icons'; +import { ClipboardIcon, GiftIcon, CheckIcon, CloseIcon } from '@/components/icons'; export default function Polls() { const { t } = useTranslation(); @@ -135,14 +135,7 @@ export default function Polls() { aria-label={t('common.close')} className="text-dark-400 hover:text-dark-200" > - - - +
diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index bb12b92..fe82370 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -319,15 +319,7 @@ export default function Profile() {

{t('profile.accounts.subtitle')}

- - - +
diff --git a/src/pages/PurchaseSuccess.tsx b/src/pages/PurchaseSuccess.tsx index 0f27b1a..8afddd9 100644 --- a/src/pages/PurchaseSuccess.tsx +++ b/src/pages/PurchaseSuccess.tsx @@ -8,6 +8,7 @@ import { landingApi } from '../api/landings'; import { authApi } from '../api/auth'; import { useAuthStore } from '../store/auth'; import { copyToClipboard } from '../utils/clipboard'; +import { CheckIcon, ClipboardIcon, ClockIcon, ExclamationIcon } from '@/components/icons'; import { Spinner } from '@/components/ui/Spinner'; import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark'; import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark'; @@ -299,32 +300,12 @@ function SuccessState({ > {copied ? ( <> - - - + {t('landing.copied', 'Copied!')} ) : ( <> - - - + {t('landing.copyLink', 'Copy link')} )} @@ -383,19 +364,7 @@ function PendingActivationState({ > {/* Warning icon */}
- - - +
@@ -560,19 +529,7 @@ function PollTimedOutState({ onRetry }: { onRetry: () => void }) { className="flex flex-col items-center gap-6 text-center" >
- - - +

diff --git a/src/pages/QuickPurchase.tsx b/src/pages/QuickPurchase.tsx index c373ee6..6504a8a 100644 --- a/src/pages/QuickPurchase.tsx +++ b/src/pages/QuickPurchase.tsx @@ -15,6 +15,7 @@ import type { PurchaseRequest, } from '../api/landings'; import { StaticBackgroundRenderer } from '../components/backgrounds/BackgroundRenderer'; +import { CheckCircleIcon, CheckIcon, DevicesIcon, DownloadIcon } from '@/components/icons'; import LanguageSwitcher from '../components/LanguageSwitcher'; import { cn } from '../lib/utils'; import { getApiErrorMessage } from '../utils/api-error'; @@ -285,52 +286,18 @@ function TariffCard({ isSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600', )} > - {isSelected && ( - - - - )} + {isSelected && }

{/* Info row */}
- - - + {tariff.traffic_limit_gb === 0 ? '∞' : tariff.traffic_limit_gb} {t('landing.gb', 'GB')} - - - + {tariff.device_limit} {t('landing.devices', 'devices')}
@@ -550,15 +517,7 @@ function SummaryCard({ {config.features.map((feature, idx) => (
- - - +

{feature.title}

diff --git a/src/pages/Referral.tsx b/src/pages/Referral.tsx index eac094a..e669e67 100644 --- a/src/pages/Referral.tsx +++ b/src/pages/Referral.tsx @@ -14,9 +14,12 @@ import { CheckIcon, ClockIcon, CopyIcon, + ExclamationIcon, LinkIcon, PartnerIcon, ShareIcon, + TelegramIcon, + UsersIcon, WalletIcon, } from '@/components/icons'; @@ -212,19 +215,7 @@ export default function Referral() { return (
- - - +

{t('referral.title')}

@@ -273,9 +264,7 @@ export default function Referral() { {botReferralLink && (
- - - + {t('referral.botLink')}
@@ -302,19 +291,7 @@ export default function Referral() { {/* Cabinet link */}
- - - + {t('referral.cabinetLink')}
@@ -383,19 +360,7 @@ export default function Referral() { ) : (
- - - +
{t('referral.noReferrals')}
@@ -519,19 +484,7 @@ export default function Referral() {
- - - +

diff --git a/src/pages/ReferralNetwork/components/CampaignDetailPanel.tsx b/src/pages/ReferralNetwork/components/CampaignDetailPanel.tsx index 84c1ac6..2661911 100644 --- a/src/pages/ReferralNetwork/components/CampaignDetailPanel.tsx +++ b/src/pages/ReferralNetwork/components/CampaignDetailPanel.tsx @@ -1,5 +1,6 @@ import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; +import { CloseIcon } from '@/components/icons'; import { referralNetworkApi } from '@/api/referralNetwork'; import { useReferralNetworkStore } from '@/store/referralNetwork'; import { formatKopeksToRubles } from '../utils'; @@ -37,15 +38,7 @@ export function CampaignDetailPanel({ campaignId, className }: CampaignDetailPan className="rounded-lg p-1 text-dark-500 transition-colors hover:bg-dark-800 hover:text-dark-300" aria-label={t('common.close')} > - - - +

diff --git a/src/pages/ReferralNetwork/components/NetworkControls.tsx b/src/pages/ReferralNetwork/components/NetworkControls.tsx index fbd4478..2bab30c 100644 --- a/src/pages/ReferralNetwork/components/NetworkControls.tsx +++ b/src/pages/ReferralNetwork/components/NetworkControls.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { MinusIcon, PlusIcon } from '@/components/icons'; import { getSigmaInstance } from '../sigmaGlobals'; interface NetworkControlsProps { @@ -62,32 +63,12 @@ export function NetworkControls({ className }: NetworkControlsProps) { { label: t('admin.referralNetwork.controls.zoomIn'), onClick: handleZoomIn, - icon: ( - - - - ), + icon: , }, { label: t('admin.referralNetwork.controls.zoomOut'), onClick: handleZoomOut, - icon: ( - - - - ), + icon: , }, { label: t('admin.referralNetwork.controls.resetZoom'), diff --git a/src/pages/ReferralNetwork/components/NetworkFilters.tsx b/src/pages/ReferralNetwork/components/NetworkFilters.tsx index 94c53d3..5ac1c6f 100644 --- a/src/pages/ReferralNetwork/components/NetworkFilters.tsx +++ b/src/pages/ReferralNetwork/components/NetworkFilters.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { CloseIcon, FilterIcon } from '@/components/icons'; import { useReferralNetworkStore } from '@/store/referralNetwork'; import type { NetworkGraphData } from '@/types/referralNetwork'; @@ -120,19 +121,7 @@ export function NetworkFilters({ data, className }: NetworkFiltersProps) { : 'border-dark-700/50 bg-dark-800/80 text-dark-300 hover:border-dark-600 hover:text-dark-100' }`} > - - - + {t('admin.referralNetwork.filters.title')} {hasActiveFilters && ( @@ -152,15 +141,7 @@ export function NetworkFilters({ data, className }: NetworkFiltersProps) { aria-label={t('common.close')} className="text-dark-500 transition-colors hover:text-dark-300" > - - - +
{panelContent} @@ -182,15 +163,7 @@ export function NetworkFilters({ data, className }: NetworkFiltersProps) { aria-label={t('common.close')} className="rounded-lg p-1 text-dark-500 transition-colors hover:bg-dark-800 hover:text-dark-300" > - - - +
{panelContent} diff --git a/src/pages/ReferralNetwork/components/ScopeSelector.tsx b/src/pages/ReferralNetwork/components/ScopeSelector.tsx index 00d453f..fd35b6e 100644 --- a/src/pages/ReferralNetwork/components/ScopeSelector.tsx +++ b/src/pages/ReferralNetwork/components/ScopeSelector.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import { referralNetworkApi } from '@/api/referralNetwork'; +import { CheckIcon, CloseIcon, PlusIcon, SearchIcon } from '@/components/icons'; import { MAX_SCOPE_ITEMS } from '@/store/referralNetwork'; import type { ScopeSelection, ScopeType } from '@/types/referralNetwork'; @@ -30,28 +31,6 @@ const AVATAR_LETTERS: Record = { user: 'U', }; -function CheckIcon() { - return ( - - - - ); -} - -function CloseIcon({ className = 'h-3 w-3' }: { className?: string }) { - return ( - - - - ); -} - function Spinner({ size = 'h-5 w-5' }: { size?: string }) { return (
- + ))} @@ -273,15 +252,7 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc }`} disabled={isMaxReached && !isDropdownOpen} > - - - +
@@ -323,19 +294,7 @@ export function ScopeSelector({ value, onAdd, onRemove, onClear, className }: Sc
- - - + - - - +
diff --git a/src/pages/ResetPassword.tsx b/src/pages/ResetPassword.tsx index 97f1a05..9863fef 100644 --- a/src/pages/ResetPassword.tsx +++ b/src/pages/ResetPassword.tsx @@ -3,6 +3,7 @@ import { useSearchParams, Link, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { authApi } from '../api/auth'; import LanguageSwitcher from '../components/LanguageSwitcher'; +import { CheckIcon } from '@/components/icons'; export default function ResetPassword() { const { t } = useTranslation(); @@ -97,15 +98,7 @@ export default function ResetPassword() { {status === 'success' ? (
- - - +

{t('resetPassword.success', 'Password changed!')} diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 6d7a2c1..cc5017c 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -17,7 +17,16 @@ import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'; import { useCurrency } from '../hooks/useCurrency'; import { useCloseOnSuccessNotification } from '../store/successNotification'; import PurchaseCTAButton from '../components/subscription/PurchaseCTAButton'; -import { CopyIcon, CheckIcon, PauseIcon } from '../components/icons'; +import { + CopyIcon, + CheckIcon, + PauseIcon, + CalendarIcon, + RefreshIcon, + DevicesIcon, + DownloadIcon, + TrashIcon, +} from '../components/icons'; import { useHaptic } from '../platform'; import { resolveConnectionUrlForUi } from '../utils/connectionLink'; import { @@ -97,26 +106,17 @@ const CountdownTimer = memo(function CountdownTimer({ : g.hoverBg, }} > - + +

{t('dashboard.remaining')}
@@ -774,20 +774,10 @@ export default function Subscription() { disabled={refreshTrafficMutation.isPending || trafficRefreshCooldown > 0} className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-medium text-dark-50/30 transition-colors hover:bg-dark-50/[0.05] hover:text-dark-50/50 disabled:cursor-not-allowed disabled:opacity-50" > - + {trafficRefreshCooldown > 0 ? `${trafficRefreshCooldown}s` : t('common.refresh')} @@ -827,23 +817,9 @@ export default function Subscription() { >
- +
@@ -1000,21 +976,9 @@ export default function Subscription() {
- +
{purchase.traffic_gb} {t('common.units.gb')} @@ -1121,23 +1085,9 @@ export default function Subscription() { >
- +
{t('subscription.noSubscription')}
diff --git a/src/pages/SubscriptionPurchase.tsx b/src/pages/SubscriptionPurchase.tsx index 28be245..d5e1387 100644 --- a/src/pages/SubscriptionPurchase.tsx +++ b/src/pages/SubscriptionPurchase.tsx @@ -12,6 +12,7 @@ import { SwitchTariffSheet } from '../components/subscription/sheets/SwitchTarif import { TariffPurchaseForm } from '../components/subscription/purchase/TariffPurchaseForm'; import { TariffPickerGrid } from '../components/subscription/purchase/TariffPickerGrid'; import { ClassicPurchaseWizard } from '../components/subscription/purchase/ClassicPurchaseWizard'; +import { ExclamationIcon, SparklesIcon } from '@/components/icons'; export default function SubscriptionPurchase() { const { t } = useTranslation(); @@ -177,23 +178,12 @@ export default function SubscriptionPurchase() {
- +
- +
void }) { className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl" style={{ background: g.innerBg }} > - - - +

{t('subscriptions.empty', 'Нет подписок')} @@ -128,15 +117,7 @@ export default function Subscriptions() { border: '1px solid rgba(var(--color-accent-400), 0.2)', }} > - - - + {t('subscriptions.buyAnother', 'Новый тариф')} )} diff --git a/src/pages/Support.tsx b/src/pages/Support.tsx index 4e0c67c..5d44498 100644 --- a/src/pages/Support.tsx +++ b/src/pages/Support.tsx @@ -12,7 +12,7 @@ import type { TicketDetail } from '../types'; import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; -import { CloseIcon, ImageIcon, PlusIcon, SendIcon } from '@/components/icons'; +import { ChatIcon, CloseIcon, ImageIcon, PlusIcon, SendIcon } from '@/components/icons'; import { usePlatform } from '@/platform'; import { linkifyText } from '../utils/linkify'; @@ -266,19 +266,7 @@ export default function Support() {
- - - +

{supportMessage.title}

{supportMessage.message}

@@ -366,19 +354,7 @@ export default function Support() {
- - - +
{t('support.contactUs')}
@@ -440,19 +416,7 @@ export default function Support() { ) : (
- - - +
{t('support.noTickets')}
diff --git a/src/pages/TelegramRedirect.tsx b/src/pages/TelegramRedirect.tsx index f7583ed..6c05007 100644 --- a/src/pages/TelegramRedirect.tsx +++ b/src/pages/TelegramRedirect.tsx @@ -8,6 +8,7 @@ import { brandingApi } from '../api/branding'; import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK'; import { tokenStorage } from '../utils/token'; import { getSafeRedirectPath } from '../utils/safeRedirect'; +import { CheckIcon, XIcon, ExclamationIcon } from '@/components/icons'; const MAX_RETRY_ATTEMPTS = 3; const RETRY_COUNT_KEY = 'telegram_redirect_retry_count'; @@ -158,15 +159,7 @@ export default function TelegramRedirect() { {status === 'success' && (
- - - +

{t('auth.loginSuccess')}

{t('telegramRedirect.redirecting')}

@@ -177,15 +170,7 @@ export default function TelegramRedirect() { {status === 'error' && (
- - - +

{t('auth.loginFailed')}

{errorMessage}

@@ -204,19 +189,7 @@ export default function TelegramRedirect() { {status === 'not-telegram' && (
- - - +

{t('telegramRedirect.openInTelegram')}

{t('telegramRedirect.openInTelegramDesc')}

diff --git a/src/pages/TopUpAmount.tsx b/src/pages/TopUpAmount.tsx index d0ef21e..a4cc3d7 100644 --- a/src/pages/TopUpAmount.tsx +++ b/src/pages/TopUpAmount.tsx @@ -20,6 +20,7 @@ import { CheckIcon, CopyIcon, CryptoIcon, + ExclamationIcon, ExternalLinkIcon, SparklesIcon, StarIcon, @@ -520,19 +521,7 @@ export default function TopUpAmount() { variants={staggerItem} className="flex items-center gap-2 rounded-xl border border-error-500/20 bg-error-500/10 p-3" > - - - + {error} )} From 9aae399ce82b8bf95eb739e1406508f89a40b097 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Sun, 31 May 2026 23:43:32 +0300 Subject: [PATCH 16/37] fix(cabinet-icons): correct two mismatched glyphs from the svg migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A personal glyph-by-glyph re-review of the migration (the "steering wheel" guard) found two wrong matches: - MaintenanceScreen: a wrench/maintenance svg had been mapped to AdjustmentsIcon (sliders) → use a proper WrenchIcon (PiWrench). - InstallationGuide tutorial button: an open-book svg had been mapped to DocumentIcon (file) → use BookOpenIcon (PiBookOpen). Add WrenchIcon and BookOpenIcon to the central barrel. All other replacements were verified correct (paper-plane→Send, server→HardDrives, warning-triangle→ Warning, etc.). --- src/components/blocking/MaintenanceScreen.tsx | 4 ++-- src/components/connection/InstallationGuide.tsx | 4 ++-- src/components/icons/extended-icons.tsx | 10 ++++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/components/blocking/MaintenanceScreen.tsx b/src/components/blocking/MaintenanceScreen.tsx index 2326a33..00b392b 100644 --- a/src/components/blocking/MaintenanceScreen.tsx +++ b/src/components/blocking/MaintenanceScreen.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next'; import { useBlockingStore } from '../../store/blocking'; import { useFocusTrap } from '../../hooks/useFocusTrap'; -import { AdjustmentsIcon } from '@/components/icons'; +import { WrenchIcon } from '@/components/icons'; export default function MaintenanceScreen() { const { t } = useTranslation(); @@ -21,7 +21,7 @@ export default function MaintenanceScreen() { {/* Icon */}
- +
diff --git a/src/components/connection/InstallationGuide.tsx b/src/components/connection/InstallationGuide.tsx index 8e79c74..05aa6cc 100644 --- a/src/components/connection/InstallationGuide.tsx +++ b/src/components/connection/InstallationGuide.tsx @@ -12,7 +12,7 @@ import { useTheme } from '@/hooks/useTheme'; import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock, BlockButtons } from './blocks'; import type { BlockRendererProps } from './blocks'; import TvQuickConnect from './TvQuickConnect'; -import { BackIcon, ChevronIcon, DocumentIcon } from '@/components/icons'; +import { BackIcon, BookOpenIcon, ChevronIcon } from '@/components/icons'; const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']; @@ -307,7 +307,7 @@ export default function InstallationGuide({ rel="noopener noreferrer" className="btn-secondary w-full justify-center" > - + {getBaseTranslation('tutorial', 'subscription.connection.tutorial')} )} diff --git a/src/components/icons/extended-icons.tsx b/src/components/icons/extended-icons.tsx index af336d9..c6424db 100644 --- a/src/components/icons/extended-icons.tsx +++ b/src/components/icons/extended-icons.tsx @@ -1,5 +1,7 @@ import { PiSlidersHorizontal, + PiWrench, + PiBookOpen, PiHeadset, PiArrowDown, PiArrowRight, @@ -90,6 +92,14 @@ export const AdjustmentsIcon = ({ className }: IconProps) => ( ); +export const WrenchIcon = ({ className }: IconProps) => ( + +); + +export const BookOpenIcon = ({ className }: IconProps) => ( + +); + export const AgentIcon = ({ className }: IconProps) => ( ); From 872f73157faff53dc75d6689c1ea75d5e4150d6f Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 13:08:06 +0300 Subject: [PATCH 17/37] feat(icons): add node/gift glyphs to the central barrel Add Key, Inbox, Export, WarningCircle, Heartbeat, CalendarBlank, CalendarStar, ChartPie, ChartDonut, Cpu, Memory, Pulse as Phosphor Regular wrappers so feature pages stop hand-writing SVGs or importing react-icons/pi directly. --- src/components/icons/extended-icons.tsx | 55 +++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/components/icons/extended-icons.tsx b/src/components/icons/extended-icons.tsx index c6424db..104eae1 100644 --- a/src/components/icons/extended-icons.tsx +++ b/src/components/icons/extended-icons.tsx @@ -73,6 +73,17 @@ import { PiVideoCamera, PiXCircle, PiX, + PiKey, + PiTray, + PiExport, + PiWarningCircle, + PiCalendarBlank, + PiCalendarStar, + PiChartPie, + PiChartDonut, + PiCpu, + PiMemory, + PiPulse, } from 'react-icons/pi'; import { cn } from '@/lib/utils'; @@ -120,6 +131,50 @@ export const BanIcon = ({ className }: IconProps) => ( ); +export const KeyIcon = ({ className }: IconProps) => ; + +export const InboxIcon = ({ className }: IconProps) => ( + +); + +export const ExportIcon = ({ className }: IconProps) => ( + +); + +export const WarningCircleIcon = ({ className }: IconProps) => ( + +); + +export const HeartbeatIcon = ({ className }: IconProps) => ( + +); + +export const CalendarBlankIcon = ({ className }: IconProps) => ( + +); + +export const CalendarStarIcon = ({ className }: IconProps) => ( + +); + +export const ChartPieIcon = ({ className }: IconProps) => ( + +); + +export const ChartDonutIcon = ({ className }: IconProps) => ( + +); + +export const CpuIcon = ({ className }: IconProps) => ; + +export const MemoryIcon = ({ className }: IconProps) => ( + +); + +export const PulseIcon = ({ className }: IconProps) => ( + +); + export const BanknotesIcon = ({ className }: IconProps) => ( ); From 6ea767253f88796f50a9e0aad8d5363033a0fba3 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 13:08:18 +0300 Subject: [PATCH 18/37] fix(gift): working bot deep-link + desktop layout + barrel icons - Build the share deep-link as t.me/?start=GIFT_ (literal underscore). The old GIFT%5F encoding was passed to the bot verbatim by Telegram, so its startswith('GIFT_') never matched and activation silently failed. - Source the bot username from the runtime branding config (env var is only a fallback), so the 'activate via bot' line no longer vanishes on builds without VITE_TELEGRAM_BOT_USERNAME. - Widen the page on desktop (max-w-2xl) and show sent gifts in a 2-col grid; it was cramped to a mobile column. - Replace the remaining hand-written SVG icons with the central barrel. --- src/pages/GiftResult.tsx | 23 ++++- src/pages/GiftSubscription.tsx | 179 +++++++-------------------------- 2 files changed, 53 insertions(+), 149 deletions(-) diff --git a/src/pages/GiftResult.tsx b/src/pages/GiftResult.tsx index 6b78350..2fea2d8 100644 --- a/src/pages/GiftResult.tsx +++ b/src/pages/GiftResult.tsx @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; import { giftApi } from '../api/gift'; +import { brandingApi, type TelegramWidgetConfig } from '../api/branding'; import { Spinner } from '@/components/ui/Spinner'; import { AnimatedCheckmark } from '@/components/ui/AnimatedCheckmark'; import { AnimatedCrossmark } from '@/components/ui/AnimatedCrossmark'; @@ -54,13 +55,25 @@ function CodeOnlySuccessState({ const navigate = useNavigate(); const [copied, setCopied] = useState(false); + // Bot username comes from the runtime branding config; the build-time env var + // is only a fallback. Otherwise the "activate via bot" line silently vanishes + // on deployments that don't set VITE_TELEGRAM_BOT_USERNAME at build time. + const { data: widgetConfig } = useQuery({ + queryKey: ['telegram-widget-config'], + queryFn: brandingApi.getTelegramWidgetConfig, + staleTime: 60000, + }); + const botUsername = + widgetConfig?.bot_username || import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; + const shortCode = purchaseToken.slice(0, 12); const giftCode = `GIFT-${shortCode}`; - const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME as string | undefined; - // Encode underscores as %5F so Telegram auto-link detection doesn't strip them - const safeCode = shortCode.replace(/_/g, '%5F'); - const botLink = botUsername ? `https://t.me/${botUsername}?start=GIFT%5F${safeCode}` : null; - const cabinetLink = `${window.location.origin}/gift?tab=activate&code=${safeCode}`; + // Telegram forwards the start parameter to the bot verbatim (no URL-decoding), + // and "_" is a valid start-param char — so use a literal "GIFT_" prefix to + // match the bot's `start_parameter.startswith('GIFT_')` handler. Encoding the + // underscore as %5F made the bot receive "GIFT%5F…" and silently fail. + const botLink = botUsername ? `https://t.me/${botUsername}?start=GIFT_${shortCode}` : null; + const cabinetLink = `${window.location.origin}/gift?tab=activate&code=${encodeURIComponent(shortCode)}`; const fullMessage = [ t('gift.shareText', 'I have a gift for you! Activate it here:'), diff --git a/src/pages/GiftSubscription.tsx b/src/pages/GiftSubscription.tsx index d1e4650..9e4fe4c 100644 --- a/src/pages/GiftSubscription.tsx +++ b/src/pages/GiftSubscription.tsx @@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { motion, AnimatePresence } from 'framer-motion'; import { giftApi } from '../api/gift'; +import { brandingApi, type TelegramWidgetConfig } from '../api/branding'; import type { GiftConfig, GiftTariff, @@ -21,113 +22,17 @@ import { getApiErrorMessage } from '../utils/api-error'; import { formatPrice } from '../utils/format'; import { useCurrency } from '../hooks/useCurrency'; import { usePlatform, useHaptic } from '@/platform'; -import { SparklesIcon } from '@/components/icons'; - -function GiftIcon({ className }: { className?: string }) { - return ( - - - - - - - - ); -} - -function CheckIcon({ className }: { className?: string }) { - return ( - - - - ); -} - -function ShareIcon({ className }: { className?: string }) { - return ( - - - - - - ); -} - -function CheckCircleIcon({ className }: { className?: string }) { - return ( - - - - - ); -} - -function KeyIcon({ className }: { className?: string }) { - return ( - - - - - - ); -} - -function InboxIcon({ className }: { className?: string }) { - return ( - - - - - ); -} +import { + SparklesIcon, + GiftIcon, + CheckIcon, + CheckCircleIcon, + KeyIcon, + InboxIcon, + ExportIcon, + WarningCircleIcon, + BanIcon, +} from '@/components/icons'; function formatPeriodLabel( days: number, @@ -194,19 +99,7 @@ function ErrorState({ message }: { message: string }) {
- - - +

{t('gift.failedTitle')}

{message}

@@ -228,19 +121,7 @@ function DisabledState() {
- - - +

{t('gift.featureDisabled')}

{t('gift.redirecting')}

@@ -991,6 +872,16 @@ function SentGiftCard({ gift }: { gift: SentGift }) { const { t } = useTranslation(); const [showToast, setShowToast] = useState(false); + // Runtime bot username (env var is only a fallback) so the "activate via bot" + // line never silently disappears when VITE_TELEGRAM_BOT_USERNAME is unset. + const { data: widgetConfig } = useQuery({ + queryKey: ['telegram-widget-config'], + queryFn: brandingApi.getTelegramWidgetConfig, + staleTime: 60000, + }); + const botUsername = + widgetConfig?.bot_username || import.meta.env.VITE_TELEGRAM_BOT_USERNAME || ''; + const shortCode = gift.token.slice(0, 12); const giftCode = `GIFT-${shortCode}`; const isActivated = isGiftActivated(gift); @@ -1003,11 +894,11 @@ function SentGiftCard({ gift }: { gift: SentGift }) { : t(getGiftStatusKey(gift.status)); const buildShareMessage = useCallback(() => { - const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME as string | undefined; - // Encode underscores as %5F so Telegram auto-link detection doesn't strip them - const safeCode = shortCode.replace(/_/g, '%5F'); - const botLink = botUsername ? `https://t.me/${botUsername}?start=GIFT%5F${safeCode}` : null; - const cabinetLink = `${window.location.origin}/gift?tab=activate&code=${safeCode}`; + // Literal "GIFT_" prefix: Telegram forwards the start param to the bot + // verbatim (no URL-decoding), so the previously-encoded "%5F" never matched + // the bot's `start_parameter.startswith('GIFT_')` handler. + const botLink = botUsername ? `https://t.me/${botUsername}?start=GIFT_${shortCode}` : null; + const cabinetLink = `${window.location.origin}/gift?tab=activate&code=${encodeURIComponent(shortCode)}`; return [ t('gift.shareText'), '', @@ -1016,7 +907,7 @@ function SentGiftCard({ gift }: { gift: SentGift }) { ] .filter(Boolean) .join('\n'); - }, [shortCode, t]); + }, [shortCode, botUsername, t]); const handleShare = useCallback(async () => { const message = buildShareMessage(); @@ -1070,7 +961,7 @@ function SentGiftCard({ gift }: { gift: SentGift }) { onClick={handleShare} className="flex w-full items-center justify-center gap-2 rounded-xl bg-accent-500 px-4 py-3 text-sm font-bold uppercase tracking-wider text-white transition-colors hover:bg-accent-400 active:scale-[0.98]" > - + {t('gift.shareGift')} @@ -1219,7 +1110,7 @@ function MyGiftsTabContent() {

{t('gift.activeGiftsTitle')}

-
+
{activeGifts.map((gift) => ( ))} @@ -1233,7 +1124,7 @@ function MyGiftsTabContent() {

{t('gift.activatedGiftsTitle')}

-
+
{activatedGifts.map((gift) => ( ))} @@ -1247,7 +1138,7 @@ function MyGiftsTabContent() {

{t('gift.receivedGiftsTitle')}

-
+
{receivedGifts!.map((gift) => ( ))} @@ -1315,7 +1206,7 @@ export default function GiftSubscription() { return (
-
+
{/* Header */} Date: Mon, 1 Jun 2026 13:08:30 +0300 Subject: [PATCH 19/37] feat(admin-remnawave): provider favicon, mobile node card, meaningful icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Render the provider favicon in the node badge (new provider_favicon / provider_name fields), with graceful fallback. - Mobile-friendly node card: stop the versions block from shifting right on wrapped rows, truncate long addresses, cap the provider badge. - Give node-summary cards (total/online/offline/disabled) and traffic-period cards (2d/7d/30d/month/year) distinct, meaningful icons instead of all-the-same globe / bar-chart. - Show traffic as 'used / limit' (e.g. 2.35 TB / ∞). - Route every icon through the central barrel (drop the direct react-icons/pi import). --- src/api/adminRemnawave.ts | 2 + src/pages/AdminRemnawave.tsx | 77 ++++++++++++++++++++++++------------ 2 files changed, 53 insertions(+), 26 deletions(-) diff --git a/src/api/adminRemnawave.ts b/src/api/adminRemnawave.ts index c9e383b..9e86819 100644 --- a/src/api/adminRemnawave.ts +++ b/src/api/adminRemnawave.ts @@ -134,6 +134,8 @@ export interface NodeInfo { created_at?: string; updated_at?: string; provider_uuid?: string; + provider_name?: string | null; + provider_favicon?: string | null; versions?: { xray: string; node: string } | null; system?: { info: { diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index b34308e..ed0cff7 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -23,6 +23,19 @@ import { ServerIcon, ChartIcon, GlobeIcon, + HeartbeatIcon, + PowerIcon, + WarningCircleIcon, + CalendarIcon, + CalendarBlankIcon, + CalendarStarIcon, + ChartPieIcon, + ChartDonutIcon, + CpuIcon, + MemoryIcon, + PulseIcon, + DevicesIcon, + StatUptimeIcon, UsersIcon, SyncIcon, RefreshIcon, @@ -36,7 +49,6 @@ import { BackIcon, ChevronRightIcon, } from '../components/icons'; -import { PiCpu, PiMemory, PiTimer, PiDevices, PiPulse } from 'react-icons/pi'; const formatBytes = (bytes: number): string => { if (bytes === 0) return '0 B'; @@ -128,6 +140,9 @@ function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) { const limit = node.traffic_limit_bytes ?? 0; const trafficPct = limit > 0 ? Math.min(100, (used / limit) * 100) : null; + // Provider name: realtime metrics first, fall back to the node's own provider. + const providerLabel = providerName || node.provider_name; + return (
{/* Identity + actions */} @@ -145,9 +160,19 @@ function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) { {getCountryFlag(node.country_code)}

{node.name}

- {providerName && ( - - {providerName} + {(providerLabel || node.provider_favicon) && ( + + {node.provider_favicon && ( + { + e.currentTarget.style.display = 'none'; + }} + /> + )} + {providerLabel && {providerLabel}} )}
@@ -186,9 +211,9 @@ function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) { {/* Address + traffic + uptime */}
- - - {node.address} + + + {node.address} {formatBytes(used)} @@ -200,11 +225,11 @@ function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) { /> )} - {limit > 0 ? formatBytes(limit) : '∞'} + / {limit > 0 ? formatBytes(limit) : '∞'} {node.xray_uptime > 0 && ( - + {formatUptime(node.xray_uptime)} )} @@ -246,7 +271,7 @@ function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) { {(node.versions?.node || node.versions?.xray) && ( - + {node.versions?.node && {node.versions.node}} {node.versions?.xray && xray {node.versions.xray}} @@ -527,20 +552,20 @@ function OverviewTab({ } + icon={} color="accent" /> } + icon={} color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'} /> } + icon={} color="blue" />
@@ -556,31 +581,31 @@ function OverviewTab({ } + icon={} color="accent" /> } + icon={} color="blue" /> } + icon={} color="green" /> } + icon={} color="purple" /> } + icon={} color="orange" />
@@ -650,7 +675,7 @@ function OverviewTab({ {devicesStats && (devicesStats.by_platform.length > 0 || devicesStats.by_app.length > 0) && (

- + {t('admin.remnawave.overview.devices', 'Устройства')} ·{' '} {devicesStats.total_hwid_devices} ({devicesStats.average_devices_per_user.toFixed(1)}/ {t('admin.remnawave.overview.perUser', 'юзер')}) @@ -717,7 +742,7 @@ function OverviewTab({ } + icon={} color="blue" /> } + icon={} color={health.event_loop_p99_ms > 50 ? 'red' : 'green'} /> } + icon={} color="accent" />

@@ -809,25 +834,25 @@ function NodesTab({ } + icon={} color="accent" /> } + icon={} color="green" /> } + icon={} color="red" /> } + icon={} color="accent" /> Date: Mon, 1 Jun 2026 13:30:53 +0300 Subject: [PATCH 20/37] fix(i18n): spell the brand "Remnawave" instead of camelCase "RemnaWave" Replace the user-facing 'RemnaWave' spelling with 'Remnawave' across all locale values (en/ru/zh/fa, 67 strings), t() display fallbacks, and code comments. Translation keys (refreshRemnaWave, ...) and code identifiers (RemnaWaveService, setRemnaWaveUuid, ...) are left untouched. --- src/api/adminApps.ts | 10 +++++----- src/api/adminUsers.ts | 2 +- src/api/servers.ts | 2 +- src/api/tariffs.ts | 8 ++++---- src/locales/en.json | 32 +++++++++++++++--------------- src/locales/fa.json | 32 +++++++++++++++--------------- src/locales/ru.json | 38 ++++++++++++++++++------------------ src/locales/zh.json | 32 +++++++++++++++--------------- src/pages/AdminApps.tsx | 6 +++--- src/pages/AdminRemnawave.tsx | 6 +++--- src/pages/Subscription.tsx | 2 +- src/types/index.ts | 4 ++-- 12 files changed, 87 insertions(+), 87 deletions(-) diff --git a/src/api/adminApps.ts b/src/api/adminApps.ts index 2b6d2c9..5879d95 100644 --- a/src/api/adminApps.ts +++ b/src/api/adminApps.ts @@ -9,7 +9,7 @@ export interface LocalizedText { } export const adminAppsApi = { - // Get RemnaWave config status + // Get Remnawave config status getRemnaWaveStatus: async (): Promise<{ enabled: boolean; config_uuid: string | null }> => { const response = await apiClient.get<{ enabled: boolean; config_uuid: string | null }>( '/cabinet/admin/apps/remnawave/status', @@ -17,7 +17,7 @@ export const adminAppsApi = { return response.data; }, - // Set RemnaWave config UUID + // Set Remnawave config UUID setRemnaWaveUuid: async ( uuid: string | null, ): Promise<{ enabled: boolean; config_uuid: string | null }> => { @@ -28,7 +28,7 @@ export const adminAppsApi = { return response.data; }, - // List available RemnaWave configs + // List available Remnawave configs listRemnaWaveConfigs: async (): Promise< { uuid: string; name: string; view_position: number }[] > => { @@ -38,7 +38,7 @@ export const adminAppsApi = { return response.data; }, - // Get RemnaWave subscription config + // Get Remnawave subscription config getRemnaWaveConfig: async (): Promise => { const response = await apiClient.get<{ uuid: string; @@ -50,7 +50,7 @@ export const adminAppsApi = { }, }; -// ============== RemnaWave Format Types ============== +// ============== Remnawave Format Types ============== export interface RemnawaveButton { url: string; diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index 388a79b..ce21696 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -698,7 +698,7 @@ export const adminUsersApi = { return response.data; }, - // Get subscription request history from RemnaWave panel + // Get subscription request history from Remnawave panel getSubscriptionRequestHistory: async ( userId: number, subscriptionId?: number, diff --git a/src/api/servers.ts b/src/api/servers.ts index 02fae00..6f24a84 100644 --- a/src/api/servers.ts +++ b/src/api/servers.ts @@ -134,7 +134,7 @@ export const serversApi = { return response.data; }, - // Sync servers with RemnaWave + // Sync servers with Remnawave syncServers: async (): Promise => { const response = await apiClient.post('/cabinet/admin/servers/sync'); return response.data; diff --git a/src/api/tariffs.ts b/src/api/tariffs.ts index 48d0c1c..ad0771d 100644 --- a/src/api/tariffs.ts +++ b/src/api/tariffs.ts @@ -87,7 +87,7 @@ export interface TariffDetail { daily_price_kopeks: number; // Режим сброса трафика traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'MONTH_ROLLING', 'NO_RESET', null = глобальная настройка - // Внешний сквад RemnaWave + // Внешний сквад Remnawave external_squad_uuid: string | null; created_at: string; updated_at: string | null; @@ -126,7 +126,7 @@ export interface TariffCreateRequest { daily_price_kopeks?: number; // Режим сброса трафика traffic_reset_mode?: string | null; - // Внешний сквад RemnaWave + // Внешний сквад Remnawave external_squad_uuid?: string | null; } @@ -170,7 +170,7 @@ export interface TariffUpdateRequest { daily_price_kopeks?: number; // Режим сброса трафика traffic_reset_mode?: string | null; - // Внешний сквад RemnaWave + // Внешний сквад Remnawave external_squad_uuid?: string | null; } @@ -266,7 +266,7 @@ export const tariffsApi = { return response.data; }, - // Get available external squads from RemnaWave + // Get available external squads from Remnawave getAvailableExternalSquads: async (): Promise => { const response = await apiClient.get('/cabinet/admin/tariffs/available-external-squads'); return response.data; diff --git a/src/locales/en.json b/src/locales/en.json index 6d52a41..0976dba 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1207,7 +1207,7 @@ "broadcasts": "Broadcasts", "users": "Users", "payments": "Payments", - "remnawave": "RemnaWave", + "remnawave": "Remnawave", "emailTemplates": "Email Templates", "paymentMethods": "Payment Methods", "campaigns": "Campaigns", @@ -1605,7 +1605,7 @@ "notFound": "Payment method not found" }, "remnawave": { - "title": "RemnaWave", + "title": "Remnawave", "subtitle": "Panel management and statistics", "connected": "Connected", "disconnected": "Not configured", @@ -1719,9 +1719,9 @@ "success": "Success", "runAutoSyncNow": "Run Auto Sync Now", "fromPanel": "From Panel", - "fromPanelDesc": "Import users from RemnaWave to bot", + "fromPanelDesc": "Import users from Remnawave to bot", "toPanel": "To Panel", - "toPanelDesc": "Export users from bot to RemnaWave" + "toPanelDesc": "Export users from bot to Remnawave" } }, "wheel": { @@ -2128,7 +2128,7 @@ "db_sqlite": "SQLite", "db_redis": "Redis", "sys_core": "Core & Debug", - "sys_remnawave": "RemnaWave", + "sys_remnawave": "Remnawave", "sys_webapi": "Web API", "sys_webhook": "Webhook", "sys_server": "Server status", @@ -2178,7 +2178,7 @@ "deleteUser": "Delete users" }, "deleteSubscription": { - "warning": "Selected subscriptions will be permanently deleted from the bot and RemnaWave panel!", + "warning": "Selected subscriptions will be permanently deleted from the bot and Remnawave panel!", "hint": "Users will lose VPN access for deleted subscriptions. This action cannot be undone.", "activePaidWarning": "{{count}} of {{total}} selected subscriptions are active paid", "forceDeleteConfirm": "I confirm deletion of active paid subscriptions" @@ -2186,7 +2186,7 @@ "deleteUser": { "warning": "Selected users will be permanently deleted from the bot! All their subscriptions, balance, and data will be lost!", "hint": "This action cannot be undone. Users will need to restart the bot to create a new account.", - "deleteFromPanel": "Also delete from RemnaWave panel" + "deleteFromPanel": "Also delete from Remnawave panel" }, "params": { "days": "Number of days", @@ -2370,16 +2370,16 @@ "importError": "Invalid JSON file format", "importCreateError": "Failed to create some apps", "noAppsToImport": "No apps to import", - "remnaWaveToggle": "Toggle RemnaWave source", - "refreshRemnaWave": "Refresh from RemnaWave", - "remnaWaveMode": "Showing apps from RemnaWave panel. Read-only mode.", - "remnaWaveSettings": "RemnaWave Settings", + "remnaWaveToggle": "Toggle Remnawave source", + "refreshRemnaWave": "Refresh from Remnawave", + "remnaWaveMode": "Showing apps from Remnawave panel. Read-only mode.", + "remnaWaveSettings": "Remnawave Settings", "remnaWaveConfigUuid": "Config UUID", - "remnaWaveConfigUuidHint": "UUID of subscription page config from RemnaWave panel", + "remnaWaveConfigUuidHint": "UUID of subscription page config from Remnawave panel", "availableConfigs": "Available configs", "noConfigs": "No configs", - "remnaWaveConnected": "RemnaWave connected", - "remnaWaveDisconnected": "RemnaWave disconnected" + "remnaWaveConnected": "Remnawave connected", + "remnaWaveDisconnected": "Remnawave disconnected" }, "tickets": { "title": "Ticket Management", @@ -2528,7 +2528,7 @@ "daysShort": "d.", "serversTitle": "Servers", "externalSquadTitle": "External Squad", - "externalSquadHint": "Assign a RemnaWave external squad for users on this tariff. When a subscription is created, the user will be automatically added to the selected squad.", + "externalSquadHint": "Assign a Remnawave external squad for users on this tariff. When a subscription is created, the user will be automatically added to the selected squad.", "noExternalSquad": "No external squad", "externalSquadUsers": "users", "serversTabHint": "Select servers available on this tariff.", @@ -2607,7 +2607,7 @@ "sync": "Sync", "syncing": "Syncing...", "syncNow": "Sync now", - "noServers": "No servers. Sync with RemnaWave.", + "noServers": "No servers. Sync with Remnawave.", "edit": "Edit Server", "originalName": "Original Name", "displayName": "Display Name", diff --git a/src/locales/fa.json b/src/locales/fa.json index 4fd0f5b..7c4e281 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1086,7 +1086,7 @@ "promoOffers": "پیشنهادات تبلیغاتی", "promocodes": "کدهای تخفیف", "promoGroups": "گروه‌های تخفیف", - "remnawave": "RemnaWave", + "remnawave": "Remnawave", "users": "کاربران", "trafficUsage": "مصرف ترافیک", "updates": "به‌روزرسانی‌ها", @@ -1832,7 +1832,7 @@ "db_sqlite": "SQLite", "db_redis": "Redis", "sys_core": "هسته و اشکال‌زدایی", - "sys_remnawave": "RemnaWave", + "sys_remnawave": "Remnawave", "sys_webapi": "Web API", "sys_webhook": "Webhook", "sys_server": "وضعیت سرور", @@ -1957,15 +1957,15 @@ "importError": "فرمت فایل JSON نامعتبر", "importPreview": "پیش‌نمایش وارد کردن", "noAppsToImport": "هیچ برنامه‌ای برای وارد کردن نیست", - "refreshRemnaWave": "بازخوانی از RemnaWave", + "refreshRemnaWave": "بازخوانی از Remnawave", "remnaWaveConfigUuid": "UUID پیکربندی", - "remnaWaveConfigUuidHint": "UUID پیکربندی صفحه اشتراک از پنل RemnaWave", - "remnaWaveMode": "نمایش برنامه‌ها از پنل RemnaWave. حالت فقط خواندنی.", - "remnaWaveSettings": "تنظیمات RemnaWave", - "remnaWaveToggle": "تغییر منبع RemnaWave", + "remnaWaveConfigUuidHint": "UUID پیکربندی صفحه اشتراک از پنل Remnawave", + "remnaWaveMode": "نمایش برنامه‌ها از پنل Remnawave. حالت فقط خواندنی.", + "remnaWaveSettings": "تنظیمات Remnawave", + "remnaWaveToggle": "تغییر منبع Remnawave", "noConfigs": "هیچ تنظیماتی وجود ندارد", - "remnaWaveConnected": "RemnaWave متصل است", - "remnaWaveDisconnected": "RemnaWave قطع است" + "remnaWaveConnected": "Remnawave متصل است", + "remnaWaveDisconnected": "Remnawave قطع است" }, "tickets": { "title": "مدیریت تیکت‌ها", @@ -2107,7 +2107,7 @@ "daysShort": "روز", "serversTitle": "سرورها", "externalSquadTitle": "گروه خارجی", - "externalSquadHint": "یک گروه خارجی RemnaWave برای کاربران این تعرفه تعیین کنید. هنگام ایجاد اشتراک، کاربر به طور خودکار به گروه انتخاب شده اضافه می‌شود.", + "externalSquadHint": "یک گروه خارجی Remnawave برای کاربران این تعرفه تعیین کنید. هنگام ایجاد اشتراک، کاربر به طور خودکار به گروه انتخاب شده اضافه می‌شود.", "noExternalSquad": "بدون گروه خارجی", "externalSquadUsers": "کاربران", "serversTabHint": "سرورهای موجود در این تعرفه را انتخاب کنید.", @@ -2186,7 +2186,7 @@ "sync": "همگام‌سازی", "syncing": "در حال همگام‌سازی...", "syncNow": "همگام‌سازی الان", - "noServers": "سروری نیست. با RemnaWave همگام‌سازی کنید.", + "noServers": "سروری نیست. با Remnawave همگام‌سازی کنید.", "edit": "ویرایش سرور", "originalName": "نام اصلی", "displayName": "نام نمایشی", @@ -3138,7 +3138,7 @@ "connected": "متصل", "disconnected": "تنظیم نشده", "noData": "بارگذاری داده‌ها ناموفق بود", - "title": "RemnaWave", + "title": "Remnawave", "subtitle": "مدیریت پنل و آمار", "tabs": { "overview": "نمای کلی", @@ -3246,9 +3246,9 @@ "success": "موفق", "runAutoSyncNow": "اجرای همگام‌سازی خودکار", "fromPanel": "از پنل", - "fromPanelDesc": "وارد کردن کاربران از RemnaWave به ربات", + "fromPanelDesc": "وارد کردن کاربران از Remnawave به ربات", "toPanel": "به پنل", - "toPanelDesc": "صادر کردن کاربران از ربات به RemnaWave" + "toPanelDesc": "صادر کردن کاربران از ربات به Remnawave" } }, "theme": { @@ -3824,7 +3824,7 @@ "deleteUser": "حذف کاربران" }, "deleteSubscription": { - "warning": "اشتراک‌های انتخاب شده برای همیشه از ربات و پنل RemnaWave حذف خواهند شد!", + "warning": "اشتراک‌های انتخاب شده برای همیشه از ربات و پنل Remnawave حذف خواهند شد!", "hint": "کاربران دسترسی VPN اشتراک‌های حذف شده را از دست خواهند داد. این عملیات قابل بازگشت نیست.", "activePaidWarning": "{{count}} از {{total}} اشتراک انتخاب‌شده اشتراک‌های فعال پرداختی هستند", "forceDeleteConfirm": "حذف اشتراک‌های فعال پرداختی را تأیید می‌کنم" @@ -3832,7 +3832,7 @@ "deleteUser": { "warning": "کاربران انتخاب شده برای همیشه از ربات حذف خواهند شد! تمام اشتراک‌ها، موجودی و داده‌های آن‌ها از بین خواهد رفت!", "hint": "این عملیات قابل بازگشت نیست. کاربران باید ربات را دوباره راه‌اندازی کنند تا حساب جدید بسازند.", - "deleteFromPanel": "همچنین از پنل RemnaWave حذف شود" + "deleteFromPanel": "همچنین از پنل Remnawave حذف شود" }, "params": { "days": "تعداد روز", diff --git a/src/locales/ru.json b/src/locales/ru.json index 0a6b6e1..a175dae 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1229,7 +1229,7 @@ "broadcasts": "Рассылки", "users": "Пользователи", "payments": "Платежи", - "remnawave": "RemnaWave", + "remnawave": "Remnawave", "emailTemplates": "Email-шаблоны", "paymentMethods": "Платёжные методы", "campaigns": "Кампании", @@ -1627,7 +1627,7 @@ "notFound": "Метод оплаты не найден" }, "remnawave": { - "title": "RemnaWave", + "title": "Remnawave", "subtitle": "Управление панелью и статистика", "connected": "Подключено", "disconnected": "Не настроено", @@ -1744,9 +1744,9 @@ "success": "Успешно", "runAutoSyncNow": "Запустить автосинхронизацию", "fromPanel": "Из панели", - "fromPanelDesc": "Импорт пользователей из RemnaWave в бота", + "fromPanelDesc": "Импорт пользователей из Remnawave в бота", "toPanel": "В панель", - "toPanelDesc": "Экспорт пользователей из бота в RemnaWave" + "toPanelDesc": "Экспорт пользователей из бота в Remnawave" } }, "wheel": { @@ -2145,7 +2145,7 @@ "db_sqlite": "SQLite", "db_redis": "Redis", "sys_core": "Ядро и отладка", - "sys_remnawave": "RemnaWave", + "sys_remnawave": "Remnawave", "sys_webapi": "Web API", "sys_webhook": "Webhook", "sys_server": "Статус сервера", @@ -2355,8 +2355,8 @@ "Api Key": "API ключ", "Api Token": "API токен", "Webhook Secret": "Секрет вебхука", - "Remnawave Url": "URL RemnaWave", - "Remnawave Token": "Токен RemnaWave", + "Remnawave Url": "URL Remnawave", + "Remnawave Token": "Токен Remnawave", "Server Status Enabled": "Статус сервера", "Monitoring Enabled": "Мониторинг", "Backup Enabled": "Бэкап", @@ -2646,7 +2646,7 @@ "SQLITE": "SQLite", "REDIS": "Redis", "CORE": "Бот", - "REMNAWAVE": "RemnaWave", + "REMNAWAVE": "Remnawave", "SERVER_STATUS": "Статус сервера", "MONITORING": "Мониторинг", "MAINTENANCE": "Обслуживание", @@ -2765,16 +2765,16 @@ "importError": "Неверный формат JSON файла", "importCreateError": "Ошибка при создании приложений", "noAppsToImport": "Нет приложений для импорта", - "remnaWaveToggle": "Переключить источник RemnaWave", - "refreshRemnaWave": "Обновить данные из RemnaWave", - "remnaWaveMode": "Отображаются приложения из панели RemnaWave. Режим только для чтения.", - "remnaWaveSettings": "Настройки RemnaWave", + "remnaWaveToggle": "Переключить источник Remnawave", + "refreshRemnaWave": "Обновить данные из Remnawave", + "remnaWaveMode": "Отображаются приложения из панели Remnawave. Режим только для чтения.", + "remnaWaveSettings": "Настройки Remnawave", "remnaWaveConfigUuid": "UUID конфигурации", - "remnaWaveConfigUuidHint": "UUID конфигурации страницы подписки из панели RemnaWave", + "remnaWaveConfigUuidHint": "UUID конфигурации страницы подписки из панели Remnawave", "availableConfigs": "Доступные конфигурации", "noConfigs": "Нет настроенных конфигов", - "remnaWaveConnected": "RemnaWave подключён", - "remnaWaveDisconnected": "RemnaWave отключён" + "remnaWaveConnected": "Remnawave подключён", + "remnaWaveDisconnected": "Remnawave отключён" }, "tickets": { "title": "Управление тикетами", @@ -2926,7 +2926,7 @@ "daysShort": "дн.", "serversTitle": "Серверы", "externalSquadTitle": "Внешний сквад", - "externalSquadHint": "Назначьте внешний сквад RemnaWave для пользователей этого тарифа. При создании подписки пользователь будет автоматически добавлен в выбранный сквад.", + "externalSquadHint": "Назначьте внешний сквад Remnawave для пользователей этого тарифа. При создании подписки пользователь будет автоматически добавлен в выбранный сквад.", "noExternalSquad": "Без внешнего сквада", "externalSquadUsers": "пользователей", "serversTabHint": "Выберите серверы, доступные на этом тарифе.", @@ -3005,7 +3005,7 @@ "sync": "Синхронизировать", "syncing": "Синхронизация...", "syncNow": "Синхронизировать сейчас", - "noServers": "Нет серверов. Синхронизируйте с RemnaWave.", + "noServers": "Нет серверов. Синхронизируйте с Remnawave.", "edit": "Редактировать сервер", "originalName": "Оригинальное название", "displayName": "Отображаемое название", @@ -3976,7 +3976,7 @@ "deleteUser": "Удалить пользователей" }, "deleteSubscription": { - "warning": "Выбранные подписки будут безвозвратно удалены из бота и панели RemnaWave!", + "warning": "Выбранные подписки будут безвозвратно удалены из бота и панели Remnawave!", "hint": "Пользователи потеряют доступ к VPN по удалённым подпискам. Это действие нельзя отменить.", "activePaidWarning": "{{count}} из {{total}} выбранных подписок: активные платные", "forceDeleteConfirm": "Подтверждаю удаление активных платных подписок" @@ -3984,7 +3984,7 @@ "deleteUser": { "warning": "Выбранные пользователи будут безвозвратно удалены из бота! Все их подписки, баланс и данные будут потеряны!", "hint": "Это действие нельзя отменить. Пользователям придётся заново запустить бота для создания нового аккаунта.", - "deleteFromPanel": "Также удалить из панели RemnaWave" + "deleteFromPanel": "Также удалить из панели Remnawave" }, "params": { "days": "Количество дней", diff --git a/src/locales/zh.json b/src/locales/zh.json index 9f9bd70..bcc6376 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1086,7 +1086,7 @@ "promoOffers": "促销优惠", "promocodes": "促销码", "promoGroups": "折扣组", - "remnawave": "RemnaWave", + "remnawave": "Remnawave", "users": "用户", "trafficUsage": "流量使用", "updates": "更新", @@ -1873,7 +1873,7 @@ "db_sqlite": "SQLite", "db_redis": "Redis", "sys_core": "核心与调试", - "sys_remnawave": "RemnaWave", + "sys_remnawave": "Remnawave", "sys_webapi": "Web API", "sys_webhook": "Webhook", "sys_server": "服务器状态", @@ -1991,12 +1991,12 @@ "importError": "无效的JSON文件格式", "importPreview": "导入预览", "noAppsToImport": "没有可导入的应用", - "refreshRemnaWave": "从RemnaWave刷新", + "refreshRemnaWave": "从Remnawave刷新", "remnaWaveConfigUuid": "配置UUID", - "remnaWaveConfigUuidHint": "来自RemnaWave面板的订阅页面配置UUID", - "remnaWaveMode": "显示来自RemnaWave面板的应用。只读模式。", - "remnaWaveSettings": "RemnaWave设置", - "remnaWaveToggle": "切换RemnaWave源", + "remnaWaveConfigUuidHint": "来自Remnawave面板的订阅页面配置UUID", + "remnaWaveMode": "显示来自Remnawave面板的应用。只读模式。", + "remnaWaveSettings": "Remnawave设置", + "remnaWaveToggle": "切换Remnawave源", "tabs": { "basic": "基本", "installation": "安装", @@ -2005,8 +2005,8 @@ "additional": "附加" }, "noConfigs": "未配置任何配置", - "remnaWaveConnected": "RemnaWave 已连接", - "remnaWaveDisconnected": "RemnaWave 未连接" + "remnaWaveConnected": "Remnawave 已连接", + "remnaWaveDisconnected": "Remnawave 未连接" }, "tickets": { "title": "工单管理", @@ -2147,7 +2147,7 @@ "daysShort": "天", "serversTitle": "服务器", "externalSquadTitle": "外部小组", - "externalSquadHint": "为此套餐的用户分配RemnaWave外部小组。创建订阅时,用户将自动添加到所选小组。", + "externalSquadHint": "为此套餐的用户分配Remnawave外部小组。创建订阅时,用户将自动添加到所选小组。", "noExternalSquad": "无外部小组", "externalSquadUsers": "用户", "serversTabHint": "选择此套餐上可用的服务器。", @@ -2226,7 +2226,7 @@ "sync": "同步", "syncing": "同步中...", "syncNow": "立即同步", - "noServers": "暂无服务器。请与RemnaWave同步。", + "noServers": "暂无服务器。请与Remnawave同步。", "edit": "编辑服务器", "originalName": "原始名称", "displayName": "显示名称", @@ -3146,7 +3146,7 @@ "connected": "已连接", "disconnected": "未配置", "noData": "加载数据失败", - "title": "RemnaWave", + "title": "Remnawave", "subtitle": "面板管理和统计", "tabs": { "overview": "概览", @@ -3254,9 +3254,9 @@ "success": "成功", "runAutoSyncNow": "立即运行自动同步", "fromPanel": "从面板", - "fromPanelDesc": "从RemnaWave导入用户到机器人", + "fromPanelDesc": "从Remnawave导入用户到机器人", "toPanel": "到面板", - "toPanelDesc": "从机器人导出用户到RemnaWave" + "toPanelDesc": "从机器人导出用户到Remnawave" } }, "roles": { @@ -3823,7 +3823,7 @@ "deleteUser": "删除用户" }, "deleteSubscription": { - "warning": "选中的订阅将从机器人和RemnaWave面板中永久删除!", + "warning": "选中的订阅将从机器人和Remnawave面板中永久删除!", "hint": "用户将失去已删除订阅的VPN访问权限。此操作无法撤销。", "activePaidWarning": "所选订阅中有 {{count}} / {{total}} 是有效的付费订阅", "forceDeleteConfirm": "我确认删除有效的付费订阅" @@ -3831,7 +3831,7 @@ "deleteUser": { "warning": "选中的用户将从机器人中永久删除!他们的所有订阅、余额和数据将丢失!", "hint": "此操作无法撤销。用户需要重新启动机器人以创建新账户。", - "deleteFromPanel": "同时从RemnaWave面板删除" + "deleteFromPanel": "同时从Remnawave面板删除" }, "params": { "days": "天数", diff --git a/src/pages/AdminApps.tsx b/src/pages/AdminApps.tsx index a69c416..27f2647 100644 --- a/src/pages/AdminApps.tsx +++ b/src/pages/AdminApps.tsx @@ -11,7 +11,7 @@ export default function AdminApps() { const queryClient = useQueryClient(); const { capabilities } = usePlatform(); - // RemnaWave status + // Remnawave status const { data: status } = useQuery({ queryKey: ['remnawave-status'], queryFn: adminAppsApi.getRemnaWaveStatus, @@ -60,8 +60,8 @@ export default function AdminApps() { /> {status?.enabled - ? t('admin.apps.remnaWaveConnected', 'RemnaWave connected') - : t('admin.apps.remnaWaveDisconnected', 'RemnaWave not connected')} + ? t('admin.apps.remnaWaveConnected', 'Remnawave connected') + : t('admin.apps.remnaWaveDisconnected', 'Remnawave not connected')}
{status?.config_uuid && ( diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index ed0cff7..3771662 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -1122,7 +1122,7 @@ function SyncTab({ title={t('admin.remnawave.sync.fromPanel', 'Sync from Panel')} description={t( 'admin.remnawave.sync.fromPanelDesc', - 'Import users from RemnaWave panel to bot', + 'Import users from Remnawave panel to bot', )} onAction={onSyncFromPanel} isLoading={loadingStates.fromPanel} @@ -1132,7 +1132,7 @@ function SyncTab({ title={t('admin.remnawave.sync.toPanel', 'Sync to Panel')} description={t( 'admin.remnawave.sync.toPanelDesc', - 'Export users from bot to RemnaWave panel', + 'Export users from bot to Remnawave panel', )} onAction={onSyncToPanel} isLoading={loadingStates.toPanel} @@ -1498,7 +1498,7 @@ export default function AdminRemnawave() {

- {t('admin.remnawave.title', 'RemnaWave')} + {t('admin.remnawave.title', 'Remnawave')}

{t('admin.remnawave.subtitle', 'Panel management and statistics')} diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index cc5017c..1fcd783 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -438,7 +438,7 @@ export default function Subscription() { queryClient.invalidateQueries({ queryKey: ['subscription'] }); queryClient.invalidateQueries({ queryKey: ['connection-link', subscriptionId] }); queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); - // RemnaWave resets device HWIDs on revoke — make sure the cabinet + // Remnawave resets device HWIDs on revoke — make sure the cabinet // re-reads the now-empty device list instead of showing the stale cache. queryClient.invalidateQueries({ queryKey: ['devices', subscriptionId] }); haptic.notification('success'); diff --git a/src/types/index.ts b/src/types/index.ts index 2208030..84ad78a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -538,7 +538,7 @@ export interface LocalizedText { [key: string]: string; } -// RemnaWave format types +// Remnawave format types export interface RemnawaveButtonClient { url?: string; link?: string; @@ -581,7 +581,7 @@ export interface AppConfig { supportUrl?: string; }; - // RemnaWave + // Remnawave isRemnawave?: boolean; svgLibrary?: Record; baseTranslations?: Record; From bd5e39fa56591a06ac94c5b6c6f095215c57ffe0 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 13:42:07 +0300 Subject: [PATCH 21/37] fix(admin-remnawave): resolve provider favicon from site URL The panel's provider.faviconLink is the provider's site URL (e.g. https://waicore.com/), not an image, so rendering it directly as failed and onError hid it. Derive the real favicon image from the URL's hostname via Google's favicon service, like the panel does. --- src/pages/AdminRemnawave.tsx | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index 3771662..17d3ad2 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -62,6 +62,19 @@ const formatBytes = (bytes: number): string => { // сохранён для случая пустого кода (важно для UI-плейсхолдеров). const getCountryFlag = (code: string | null | undefined): string => getFlagEmoji(code) || '🌍'; +// The panel's provider.faviconLink is the provider's site URL (e.g. +// "https://waicore.com/"), not an image. Resolve it to an actual favicon +// image via Google's favicon service, the same way the panel renders it. +const providerFaviconUrl = (link: string | null | undefined): string | null => { + if (!link) return null; + try { + const host = new URL(link).hostname; + return host ? `https://www.google.com/s2/favicons?domain=${host}&sz=64` : null; + } catch { + return null; + } +}; + // Realtime interface throughput (bytes/s) → network-style speed, matching the // panel exactly: the unit step is by 1024 bytes, but the value is shown in bits // (×8 / 1000), e.g. 341 KB/s → "2728 Kb/s", 1.34 MB/s → "10.72 Mb/s". @@ -142,6 +155,7 @@ function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) { // Provider name: realtime metrics first, fall back to the node's own provider. const providerLabel = providerName || node.provider_name; + const providerFavicon = providerFaviconUrl(node.provider_favicon); return (

@@ -160,11 +174,11 @@ function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) { {getCountryFlag(node.country_code)}

{node.name}

- {(providerLabel || node.provider_favicon) && ( + {(providerLabel || providerFavicon) && ( - {node.provider_favicon && ( + {providerFavicon && ( { From 7ca9c043ddfc5393efcd6c00fc5677d60b6dbe55 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 13:52:22 +0300 Subject: [PATCH 22/37] feat(admin-remnawave): meaningful icons for users-by-status cards Each Remnawave user status now gets a distinct icon instead of the same people glyph: ACTIVE -> check-circle, DISABLED -> prohibit, LIMITED -> gauge, EXPIRED -> clock (people icon as fallback for unknown statuses). --- src/pages/AdminRemnawave.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index 17d3ad2..d7fee72 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -37,6 +37,10 @@ import { DevicesIcon, StatUptimeIcon, UsersIcon, + CheckCircleIcon, + BanIcon, + TrafficIcon, + ClockIcon, SyncIcon, RefreshIcon, PlayIcon, @@ -75,6 +79,22 @@ const providerFaviconUrl = (link: string | null | undefined): string | null => { } }; +// Meaningful icon per Remnawave user status (instead of the same people glyph). +const userStatusIcon = (status: string): React.ReactNode => { + switch (status.toUpperCase()) { + case 'ACTIVE': + return ; + case 'DISABLED': + return ; + case 'LIMITED': + return ; + case 'EXPIRED': + return ; + default: + return ; + } +}; + // Realtime interface throughput (bytes/s) → network-style speed, matching the // panel exactly: the unit step is by 1024 bytes, but the value is shown in bits // (×8 / 1000), e.g. 341 KB/s → "2728 Kb/s", 1.34 MB/s → "10.72 Mb/s". @@ -637,7 +657,7 @@ function OverviewTab({ key={status} label={status} value={count} - icon={} + icon={userStatusIcon(status)} color={status === 'ACTIVE' ? 'green' : status === 'DISABLED' ? 'red' : 'accent'} /> ))} From 50d7d2146296a30027306b02e01ce4177dc210e3 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 14:11:50 +0300 Subject: [PATCH 23/37] fix(gift): stretch my-gifts cards full-width instead of cramped 2-col grid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2-column grid inside the max-w-2xl container made each gift card a narrow ~330px half. Gift cards are content-rich (header, code, share preview, buttons), so stack them single-column full-width — they now stretch to the container width on every breakpoint. --- src/pages/GiftSubscription.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/GiftSubscription.tsx b/src/pages/GiftSubscription.tsx index 9e4fe4c..9dc2002 100644 --- a/src/pages/GiftSubscription.tsx +++ b/src/pages/GiftSubscription.tsx @@ -1110,7 +1110,7 @@ function MyGiftsTabContent() {

{t('gift.activeGiftsTitle')}

-
+
{activeGifts.map((gift) => ( ))} @@ -1124,7 +1124,7 @@ function MyGiftsTabContent() {

{t('gift.activatedGiftsTitle')}

-
+
{activatedGifts.map((gift) => ( ))} @@ -1138,7 +1138,7 @@ function MyGiftsTabContent() {

{t('gift.receivedGiftsTitle')}

-
+
{receivedGifts!.map((gift) => ( ))} From 6c9a77d419d7358308836efbe7614efad5932971 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 14:24:07 +0300 Subject: [PATCH 24/37] feat(admin-remnawave): merge Traffic into Nodes as a per-node accordion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the separate Traffic tab and fold its per-node inbound/outbound breakdown into the Nodes tab: click a node card to expand its traffic breakdown, click again to collapse (chevron indicator; action buttons stop propagation). Realtime totals (down/up/Σ) move to a compact line above the node list. Realtime stats now load on the Nodes tab. --- src/locales/ru.json | 1 + src/pages/AdminRemnawave.tsx | 280 ++++++++++++++++------------------- 2 files changed, 125 insertions(+), 156 deletions(-) diff --git a/src/locales/ru.json b/src/locales/ru.json index a175dae..bf8081d 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1645,6 +1645,7 @@ "totalUpload": "Отдача", "totalTraffic": "Всего", "online": "онлайн", + "realtimeTitle": "Текущий трафик", "inbounds": "Inbounds", "outbounds": "Outbounds" }, diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index d7fee72..25dec88 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -139,15 +139,46 @@ function StatCard({ label, value, icon, color = 'accent', subValue }: StatCardPr ); } +function NodeTrafficBreakdown({ + title, + items, +}: { + title: string; + items: { tag: string; downloadBytes: number; uploadBytes: number; totalBytes: number }[]; +}) { + return ( +
+

{title}

+ {[...items] + .sort((a, b) => b.totalBytes - a.totalBytes) + .map((it) => ( +
+ {it.tag} +
+ ↓ {formatBytes(it.downloadBytes)} + ↑ {formatBytes(it.uploadBytes)} + {formatBytes(it.totalBytes)} +
+
+ ))} +
+ ); +} + interface NodeCardProps { node: NodeInfo; providerName?: string; + realtime?: NodeRealtimeStats; onAction: (uuid: string, action: 'enable' | 'disable' | 'restart') => void; isLoading?: boolean; } -function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) { +function NodeCard({ node, providerName, realtime, onAction, isLoading }: NodeCardProps) { const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); const isUp = node.is_connected && node.is_node_online && !node.is_disabled; const dotColor = node.is_disabled ? 'bg-dark-500' : isUp ? 'bg-success-400' : 'bg-error-400'; @@ -177,8 +208,19 @@ function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) { const providerLabel = providerName || node.provider_name; const providerFavicon = providerFaviconUrl(node.provider_favicon); + // Per-node traffic breakdown (merged from the former Traffic tab) — shown in + // an accordion that toggles when the card is clicked. + const inbounds = realtime?.inbounds ?? []; + const outbounds = realtime?.outbounds ?? []; + const hasBreakdown = inbounds.length + outbounds.length > 0; + return ( -
+
setExpanded((v) => !v) : undefined} + > {/* Identity + actions */}
@@ -213,7 +255,10 @@ function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) {
+ {hasBreakdown && ( + + )}
@@ -312,6 +367,27 @@ function NodeCard({ node, providerName, onAction, isLoading }: NodeCardProps) { )}
)} + + {/* Per-node traffic accordion (merged from the former Traffic tab) */} + {expanded && hasBreakdown && ( +
e.stopPropagation()} + > + {inbounds.length > 0 && ( + + )} + {outbounds.length > 0 && ( + + )} +
+ )}
); } @@ -824,6 +900,7 @@ function OverviewTab({ interface NodesTabProps { nodes: NodeInfo[]; providerByUuid: Record; + realtimeByUuid: Record; isLoading: boolean; onRefresh: () => void; onAction: (uuid: string, action: 'enable' | 'disable' | 'restart') => void; @@ -834,6 +911,7 @@ interface NodesTabProps { function NodesTab({ nodes, providerByUuid, + realtimeByUuid, isLoading, onRefresh, onAction, @@ -853,6 +931,13 @@ function NodesTab({ return { total, online, offline, disabled, totalUsers }; }, [nodes]); + const traffic = useMemo(() => { + const vals = Object.values(realtimeByUuid); + const download = vals.reduce((a, n) => a + (n.downloadBytes ?? 0), 0); + const upload = vals.reduce((a, n) => a + (n.uploadBytes ?? 0), 0); + return { download, upload, total: download + upload }; + }, [realtimeByUuid]); + if (isLoading) { return (
@@ -916,6 +1001,26 @@ function NodesTab({
+ {/* Realtime traffic totals (merged from the former Traffic tab) */} + {traffic.total > 0 && ( +
+ + {t('admin.remnawave.traffic.realtimeTitle', 'Realtime traffic')} + + + + {formatBytes(traffic.download)} + + + + {formatBytes(traffic.upload)} + + + {'Σ'} {formatBytes(traffic.total)} + +
+ )} + {/* Nodes List */}
{nodes.length === 0 ? ( @@ -928,6 +1033,7 @@ function NodesTab({ key={node.uuid} node={node} providerName={providerByUuid[node.uuid]} + realtime={realtimeByUuid[node.uuid]} onAction={onAction} isLoading={isActionLoading} /> @@ -1177,138 +1283,7 @@ function SyncTab({ ); } -interface TrafficTabProps { - data: NodeRealtimeStats[] | undefined; - isLoading: boolean; - onRefresh: () => void; -} - -function TrafficTab({ data, isLoading, onRefresh }: TrafficTabProps) { - const { t } = useTranslation(); - - if (isLoading) { - return ( -
-
-
- ); - } - - if (!data || data.length === 0) { - return ( -
-

- {t('admin.remnawave.traffic.noData', 'No traffic data available')} -

- -
- ); - } - - const totalDownload = data.reduce((acc, n) => acc + n.downloadBytes, 0); - const totalUpload = data.reduce((acc, n) => acc + n.uploadBytes, 0); - - return ( -
- {/* Totals */} -
- } - color="green" - /> - } - color="blue" - /> - } - color="purple" - /> -
- - {/* Per-node inbound breakdown */} - {data.map((node) => ( -
-
-
- {node.countryEmoji && {node.countryEmoji}} -

{node.nodeName}

- {node.providerName && ( - - {node.providerName} - - )} - - {node.usersOnline} {t('admin.remnawave.traffic.online', 'online')} - -
- {formatBytes(node.totalBytes)} -
- - {(node.inbounds?.length ?? 0) > 0 && ( -
-

- {t('admin.remnawave.traffic.inbounds', 'Inbounds')} -

- {[...(node.inbounds ?? [])] - .sort((a, b) => b.totalBytes - a.totalBytes) - .map((ib) => ( -
- {ib.tag} -
- ↓ {formatBytes(ib.downloadBytes)} - ↑ {formatBytes(ib.uploadBytes)} - - {formatBytes(ib.totalBytes)} - -
-
- ))} -
- )} - - {(node.outbounds?.length ?? 0) > 0 && ( -
-

- {t('admin.remnawave.traffic.outbounds', 'Outbounds')} -

- {[...(node.outbounds ?? [])] - .sort((a, b) => b.totalBytes - a.totalBytes) - .map((ob) => ( -
- {ob.tag} -
- ↓ {formatBytes(ob.downloadBytes)} - ↑ {formatBytes(ob.uploadBytes)} - - {formatBytes(ob.totalBytes)} - -
-
- ))} -
- )} -
- ))} -
- ); -} - -type TabType = 'overview' | 'nodes' | 'traffic' | 'squads' | 'sync'; +type TabType = 'overview' | 'nodes' | 'squads' | 'sync'; export default function AdminRemnawave() { const { t } = useTranslation(); @@ -1402,15 +1377,12 @@ export default function AdminRemnawave() { enabled: activeTab === 'squads', }); - const { - data: realtimeData, - isLoading: isLoadingRealtime, - refetch: refetchRealtime, - } = useQuery({ + const { data: realtimeData } = useQuery({ queryKey: ['admin-remnawave-realtime'], queryFn: adminRemnawaveApi.getNodesRealtime, - // Loaded on the Nodes tab too — realtime carries the provider name per node. - enabled: activeTab === 'nodes' || activeTab === 'traffic', + // Realtime carries the provider name + per-node inbound/outbound breakdown + // that the Nodes tab shows (provider badge + the per-node traffic accordion). + enabled: activeTab === 'nodes', refetchInterval: 10000, }); @@ -1424,6 +1396,14 @@ export default function AdminRemnawave() { return map; }, [realtimeData]); + // Full realtime stats by node uuid — feeds the per-node traffic accordion + // (inbounds/outbounds) merged into the Nodes tab. + const realtimeByUuid = useMemo(() => { + const map: Record = {}; + for (const r of realtimeData ?? []) map[r.nodeUuid] = r; + return map; + }, [realtimeData]); + const { data: autoSyncStatus, isLoading: isLoadingAutoSync } = useQuery({ queryKey: ['admin-remnawave-autosync'], queryFn: adminRemnawaveApi.getAutoSyncStatus, @@ -1498,11 +1478,6 @@ export default function AdminRemnawave() { icon: , }, { id: 'nodes' as const, label: t('admin.remnawave.tabs.nodes', 'Nodes'), icon: }, - { - id: 'traffic' as const, - label: t('admin.remnawave.tabs.traffic', 'Traffic'), - icon: , - }, { id: 'squads' as const, label: t('admin.remnawave.tabs.squads', 'Squads'), @@ -1598,6 +1573,7 @@ export default function AdminRemnawave() { refetchNodes()} onAction={handleNodeAction} @@ -1606,14 +1582,6 @@ export default function AdminRemnawave() { /> )} - {activeTab === 'traffic' && ( - refetchRealtime()} - /> - )} - {activeTab === 'squads' && ( Date: Mon, 1 Jun 2026 14:28:27 +0300 Subject: [PATCH 25/37] fix(admin-remnawave): truncate long tags in node traffic accordion Give the inbound/outbound tag min-w-0 flex-1 so a long tag shrinks and ellipsizes instead of pushing the byte counters off-screen on narrow viewports; tighten the counter gap. --- src/pages/AdminRemnawave.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index 25dec88..e86e735 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -156,8 +156,8 @@ function NodeTrafficBreakdown({ key={it.tag} className="flex items-center justify-between gap-3 rounded-lg bg-dark-900/50 px-2.5 py-1.5" > - {it.tag} -
+ {it.tag} +
↓ {formatBytes(it.downloadBytes)} ↑ {formatBytes(it.uploadBytes)} {formatBytes(it.totalBytes)} From 297d75a92c30d650c2a5eee4c3785b2365d6d094 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 14:37:36 +0300 Subject: [PATCH 26/37] fix(admin-remnawave): add RAM/CPU icons to node metrics like the panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checked the live panel — every node metric carries an icon there, but our card showed RAM% and load average as bare text while the speeds had icons. Add MemoryIcon before RAM% and CpuIcon before the load average so the metrics row matches the original. --- src/pages/AdminRemnawave.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index e86e735..bf9b64d 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -329,6 +329,7 @@ function NodeCard({ node, providerName, realtime, onAction, isLoading }: NodeCar
{ramPct !== null && ( + 85 @@ -348,7 +349,12 @@ function NodeCard({ node, providerName, realtime, onAction, isLoading }: NodeCar )} - {loadAvg && {loadAvg}} + {loadAvg && ( + + + {loadAvg} + + )} From 1b1046bb5c5caf43e55f3bb5915962a05319cc23 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 14:43:11 +0300 Subject: [PATCH 27/37] feat(admin-remnawave): add node/xray version icons like the panel The panel marks each node version with its product logo. Add an XrayIcon (the Xray core brand pinwheel, custom SVG like RemnawaveIcon) and put RemnawaveIcon before the remnanode version + XrayIcon before the xray version, matching the original node rows. Drop the redundant 'xray' word since the logo conveys it. --- src/components/icons/index.tsx | 16 ++++++++++++++++ src/pages/AdminRemnawave.tsx | 17 ++++++++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx index 0fa9967..1b71d56 100644 --- a/src/components/icons/index.tsx +++ b/src/components/icons/index.tsx @@ -257,6 +257,22 @@ export const ChartIcon = ({ className }: IconProps) => ( ); +// Xray core brand logo (4-blade pinwheel) — matches the panel's node rows. +// Not a Phosphor glyph; kept custom like RemnawaveIcon for brand fidelity. +export const XrayIcon = ({ className }: IconProps) => ( + + + + + + +); + export const GlobeIcon = ({ className }: IconProps) => ( ); diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index bf9b64d..112a90d 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -47,6 +47,7 @@ import { StopIcon, ArrowPathIcon, RemnawaveIcon, + XrayIcon, DownloadIcon, UploadIcon, SubscriptionIcon, @@ -366,9 +367,19 @@ function NodeCard({ node, providerName, realtime, onAction, isLoading }: NodeCar {(node.versions?.node || node.versions?.xray) && ( - - {node.versions?.node && {node.versions.node}} - {node.versions?.xray && xray {node.versions.xray}} + + {node.versions?.node && ( + + + {node.versions.node} + + )} + {node.versions?.xray && ( + + + {node.versions.xray} + + )} )}
From 43acb70ab96ad70b7f66f26db32b05f6dc18349f Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 14:52:28 +0300 Subject: [PATCH 28/37] fix(admin-remnawave): lay node metrics in 3 fixed rows (processor/traffic/versions) The metrics sat in one flex-wrap, so when realtime speeds change width (Kb/s<->Mb/s, large values) elements reflowed and jumped between lines, and the card height shifted. Split into 3 fixed rows: processor (CPU load + RAM), traffic (down/up), versions (remnanode + xray). Traffic uses a 2-column grid so a wide value never pushes its sibling; tabular-nums keeps digit widths constant. --- src/pages/AdminRemnawave.tsx | 97 ++++++++++++++++++++---------------- 1 file changed, 55 insertions(+), 42 deletions(-) diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index 112a90d..dcd7eb4 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -325,62 +325,75 @@ function NodeCard({ node, providerName, realtime, onAction, isLoading }: NodeCar )}
- {/* Live metrics: RAM · load · speeds · versions */} + {/* Live metrics in 3 fixed rows (processor · traffic · versions) so the + changing speed/value widths never reflow or jump between lines. */} {(ramPct !== null || loadAvg || rx > 0 || tx > 0 || node.versions) && ( -
- {ramPct !== null && ( - - - 85 - ? 'text-error-400' - : ramPct > 65 - ? 'text-warning-400' - : 'text-dark-400' - } - > - {ramPct}% - - - - - +
+ {/* Row 1 — Processor: CPU load + RAM */} + {(loadAvg || ramPct !== null) && ( +
+ {loadAvg && ( + + + {loadAvg} + + )} + {ramPct !== null && ( + + + 85 + ? 'text-error-400' + : ramPct > 65 + ? 'text-warning-400' + : 'text-dark-400' + } + > + {ramPct}% + + + + + + )} +
)} - {loadAvg && ( - - - {loadAvg} - + + {/* Row 2 — Traffic: down / up speeds (2 fixed columns so a wide value + never pushes the other) */} + {(rx > 0 || tx > 0) && ( +
+ + + {formatSpeed(rx)} + + + + {formatSpeed(tx)} + +
)} - - - - {formatSpeed(rx)} - - - - {formatSpeed(tx)} - - + + {/* Row 3 — Versions: remnanode + xray core */} {(node.versions?.node || node.versions?.xray) && ( - +
{node.versions?.node && ( - + {node.versions.node} )} {node.versions?.xray && ( - + {node.versions.xray} )} - +
)}
)} From 97d436721ac5c180fd82af40e1267ffd97deda9f Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 15:42:29 +0300 Subject: [PATCH 29/37] fix(admin-remnawave): node metrics 3-rows on mobile, original single row on desktop Jumping only happened on mobile (the wrap reshuffled when speeds changed width); desktop fits on one line and never jumped. So keep the 3 fixed rows for mobile (sm:hidden) and restore the original single flex-wrap row for desktop (hidden sm:flex). On mobile, download/upload now sit tight in a flex row (gap-4) instead of a full-width 2-col grid that looked too spread out. --- src/pages/AdminRemnawave.tsx | 168 ++++++++++++++++++++++------------- 1 file changed, 106 insertions(+), 62 deletions(-) diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index dcd7eb4..3c7c592 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -215,6 +215,15 @@ function NodeCard({ node, providerName, realtime, onAction, isLoading }: NodeCar const outbounds = realtime?.outbounds ?? []; const hasBreakdown = inbounds.length + outbounds.length > 0; + const ramColorClass = + ramPct === null + ? '' + : ramPct > 85 + ? 'text-error-400' + : ramPct > 65 + ? 'text-warning-400' + : 'text-dark-400'; + return (
- {/* Live metrics in 3 fixed rows (processor · traffic · versions) so the - changing speed/value widths never reflow or jump between lines. */} + {/* Live metrics — mobile: 3 fixed rows so wrapping speeds don't reflow; + desktop (sm+): the original single wrap row, wide enough not to jump. */} {(ramPct !== null || loadAvg || rx > 0 || tx > 0 || node.versions) && ( -
- {/* Row 1 — Processor: CPU load + RAM */} - {(loadAvg || ramPct !== null) && ( -
- {loadAvg && ( - - - {loadAvg} - - )} - {ramPct !== null && ( - - - 85 - ? 'text-error-400' - : ramPct > 65 - ? 'text-warning-400' - : 'text-dark-400' - } - > - {ramPct}% + <> + {/* Mobile: processor · traffic · versions */} +
+ {(loadAvg || ramPct !== null) && ( +
+ {loadAvg && ( + + + {loadAvg} - - + )} + {ramPct !== null && ( + + + {ramPct}% + + + + )} +
+ )} + {(rx > 0 || tx > 0) && ( +
+ + + {formatSpeed(rx)} - )} -
- )} + + + {formatSpeed(tx)} + +
+ )} + {(node.versions?.node || node.versions?.xray) && ( +
+ {node.versions?.node && ( + + + {node.versions.node} + + )} + {node.versions?.xray && ( + + + {node.versions.xray} + + )} +
+ )} +
- {/* Row 2 — Traffic: down / up speeds (2 fixed columns so a wide value - never pushes the other) */} - {(rx > 0 || tx > 0) && ( -
- - + {/* Desktop: single wrap row (original) */} +
+ {ramPct !== null && ( + + + {ramPct}% + + + + + )} + {loadAvg && ( + + + {loadAvg} + + )} + + + {formatSpeed(rx)} - - + + {formatSpeed(tx)} -
- )} - - {/* Row 3 — Versions: remnanode + xray core */} - {(node.versions?.node || node.versions?.xray) && ( -
- {node.versions?.node && ( - - - {node.versions.node} - - )} - {node.versions?.xray && ( - - - {node.versions.xray} - - )} -
- )} -
+ + {(node.versions?.node || node.versions?.xray) && ( + + {node.versions?.node && ( + + + {node.versions.node} + + )} + {node.versions?.xray && ( + + + {node.versions.xray} + + )} + + )} +
+ )} {/* Per-node traffic accordion (merged from the former Traffic tab) */} From 7b7ff536729872bc4b107bb4ce19a1643d979ca5 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 15:49:29 +0300 Subject: [PATCH 30/37] fix(news): fill the orphan card so the grid has no empty cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On sm+ the regular news cards sit in a 2-col grid; an odd count left an empty cell. Move the featured card out of the grid (it was col-span-full anyway, which also skewed nth-child parity) and stretch the last regular card to span both columns when it's the lone one in its row — same orphan-fill used for the admin stat cards. --- src/components/news/NewsSection.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/components/news/NewsSection.tsx b/src/components/news/NewsSection.tsx index c9ae90a..fb78ad5 100644 --- a/src/components/news/NewsSection.tsx +++ b/src/components/news/NewsSection.tsx @@ -429,11 +429,20 @@ export default function NewsSection() { {/* Grid */} {items.length > 0 && ( -
+
{featured && } - {regular.map((item, i) => ( - - ))} + {regular.length > 0 && ( +
+ {regular.map((item, i) => ( + + ))} +
+ )}
)} From b877d7f17550aff9157e662af0525653ab764a0f Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 16:00:28 +0300 Subject: [PATCH 31/37] fix(flags): render flag emoji on Windows globally via a scoped flag font MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows ships no glyphs for regional-indicator flag emoji, so flags fell back to country letters everywhere react-twemoji wasn't manually wrapped. Bundle the Twemoji country-flags woff2 locally (no runtime CDN — matters for our audience) and add it as an @font-face scoped with unicode-range to flag codepoints only, then put it first in every Tailwind font stack (sans/display/mono). The browser now uses it solely for flag characters, so every flag renders correctly app-wide with zero markup changes and no effect on any other text. --- public/fonts/TwemojiCountryFlags.woff2 | Bin 0 -> 78292 bytes src/styles/globals.css | 12 ++++++++++++ tailwind.config.js | 8 ++++++-- 3 files changed, 18 insertions(+), 2 deletions(-) create mode 100644 public/fonts/TwemojiCountryFlags.woff2 diff --git a/public/fonts/TwemojiCountryFlags.woff2 b/public/fonts/TwemojiCountryFlags.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b9d6ea84b392bd43479c556d264fff6e5e57940e GIT binary patch literal 78292 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 K1)JR*5&!^7A{=4> literal 0 HcmV?d00001 diff --git a/src/styles/globals.css b/src/styles/globals.css index b351a0f..849ca51 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -2,6 +2,18 @@ @tailwind components; @tailwind utilities; +/* Flag emoji polyfill — Windows has no glyphs for regional-indicator flags, so + they fall back to country letters. This locally-bundled Twemoji flag font is + scoped via unicode-range to ONLY flag codepoints, so adding it to the front + of the font stacks fixes every flag everywhere without touching any markup + and without affecting any other text. Root fix, not a per-component wrap. */ +@font-face { + font-family: 'Twemoji Country Flags'; + unicode-range: U+1F1E6-1F1FF, U+1F3F4, U+E0062-E007F; + src: url('/fonts/TwemojiCountryFlags.woff2') format('woff2'); + font-display: swap; +} + /* Animated gradient border — @property enables CSS-only angle animation */ @property --border-angle { syntax: ''; diff --git a/tailwind.config.js b/tailwind.config.js index 4729ba0..fb1410b 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -110,7 +110,11 @@ export default { }, }, fontFamily: { + // 'Twemoji Country Flags' is first in every stack so Windows renders flag + // emoji (it's unicode-range-scoped to flag codepoints only — see globals.css — + // so it never affects any other glyph). Global root fix for flags everywhere. sans: [ + 'Twemoji Country Flags', 'Manrope', 'system-ui', '-apple-system', @@ -119,8 +123,8 @@ export default { 'Roboto', 'sans-serif', ], - display: ['Outfit', 'Manrope', 'system-ui', 'sans-serif'], - mono: ['IBM Plex Mono', 'ui-monospace', 'monospace'], + display: ['Twemoji Country Flags', 'Outfit', 'Manrope', 'system-ui', 'sans-serif'], + mono: ['Twemoji Country Flags', 'IBM Plex Mono', 'ui-monospace', 'monospace'], }, borderRadius: { bento: '24px', From 8a41aa77819d141bc3e6cb6b481e2420b11f391e Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 16:24:02 +0300 Subject: [PATCH 32/37] fix(wheel): kill the center-line LED artifact during spin (drop CSS blur filter) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rim lights are static cx/cy SVG circles; their glow used a CSS filter:blur(2px). During the spin the wheel's transform transition promotes the SVG to a GPU layer, and Chrome mis-renders CSS filters on geometry-positioned SVG elements — the blurs streak toward the view-box origin, drawing a line of lights through the centre. Replace the blur with a soft radial-gradient fill (no filter, so the artefact can't occur). Also drive both the chase speed and the per-dot delay from one --led-dur var so the chase stays in order at the faster spin speed instead of scrambling (the delay was a fixed 6s value that broke once the duration dropped to 2s). --- src/components/wheel/FortuneWheel.tsx | 52 +++++++++++++++------------ 1 file changed, 29 insertions(+), 23 deletions(-) diff --git a/src/components/wheel/FortuneWheel.tsx b/src/components/wheel/FortuneWheel.tsx index 4563a27..10adacc 100644 --- a/src/components/wheel/FortuneWheel.tsx +++ b/src/components/wheel/FortuneWheel.tsx @@ -206,6 +206,17 @@ const FortuneWheel = memo(function FortuneWheel({ + + {/* LED glow as a soft radial gradient instead of a CSS blur filter. + A `filter: blur()` on cx/cy-positioned SVG circles mis-renders under + GPU compositing during the spin — the blurs streak toward the + view-box origin and draw a line of lights through the centre. A + gradient fill needs no filter, so that artefact can't occur. */} + + + + + {/* Background shadow */} @@ -242,10 +253,20 @@ const FortuneWheel = memo(function FortuneWheel({ 0%, 100% { opacity: 0; } 10%, 30% { opacity: 0.4; } } - .led-dot { animation: ledChase 6s linear infinite; } - .led-glow { opacity: 0; animation: ledGlow 6s linear infinite; } - .led-spinning .led-dot { animation-duration: 2s; } - .led-spinning .led-glow { animation-duration: 2s; } + /* --led-dur drives both the speed and the per-dot delay, so they + scale together and the chase stays in order at the faster spin + speed (the delay used to be a fixed 6s value, which scrambled + the order once the duration dropped to 2s). */ + .led-dot, .led-glow { + --led-dur: 6s; + animation-duration: var(--led-dur); + animation-timing-function: linear; + animation-iteration-count: infinite; + animation-delay: calc(var(--led-i, 0) / 20 * var(--led-dur)); + } + .led-dot { animation-name: ledChase; } + .led-glow { opacity: 0; animation-name: ledGlow; } + .led-spinning .led-dot, .led-spinning .led-glow { --led-dur: 2s; } `} @@ -254,26 +275,11 @@ const FortuneWheel = memo(function FortuneWheel({ const ledRadius = outerRadius + 3; const dotX = center + ledRadius * Math.cos(angle); const dotY = center + ledRadius * Math.sin(angle); - // Delay as fraction of full cycle — CSS handles speed via animation-duration - const delay = `${(i / 20) * 6}s`; return ( - - - + // --led-i (dot index) drives the staggered chase delay in CSS + + + ); })} From 7716e32eecea6e43264342635090d8e4f68e4d87 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 16:45:35 +0300 Subject: [PATCH 33/37] fix(wheel): scatter spin sparkles so they don't streak through the centre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The real cause of the center line: SPARKLE_POSITIONS (yellow dots shown only while spinning) used `20 + (i*10)%60` / `15 + (i*13)%70`. For the first ~6 sparkles the modulo never wraps, so top/left increment linearly and the dots land on a straight diagonal through the centre — the 'line of lights' during the spin. Scatter them on a golden-angle spiral inside the wheel instead. Reverts the earlier LED-glow change (it wasn't the cause): the rim-light blur is restored to the original. --- src/components/wheel/FortuneWheel.tsx | 72 ++++++++++++++------------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/src/components/wheel/FortuneWheel.tsx b/src/components/wheel/FortuneWheel.tsx index 10adacc..3bf5974 100644 --- a/src/components/wheel/FortuneWheel.tsx +++ b/src/components/wheel/FortuneWheel.tsx @@ -8,12 +8,20 @@ interface FortuneWheelProps { onSpinComplete: () => void; } -// Pre-generate sparkle positions to avoid recalculating on each render -const SPARKLE_POSITIONS = Array.from({ length: 8 }, (_, i) => ({ - top: `${20 + ((i * 10) % 60)}%`, - left: `${15 + ((i * 13) % 70)}%`, - delay: `${i * 0.15}s`, -})); +// Pre-generate sparkle positions (shown only while spinning). The old formula +// `20 + (i*10)%60 / 15 + (i*13)%70` left the first ~6 sparkles colinear (the +// modulo never wrapped for small i), so they lined up as a diagonal streak +// through the centre. Scatter them on a phyllotaxis (golden-angle) spiral +// inside the wheel instead — well-distributed, never colinear. +const SPARKLE_POSITIONS = Array.from({ length: 8 }, (_, i) => { + const angle = i * 2.39996; // golden angle (radians) + const radius = 16 + (i % 4) * 8; // 16–40% from centre, kept within the wheel + return { + top: `${(50 + radius * Math.sin(angle)).toFixed(1)}%`, + left: `${(50 + radius * Math.cos(angle)).toFixed(1)}%`, + delay: `${i * 0.15}s`, + }; +}); const FortuneWheel = memo(function FortuneWheel({ prizes, @@ -206,17 +214,6 @@ const FortuneWheel = memo(function FortuneWheel({ - - {/* LED glow as a soft radial gradient instead of a CSS blur filter. - A `filter: blur()` on cx/cy-positioned SVG circles mis-renders under - GPU compositing during the spin — the blurs streak toward the - view-box origin and draw a line of lights through the centre. A - gradient fill needs no filter, so that artefact can't occur. */} - - - - - {/* Background shadow */} @@ -253,20 +250,10 @@ const FortuneWheel = memo(function FortuneWheel({ 0%, 100% { opacity: 0; } 10%, 30% { opacity: 0.4; } } - /* --led-dur drives both the speed and the per-dot delay, so they - scale together and the chase stays in order at the faster spin - speed (the delay used to be a fixed 6s value, which scrambled - the order once the duration dropped to 2s). */ - .led-dot, .led-glow { - --led-dur: 6s; - animation-duration: var(--led-dur); - animation-timing-function: linear; - animation-iteration-count: infinite; - animation-delay: calc(var(--led-i, 0) / 20 * var(--led-dur)); - } - .led-dot { animation-name: ledChase; } - .led-glow { opacity: 0; animation-name: ledGlow; } - .led-spinning .led-dot, .led-spinning .led-glow { --led-dur: 2s; } + .led-dot { animation: ledChase 6s linear infinite; } + .led-glow { opacity: 0; animation: ledGlow 6s linear infinite; } + .led-spinning .led-dot { animation-duration: 2s; } + .led-spinning .led-glow { animation-duration: 2s; } `} @@ -275,11 +262,26 @@ const FortuneWheel = memo(function FortuneWheel({ const ledRadius = outerRadius + 3; const dotX = center + ledRadius * Math.cos(angle); const dotY = center + ledRadius * Math.sin(angle); + // Delay as fraction of full cycle — CSS handles speed via animation-duration + const delay = `${(i / 20) * 6}s`; return ( - // --led-i (dot index) drives the staggered chase delay in CSS - - - + + + ); })} From 6c5d1ee0b1cfb3329580879ce6b4be1c6c6a69ec Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 17:46:36 +0300 Subject: [PATCH 34/37] fix(wheel): keep spin sparkles in the outer ring, never near the centre MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confirmed from the user's spin video: the sparkles bunched into the middle during a spin because they were placed at 16-40% from centre (some right by the hub). Push them all to a 34-43% radius (golden-angle distributed) so they ring the outer wheel and the centre stays clear — no sparkles crossing or near the centre. --- src/components/wheel/FortuneWheel.tsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/wheel/FortuneWheel.tsx b/src/components/wheel/FortuneWheel.tsx index 3bf5974..be28029 100644 --- a/src/components/wheel/FortuneWheel.tsx +++ b/src/components/wheel/FortuneWheel.tsx @@ -8,14 +8,14 @@ interface FortuneWheelProps { onSpinComplete: () => void; } -// Pre-generate sparkle positions (shown only while spinning). The old formula -// `20 + (i*10)%60 / 15 + (i*13)%70` left the first ~6 sparkles colinear (the -// modulo never wrapped for small i), so they lined up as a diagonal streak -// through the centre. Scatter them on a phyllotaxis (golden-angle) spiral -// inside the wheel instead — well-distributed, never colinear. +// Pre-generate sparkle positions (shown only while spinning). They must stay in +// the OUTER ring of the wheel and never near the hub — the old formula scattered +// some right onto the centre, so during a spin the sparkles bunched into / streaked +// through the middle. Distribute by golden angle at a large radius (34–43% from +// centre) so they ring the wheel and the centre stays clear. const SPARKLE_POSITIONS = Array.from({ length: 8 }, (_, i) => { - const angle = i * 2.39996; // golden angle (radians) - const radius = 16 + (i % 4) * 8; // 16–40% from centre, kept within the wheel + const angle = i * 2.39996; // golden angle (radians) → evenly spread, never colinear + const radius = 34 + (i % 4) * 3; // 34–43% from centre: outer ring only, never central return { top: `${(50 + radius * Math.sin(angle)).toFixed(1)}%`, left: `${(50 + radius * Math.cos(angle)).toFixed(1)}%`, From 2ce966cecec1c3a9634814cb1fb89a43d7ab63d9 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 18:00:00 +0300 Subject: [PATCH 35/37] fix(wheel): remove CSS filters from the spinning SVG (kill GPU-composite streak) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The center streak doesn't appear in screen recordings, which is the signature of a GPU-compositing artifact — CSS filters on SVG elements mis-render when the wheel group is promoted to a layer during the spin. Replace the two SVG CSS filters with compositing-safe equivalents: LED-glow blur -> radial gradient fill, emoji drop-shadow -> SVG-native feDropShadow. (HTML background blur left as-is; it's not on the SVG and isn't a light.) --- src/components/wheel/FortuneWheel.tsx | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/components/wheel/FortuneWheel.tsx b/src/components/wheel/FortuneWheel.tsx index be28029..7b64fd4 100644 --- a/src/components/wheel/FortuneWheel.tsx +++ b/src/components/wheel/FortuneWheel.tsx @@ -214,6 +214,21 @@ const FortuneWheel = memo(function FortuneWheel({ + + {/* LED glow as a soft gradient instead of a CSS blur. CSS filters on + cx/cy-positioned SVG elements mis-render under the spin's GPU + compositing — phantom streaks toward the centre that are often + invisible in screen recordings but visible live. */} + + + + + + + {/* SVG-native emoji shadow (not a CSS filter) for the same reason */} + + + {/* Background shadow */} @@ -270,9 +285,9 @@ const FortuneWheel = memo(function FortuneWheel({ className="led-glow" cx={dotX} cy={dotY} - r={5} - fill="#FEF08A" - style={{ filter: 'blur(2px)', animationDelay: delay }} + r={9} + fill="url(#ledGlowGrad)" + style={{ animationDelay: delay }} /> {prize.emoji} From bc8503469dc6651accb77f41b865e443849b75f2 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 18:12:55 +0300 Subject: [PATCH 36/37] fix(wheel): remove the spin-only sparkle lights entirely MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The extra lights that appear during a spin (and bunched into a line through the centre) were the SPARKLE_POSITIONS effect — yellow animate-ping dots rendered only while isSpinning. Moving/scattering them never satisfied; they're unwanted during the spin. Remove them outright: while spinning, only the rim LEDs remain — no extra lights, no centre line. --- src/components/wheel/FortuneWheel.tsx | 34 --------------------------- 1 file changed, 34 deletions(-) diff --git a/src/components/wheel/FortuneWheel.tsx b/src/components/wheel/FortuneWheel.tsx index 7b64fd4..3fd8de1 100644 --- a/src/components/wheel/FortuneWheel.tsx +++ b/src/components/wheel/FortuneWheel.tsx @@ -8,21 +8,6 @@ interface FortuneWheelProps { onSpinComplete: () => void; } -// Pre-generate sparkle positions (shown only while spinning). They must stay in -// the OUTER ring of the wheel and never near the hub — the old formula scattered -// some right onto the centre, so during a spin the sparkles bunched into / streaked -// through the middle. Distribute by golden angle at a large radius (34–43% from -// centre) so they ring the wheel and the centre stays clear. -const SPARKLE_POSITIONS = Array.from({ length: 8 }, (_, i) => { - const angle = i * 2.39996; // golden angle (radians) → evenly spread, never colinear - const radius = 34 + (i % 4) * 3; // 34–43% from centre: outer ring only, never central - return { - top: `${(50 + radius * Math.sin(angle)).toFixed(1)}%`, - left: `${(50 + radius * Math.cos(angle)).toFixed(1)}%`, - delay: `${i * 0.15}s`, - }; -}); - const FortuneWheel = memo(function FortuneWheel({ prizes, isSpinning, @@ -423,25 +408,6 @@ const FortuneWheel = memo(function FortuneWheel({ /> )}
- - {/* Sparkle effects when spinning - optimized with pre-calculated positions */} - {isSpinning && ( -
- {SPARKLE_POSITIONS.map((pos, i) => ( -
- ))} -
- )}
); }); From 484c3ad005616e16fc4280284048dcf674551057 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 1 Jun 2026 18:19:57 +0300 Subject: [PATCH 37/37] fix(subscriptions): fill the orphan card so a lone subscription has no empty cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The My Subscriptions list is a 2-col grid on sm+, so a single subscription (e.g. one tariff in multi-tariff mode) left an empty cell. Stretch the last card to span both columns when it's the lone one in its row — same orphan-fill used for the news grid and admin stat cards. --- src/pages/Subscriptions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Subscriptions.tsx b/src/pages/Subscriptions.tsx index 283d860..67b64e8 100644 --- a/src/pages/Subscriptions.tsx +++ b/src/pages/Subscriptions.tsx @@ -165,7 +165,7 @@ export default function Subscriptions() { {/* Subscription grid */} {subscriptions.length > 0 && ( -
+
{subscriptions.map((sub) => (