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/public/fonts/TwemojiCountryFlags.woff2 b/public/fonts/TwemojiCountryFlags.woff2 new file mode 100644 index 0000000..b9d6ea8 Binary files /dev/null and b/public/fonts/TwemojiCountryFlags.woff2 differ diff --git a/src/AppWithNavigator.tsx b/src/AppWithNavigator.tsx index f0f20cc..ba3a78b 100644 --- a/src/AppWithNavigator.tsx +++ b/src/AppWithNavigator.tsx @@ -1,11 +1,12 @@ import { useEffect, useRef, useCallback } from 'react'; -import { BrowserRouter, useLocation, useNavigate } from 'react-router'; +import { BrowserRouter, useLocation, useNavigate, useNavigationType } from 'react-router'; import { showBackButton, hideBackButton, 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'; @@ -15,7 +16,8 @@ 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; @@ -26,35 +28,98 @@ 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 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. + 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; + + // 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) && depthRef.current === 0; 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(() => { - // 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(() => { 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/adminRemnawave.ts b/src/api/adminRemnawave.ts index 0218421..9e86819 100644 --- a/src/api/adminRemnawave.ts +++ b/src/api/adminRemnawave.ts @@ -21,12 +21,57 @@ export interface SystemSummary { total_users: number; active_connections: number; nodes_online: number; + total_nodes: number; users_last_day: number; users_last_week: number; users_never_online: number; total_user_traffic: number; } +// Panel recap / devices / top consumers +export interface RecapResponse { + version: string | null; + init_date: string | null; + total: { + users: number; + nodes: number; + traffic_bytes: number; + nodes_ram_bytes: number; + nodes_cpu_cores: number; + distinct_countries: number; + }; + this_month: { users: number; traffic_bytes: number }; +} + +export interface DevicesStatsResponse { + by_platform: { platform: string; count: number }[]; + by_app: { app: string; count: number }[]; + top_users: { username: string; devices_count: number }[]; + total_unique_devices: number; + total_hwid_devices: number; + average_devices_per_user: number; +} + +export interface TopConsumersResponse { + period_days: number; + users: { username: string; total_bytes: number }[]; +} + +export interface HealthResponse { + instances: number; + rss_bytes: number; + heap_used_bytes: number; + heap_total_bytes: number; + event_loop_delay_ms: number; + event_loop_p99_ms: number; + uptime_seconds: number; + instance_id: string | null; +} + +export interface SubscriptionRequestStatsResponse { + by_app: { app: string; count: number }[]; +} + export interface ServerInfo { cpu_cores: number; memory_total: number; @@ -89,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: { @@ -292,6 +339,34 @@ export const adminRemnawaveApi = { return response.data; }, + // Panel recap / devices / top consumers + getRecap: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/remnawave/recap'); + return response.data; + }, + + getDevicesStats: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/remnawave/devices-stats'); + return response.data; + }, + + getTopConsumers: async (days = 7, limit = 10): Promise => { + const response = await apiClient.get('/cabinet/admin/remnawave/top-consumers', { + params: { days, limit }, + }); + return response.data; + }, + + getHealth: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/remnawave/health'); + return response.data; + }, + + getSubscriptionRequests: async (): Promise => { + const response = await apiClient.get('/cabinet/admin/remnawave/subscription-requests'); + return response.data; + }, + // Nodes getNodes: async (): Promise => { const response = await apiClient.get('/cabinet/admin/remnawave/nodes'); 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/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/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/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/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/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/MenuEditorTab.tsx b/src/components/admin/MenuEditorTab.tsx index 80e532e..fb8aeb4 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 { PiCaretDown } from 'react-icons/pi'; +import { + GripIcon, + TrashIcon, + PlusIcon, + LinkIcon, + ArrowUpIcon, + ArrowDownIcon, +} from '@/components/icons'; import { menuLayoutApi, type MenuConfig, @@ -31,82 +40,8 @@ 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 +135,7 @@ function ButtonChip({ : 'cursor-default text-dark-700' }`} > - +
@@ -221,7 +156,7 @@ function ButtonChip({ {!isBuiltin && ( - + )} onUpdate({ enabled: !button.enabled })} /> @@ -236,7 +171,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 +360,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 +416,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 +451,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..90af4a3 100644 --- a/src/components/admin/SortableSelectedMethodCard.tsx +++ b/src/components/admin/SortableSelectedMethodCard.tsx @@ -1,8 +1,10 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; 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'; @@ -10,15 +12,7 @@ import type { PaymentMethodSubOptionInfo } from '../../types'; export type MethodWithId = AdminLandingPaymentMethod & { _id: string }; const ChevronDownIcon = ({ open }: { open: boolean }) => ( - - - + ); interface SortableSelectedMethodProps { @@ -225,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 e9d442d..3371b36 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, ChevronDownIcon, XCloseIcon, XIcon } 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); @@ -115,31 +98,11 @@ function ProgressView({ progress }: { progress: ProgressState }) {
{entry.success ? ( ) : ( )} @@ -176,18 +139,12 @@ function ErrorDetails({ result }: { result: BulkActionResult }) { {t('admin.bulkActions.errors.title', { count: result.error_count })} - - - + /> {expanded && (
@@ -195,15 +152,7 @@ function ErrorDetails({ result }: { result: BulkActionResult }) { {result.errors.map((err, idx) => (
{err.username ? `@${err.username}` : `#${err.user_id}`} @@ -520,7 +469,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/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/TrafficIcons.tsx b/src/components/admin/trafficUsage/TrafficIcons.tsx index 78bf843..f4ec955 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 { PiCaretDown, PiCaretUpDown, PiCaretUp } 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..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'; @@ -49,13 +50,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 && ( @@ -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 c3cb2b4..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'; @@ -52,14 +53,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 && ( @@ -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/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..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. @@ -63,14 +64,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 && ( @@ -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 5f4173c..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, @@ -50,14 +51,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 && ( @@ -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/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/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 c45c52a..b6fec90 100644 --- a/src/components/admin/userDetail/SubscriptionTab.tsx +++ b/src/components/admin/userDetail/SubscriptionTab.tsx @@ -1,4 +1,15 @@ import { useTranslation } from 'react-i18next'; +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'; @@ -37,28 +48,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; @@ -238,19 +227,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) { - - - +
@@ -330,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', 'Все подписки')} )} @@ -390,7 +359,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 +373,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" > - +
@@ -964,19 +933,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) { )} aria-label={t('admin.users.detail.devices.renameSave', 'Сохранить')} > - + ) : ( @@ -1016,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/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/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..00b392b 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 { WrenchIcon } 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 055b761..05aa6cc 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, BookOpenIcon, ChevronIcon } 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" > - + )}

@@ -264,15 +259,7 @@ export default function InstallationGuide({ ))}
- - - +

)} @@ -320,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 */}
( - - - -); - -const CheckIcon = () => ( - - - -); - interface BlockButtonsProps { buttons: RemnawaveButtonClient[] | undefined; variant: 'light' | 'subtle'; diff --git a/src/components/dashboard/PendingGiftCard.tsx b/src/components/dashboard/PendingGiftCard.tsx index c3d8691..a47f66a 100644 --- a/src/components/dashboard/PendingGiftCard.tsx +++ b/src/components/dashboard/PendingGiftCard.tsx @@ -1,6 +1,7 @@ import { Link } from 'react-router'; import { useTranslation } from 'react-i18next'; import { motion } from 'framer-motion'; +import { GiftIcon } from '@/components/icons'; import type { PendingGift } from '../../api/gift'; interface PendingGiftCardProps { @@ -28,19 +29,7 @@ export default function PendingGiftCard({ gifts, className }: PendingGiftCardPro
{/* Gift icon */}
- - - +
{/* Content */} diff --git a/src/components/dashboard/StatsGrid.tsx b/src/components/dashboard/StatsGrid.tsx index 791ffd1..3a41585 100644 --- a/src/components/dashboard/StatsGrid.tsx +++ b/src/components/dashboard/StatsGrid.tsx @@ -1,3 +1,4 @@ +import { PiCaretRight } 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('dashboard.remaining')}
diff --git a/src/components/dashboard/SubscriptionCardExpired.tsx b/src/components/dashboard/SubscriptionCardExpired.tsx index 95d5e46..5431944 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 { ClockIcon, ExclamationIcon, PlusIcon, SubscriptionIcon } from '@/components/icons'; interface SubscriptionCardExpiredProps { subscription: Subscription; @@ -167,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 ? ( - + ) : ( - + )}

@@ -274,19 +249,7 @@ export default function SubscriptionCardExpired({ boxShadow: `0 4px 20px rgba(${accent.r},${accent.g},${accent.b},0.2)`, }} > - + {t('subscription.buyTraffic')} ) : ( @@ -311,19 +274,7 @@ export default function SubscriptionCardExpired({ aria-hidden="true" /> ) : ( - + )} {isRenewing ? t('common.loading') @@ -341,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 */}
( + +); + +export const ItalicIcon = ({ className }: IconProps) => ( + +); + +export const UnderlineIcon = ({ className }: IconProps) => ( + +); + +export const StrikeIcon = ({ className }: IconProps) => ( + +); + +export const H1Icon = ({ className }: IconProps) => ( + +); + +export const H2Icon = ({ className }: IconProps) => ( + +); + +export const H3Icon = ({ className }: IconProps) => ( + +); + +export const ListBulletIcon = ({ className }: IconProps) => ( + +); + +export const ListOrderedIcon = ({ className }: IconProps) => ( + +); + +export const QuoteIcon = ({ className }: IconProps) => ( + +); + +export const CodeBlockIcon = ({ className }: IconProps) => ( + +); + +export const AlignLeftIcon = ({ className }: IconProps) => ( + +); + +export const AlignCenterIcon = ({ className }: IconProps) => ( + +); + +export const HighlightIcon = ({ className }: IconProps) => ( + +); diff --git a/src/components/icons/extended-icons.tsx b/src/components/icons/extended-icons.tsx new file mode 100644 index 0000000..104eae1 --- /dev/null +++ b/src/components/icons/extended-icons.tsx @@ -0,0 +1,474 @@ +import { + PiSlidersHorizontal, + PiWrench, + PiBookOpen, + PiHeadset, + PiArrowDown, + PiArrowRight, + PiArrowUp, + PiProhibit, + PiMoney, + PiBell, + PiLightning, + PiRobot, + PiBroadcast, + PiAppWindow, + PiCalendarDots, + PiCreditCard, + PiChartBar, + PiCheckCircle, + PiCaretUpDown, + PiCaretDown, + PiCaretLeft, + PiCaretUp, + PiCurrencyBtc, + PiDevices, + PiFileText, + PiCircleFill, + PiEnvelope, + PiWarning, + PiArrowSquareOut, + PiEye, + PiFunnel, + PiDotsSix, + PiDotsSixVertical, + PiHeartbeat, + PiClockCounterClockwise, + PiImage, + PiInfinity, + PiLink, + PiMegaphone, + PiMinus, + PiNewspaper, + PiHandshake, + PiPushPin, + PiPlus, + PiPower, + PiQuestion, + PiRepeat, + PiFlag, + PiArrowCounterClockwise, + PiArrowClockwise, + PiRocket, + PiFloppyDisk, + PiPaperPlaneTilt, + PiPercent, + PiHardDrives, + PiGearSix, + PiShareNetwork, + PiSparkle, + PiChartLine, + PiGift, + PiTimer, + PiCircle, + PiTag, + PiTelegramLogo, + PiTicket, + PiGauge, + PiTrash, + PiTrophy, + PiPushPinSlash, + PiUserPlus, + PiUsersThree, + PiVideoCamera, + PiXCircle, + PiX, + PiKey, + PiTray, + PiExport, + PiWarningCircle, + PiCalendarBlank, + PiCalendarStar, + PiChartPie, + PiChartDonut, + PiCpu, + PiMemory, + PiPulse, +} from 'react-icons/pi'; + +import { cn } from '@/lib/utils'; + +interface IconProps { + className?: string; +} + +/** + * Extended cabinet icon set — Phosphor (react-icons/pi), the panel's + * icon family. These cover the icons that used to be hand-written inline across + * feature pages and components. Names match the historical local definitions so + * every page can import from the central barrel instead of redefining SVGs. + */ + +export const AdjustmentsIcon = ({ className }: IconProps) => ( + +); + +export const WrenchIcon = ({ className }: IconProps) => ( + +); + +export const BookOpenIcon = ({ className }: IconProps) => ( + +); + +export const AgentIcon = ({ className }: IconProps) => ( + +); + +export const ArrowDownIcon = ({ className }: IconProps) => ( + +); + +export const ArrowIcon = ({ className }: IconProps) => ( + +); + +export const ArrowUpIcon = ({ className }: IconProps) => ( + +); + +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) => ( + +); + +export const BellIcon = ({ className }: IconProps) => ( + +); + +export const BoltIcon = ({ className }: IconProps) => ( + +); + +export const BotIcon = ({ className }: IconProps) => ( + +); + +export const BroadcastIcon = ({ className }: IconProps) => ( + +); + +export const CabinetIcon = ({ className }: IconProps) => ( + +); + +export const CalendarIcon = ({ className }: IconProps) => ( + +); + +export const CardIcon = ({ className }: IconProps) => ( + +); + +export const ChannelIcon = ({ className }: IconProps) => ( + +); + +export const ChartBarIcon = ({ className }: IconProps) => ( + +); + +export const CheckCircleIcon = ({ className }: IconProps) => ( + +); + +export const ChevronExpandIcon = ({ className }: IconProps) => ( + +); + +export const ChevronIcon = ({ className }: IconProps) => ( + +); + +export const ChevronLeftIcon = ({ className }: IconProps) => ( + +); + +export const ChevronUpIcon = ({ className }: IconProps) => ( + +); + +export const CryptoIcon = ({ className }: IconProps) => ( + +); + +export const DevicesIcon = ({ className }: IconProps) => ( + +); + +export const DocumentIcon = ({ className }: IconProps) => ( + +); + +export const DotIcon = ({ className }: IconProps) => ( + +); + +export const EmailIcon = ({ className }: IconProps) => ( + +); + +export const ExclamationIcon = ({ className }: IconProps) => ( + +); + +export const ExternalLinkIcon = ({ className }: IconProps) => ( + +); + +export const EyeIcon = ({ className }: IconProps) => ; + +export const FileTextIcon = ({ className }: IconProps) => ( + +); + +export const FilterIcon = ({ className }: IconProps) => ( + +); + +export const GripIcon = ({ className }: IconProps) => ( + +); + +export const GripVerticalIcon = ({ className }: IconProps) => ( + +); + +export const HealthIcon = ({ className }: IconProps) => ( + +); + +export const HistoryIcon = ({ className }: IconProps) => ( + +); + +export const ImageIcon = ({ className }: IconProps) => ( + +); + +export const InfinityIcon = ({ className }: IconProps) => ( + +); + +export const LinkIcon = ({ className }: IconProps) => ( + +); + +export const MailIcon = ({ className }: IconProps) => ( + +); + +export const MegaphoneIcon = ({ className }: IconProps) => ( + +); + +export const MinusIcon = ({ className }: IconProps) => ( + +); + +export const NewsIcon = ({ className }: IconProps) => ( + +); + +export const PartnerIcon = ({ className }: IconProps) => ( + +); + +export const PhotoIcon = ({ className }: IconProps) => ( + +); + +export const PercentIcon = ({ className }: IconProps) => ( + +); + +export const PinIcon = ({ className }: IconProps) => ( + +); + +export const PlusSmallIcon = ({ className }: IconProps) => ( + +); + +export const PowerIcon = ({ className }: IconProps) => ( + +); + +export const QuestionIcon = ({ className }: IconProps) => ( + +); + +export const RepeatIcon = ({ className }: IconProps) => ( + +); + +export const ReportIcon = ({ className }: IconProps) => ( + +); + +export const ResetIcon = ({ className }: IconProps) => ( + +); + +export const RestartIcon = ({ className }: IconProps) => ( + +); + +export const RocketIcon = ({ className }: IconProps) => ( + +); + +export const SaveIcon = ({ className }: IconProps) => ( + +); + +export const SendIcon = ({ className }: IconProps) => ( + +); + +export const ServerSmallIcon = ({ className }: IconProps) => ( + +); + +export const SettingsIcon = ({ className }: IconProps) => ( + +); + +export const ShareIcon = ({ className }: IconProps) => ( + +); + +export const SparklesIcon = ({ className }: IconProps) => ( + +); + +export const StatBotIcon = ({ className }: IconProps) => ( + +); + +export const StatCabinetIcon = ({ className }: IconProps) => ( + +); + +export const StatPaidIcon = ({ className }: IconProps) => ( + +); + +export const StatsChartIcon = ({ className }: IconProps) => ( + +); + +export const StatTrialIcon = ({ className }: IconProps) => ( + +); + +export const StatUptimeIcon = ({ className }: IconProps) => ( + +); + +export const StatusIcon = ({ className }: IconProps) => ( + +); + +export const TagIcon = ({ className }: IconProps) => ; + +export const TelegramIcon = ({ className }: IconProps) => ( + +); + +export const TelegramSmallIcon = ({ className }: IconProps) => ( + +); + +export const TicketIcon = ({ className }: IconProps) => ( + +); + +export const TrafficIcon = ({ className }: IconProps) => ( + +); + +export const TrashSmallIcon = ({ className }: IconProps) => ( + +); + +export const TrophyIcon = ({ className }: IconProps) => ( + +); + +export const UnpinIcon = ({ className }: IconProps) => ( + +); + +export const UserPlusIcon = ({ className }: IconProps) => ( + +); + +export const UsersOnlineIcon = ({ className }: IconProps) => ( + +); + +export const VideoIcon = ({ className }: IconProps) => ( + +); + +export const WarningIcon = ({ className }: IconProps) => ( + +); + +export const XCircleIcon = ({ className }: IconProps) => ( + +); + +export const XCloseIcon = ({ className }: IconProps) => ( + +); + +export const XMarkIcon = ({ className }: IconProps) => ; diff --git a/src/components/icons/index.tsx b/src/components/icons/index.tsx index bbd76ec..1b71d56 100644 --- a/src/components/icons/index.tsx +++ b/src/components/icons/index.tsx @@ -1,611 +1,242 @@ +import type { CSSProperties } from 'react'; +import { + PiArrowLeft, + PiArrowRight, + PiArrowsClockwise, + PiCaretDown, + PiCaretRight, + PiChartBar, + PiChatCircle, + PiCheck, + PiClipboardText, + PiClock, + PiCopy, + PiCreditCard, + PiDownloadSimple, + PiGameController, + PiGearSix, + PiGift, + PiGlobe, + PiHardDrives, + PiHouse, + PiInfo, + PiList, + PiLock, + PiMagnifyingGlass, + PiMegaphone, + PiMoon, + PiPalette, + PiPauseCircle, + PiPencil, + PiPencilSimple, + PiPlay, + PiPlus, + PiShield, + PiSignOut, + PiSparkle, + PiStar, + PiStarFill, + PiDiceFive, + PiStop, + PiSun, + PiTrash, + PiUploadSimple, + PiUser, + PiUsers, + PiWallet, + PiX, +} from 'react-icons/pi'; + import { cn } from '@/lib/utils'; +// Re-export the extended Phosphor icon sets so the whole cabinet imports the +// panel's icon family from a single barrel. +export * from './extended-icons'; +export * from './editor-icons'; + interface IconProps { className?: string; } +/** + * Cabinet icons are thin wrappers over the Remnawave panel's own icon library + * (Phosphor, via `react-icons/pi`, regular weight). Each export keeps the + * historical name + default Tailwind sizing so every consumer keeps working, + * while the cabinet now renders the exact icon set the panel uses instead of + * hand-written SVGs. + * + * react-icons components inherit `currentColor` and accept `className`, so + * Tailwind size (`h-5 w-5`) and text-color utilities control them as before. + */ + // Navigation & Layout export const HomeIcon = ({ className }: IconProps) => ( - - - + ); export const BackIcon = ({ className }: IconProps) => ( - - - + ); export const ChevronRightIcon = ({ className }: IconProps) => ( - - - + ); export const MenuIcon = ({ className }: IconProps) => ( - - - + ); -export const CloseIcon = ({ className }: IconProps) => ( - - - -); +export const CloseIcon = ({ className }: IconProps) => ; export const ChevronDownIcon = ({ className }: IconProps) => ( - - - + ); export const ArrowRightIcon = ({ className }: IconProps) => ( - - - + ); // Actions export const SearchIcon = ({ className }: IconProps) => ( - - - + ); export const PlusIcon = ({ className }: IconProps) => ( - - - + ); export const EditIcon = ({ className }: IconProps) => ( - - - + ); export const PencilIcon = ({ className }: IconProps) => ( - - - + ); export const TrashIcon = ({ className }: IconProps) => ( - - - + ); export const UploadIcon = ({ className }: IconProps) => ( - - - + ); export const DownloadIcon = ({ className }: IconProps) => ( - - - + ); export const RefreshIcon = ({ className, spinning = false, }: IconProps & { spinning?: boolean }) => ( - - - + ); export const SyncIcon = ({ className }: IconProps) => ( - - - + ); // Status export const CheckIcon = ({ className }: IconProps) => ( - - - + ); export const CopyIcon = ({ className }: IconProps) => ( - - - + ); -export const XIcon = ({ className }: IconProps) => ( - - - -); +export const XIcon = ({ className }: IconProps) => ; export const LockIcon = ({ className }: IconProps) => ( - - - + ); export const InfoIcon = ({ className }: IconProps) => ( - - - + ); // User & People export const UserIcon = ({ className }: IconProps) => ( - - - + ); export const UsersIcon = ({ className }: IconProps) => ( - - - + ); export const LogoutIcon = ({ className }: IconProps) => ( - - - + ); // Theme -export const SunIcon = ({ className }: IconProps) => ( - - - -); +export const SunIcon = ({ className }: IconProps) => ; export const MoonIcon = ({ className }: IconProps) => ( - - - + ); export const PaletteIcon = ({ className }: IconProps) => ( - - - + ); // Features & Content export const SubscriptionIcon = ({ className }: IconProps) => ( - - - + ); export const WalletIcon = ({ className }: IconProps) => ( - - - + ); export const ChatIcon = ({ className }: IconProps) => ( - - - + ); export const GiftIcon = ({ className }: IconProps) => ( - - - + ); export const ClockIcon = ({ className }: IconProps) => ( - - - + ); -export const StarIcon = ({ className, filled }: IconProps & { filled?: boolean }) => ( - - - -); +export const StarIcon = ({ className, filled }: IconProps & { filled?: boolean }) => + filled ? ( + + ) : ( + + ); export const GamepadIcon = ({ className }: IconProps) => ( - - - + ); export const ClipboardIcon = ({ className }: IconProps) => ( - - - + ); export const CogIcon = ({ className }: IconProps) => ( - - - - + ); export const WheelIcon = ({ className }: IconProps) => ( - - - + +); + +export const ShieldIcon = ({ className }: IconProps) => ( + ); export const ServerIcon = ({ className }: IconProps) => ( - - - + ); export const CampaignIcon = ({ className }: IconProps) => ( - - - + ); +// Brand mark — the genuine Remnawave panel logo (kept as-is, this is the +// panel's own SVG, not a hand-drawn substitute). export const RemnawaveIcon = ({ className }: IconProps) => ( ( ); 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) => ( - - - + ); export const PlayIcon = ({ className }: IconProps) => ( - - - + ); export const StopIcon = ({ className }: IconProps) => ( - - - + ); export const ArrowPathIcon = ({ className }: IconProps) => ( - - - + ); -export const PauseIcon = ({ className, style }: IconProps & { style?: React.CSSProperties }) => ( - - - +export const PauseIcon = ({ className, style }: IconProps & { style?: CSSProperties }) => ( + ); export const CreditCardIcon = ({ className }: IconProps) => ( - - - + ); diff --git a/src/components/layout/AppShell/AppShell.tsx b/src/components/layout/AppShell/AppShell.tsx index 08a1f87..76bae6f 100644 --- a/src/components/layout/AppShell/AppShell.tsx +++ b/src/components/layout/AppShell/AppShell.tsx @@ -21,173 +21,25 @@ import SuccessNotificationModal from '@/components/SuccessNotificationModal'; import { PromptDialogHost } from '@/components/PromptDialogHost'; import LanguageSwitcher from '@/components/LanguageSwitcher'; import TicketNotificationBell from '@/components/TicketNotificationBell'; -import { SubscriptionIcon, GiftIcon } from '@/components/icons'; +import { + SubscriptionIcon, + GiftIcon, + HomeIcon, + CreditCardIcon, + ChatIcon, + UserIcon, + UsersIcon, + ShieldIcon, + InfoIcon, + LogoutIcon, + SunIcon, + MoonIcon, +} from '@/components/icons'; import { MobileBottomNav } from './MobileBottomNav'; import { AppHeader } from './AppHeader'; import { BackgroundRenderer } from '@/components/backgrounds/BackgroundRenderer'; -// Desktop nav icons -const HomeIcon = ({ className }: { className?: string }) => ( - - - -); - -const CreditCardIcon = ({ className }: { className?: string }) => ( - - - -); - -const ChatIcon = ({ className }: { className?: string }) => ( - - - -); - -const UserIcon = ({ className }: { className?: string }) => ( - - - -); - -const UsersIcon = ({ className }: { className?: string }) => ( - - - -); - -const ShieldIcon = ({ className }: { className?: string }) => ( - - - -); - -const InfoIcon = ({ className }: { className?: string }) => ( - - - -); - -const LogoutIcon = ({ className }: { className?: string }) => ( - - - -); - -const SunIcon = ({ className }: { className?: string }) => ( - - - -); - -const MoonIcon = ({ className }: { className?: string }) => ( - - - -); - interface AppShellProps { children: React.ReactNode; } diff --git a/src/components/news/NewsSection.tsx b/src/components/news/NewsSection.tsx index 0ea43ae..fb78ad5 100644 --- a/src/components/news/NewsSection.tsx +++ b/src/components/news/NewsSection.tsx @@ -6,6 +6,7 @@ import { motion } from 'framer-motion'; import { newsApi } from '../../api/news'; import { useHapticFeedback } from '../../platform/hooks/useHaptic'; import { cn } from '../../lib/utils'; +import { ArrowIcon, NewsIcon } from '@/components/icons'; import type { NewsListItem } from '../../types/news'; // --- Security: hex color validation to prevent CSS injection --- @@ -31,19 +32,6 @@ const fadeSlideUp = { }), }; -// --- Icons --- -const ArrowIcon = () => ( - -); - // --- Sub-components --- interface CategoryBadgeProps { @@ -427,35 +415,7 @@ export default function NewsSection() { >
- +
{t('news.title')} @@ -469,11 +429,20 @@ export default function NewsSection() { {/* Grid */} {items.length > 0 && ( -
+
{featured && } - {regular.map((item, i) => ( - - ))} + {regular.length > 0 && ( +
+ {regular.map((item, i) => ( + + ))} +
+ )}
)} diff --git a/src/components/partner/CampaignCard.tsx b/src/components/partner/CampaignCard.tsx index 6515d68..0c58077 100644 --- a/src/components/partner/CampaignCard.tsx +++ b/src/components/partner/CampaignCard.tsx @@ -1,6 +1,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { PiCaretDown } from 'react-icons/pi'; +import { CheckIcon, CopyIcon } from '@/components/icons'; import type { PartnerCampaignInfo } from '../../api/partners'; import { PARTNER_STATS } from '../../constants/partner'; import { useCurrency } from '../../hooks/useCurrency'; @@ -9,32 +11,8 @@ import { copyToClipboard } from '../../utils/clipboard'; import { CampaignDetailStats } from './CampaignDetailStats'; import { StatCard } from '../stats/StatCard'; -const CopyIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( - - - + ); interface CampaignCardProps { diff --git a/src/components/primitives/Command/Command.tsx b/src/components/primitives/Command/Command.tsx index e611f6c..91b9825 100644 --- a/src/components/primitives/Command/Command.tsx +++ b/src/components/primitives/Command/Command.tsx @@ -1,20 +1,8 @@ import { Command as CommandPrimitive } from 'cmdk'; import { forwardRef, type ComponentPropsWithoutRef, type HTMLAttributes } from 'react'; +import { SearchIcon } from '@/components/icons'; import { cn } from '@/lib/utils'; -// Search icon -const SearchIcon = () => ( - - - -); - // Root Command export type CommandProps = ComponentPropsWithoutRef; diff --git a/src/components/primitives/Dialog/Dialog.tsx b/src/components/primitives/Dialog/Dialog.tsx index 1df7aa6..79cbc88 100644 --- a/src/components/primitives/Dialog/Dialog.tsx +++ b/src/components/primitives/Dialog/Dialog.tsx @@ -8,6 +8,7 @@ import { useState, } from 'react'; import { cn } from '@/lib/utils'; +import { CloseIcon } from '@/components/icons'; import { backdrop, backdropTransition, scale, scaleTransition } from '../../motion/transitions'; export { @@ -16,19 +17,6 @@ export { Close as DialogClose, } from '@radix-ui/react-dialog'; -// Close icon -const CloseIcon = () => ( - - - -); - // Context for AnimatePresence const DialogContext = createContext<{ open: boolean }>({ open: false }); diff --git a/src/components/primitives/DropdownMenu/DropdownMenu.tsx b/src/components/primitives/DropdownMenu/DropdownMenu.tsx index 78e8c67..97fbc28 100644 --- a/src/components/primitives/DropdownMenu/DropdownMenu.tsx +++ b/src/components/primitives/DropdownMenu/DropdownMenu.tsx @@ -1,6 +1,7 @@ import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; import { motion } from 'framer-motion'; import { forwardRef, type ComponentPropsWithoutRef } from 'react'; +import { CheckIcon, ChevronRightIcon, DotIcon } from '@/components/icons'; import { cn } from '@/lib/utils'; import { usePlatform } from '@/platform'; import { dropdown, dropdownTransition } from '../../motion/transitions'; @@ -14,37 +15,6 @@ export { RadioGroup as DropdownMenuRadioGroup, } from '@radix-ui/react-dropdown-menu'; -// Icons -const CheckIcon = () => ( - - - -); - -const ChevronRightIcon = () => ( - - - -); - -const DotIcon = () => ( - - - -); - // SubTrigger export interface DropdownMenuSubTriggerProps extends ComponentPropsWithoutRef< typeof DropdownMenuPrimitive.SubTrigger diff --git a/src/components/primitives/Popover/Popover.tsx b/src/components/primitives/Popover/Popover.tsx index 64f3e71..194fee1 100644 --- a/src/components/primitives/Popover/Popover.tsx +++ b/src/components/primitives/Popover/Popover.tsx @@ -1,6 +1,7 @@ import * as PopoverPrimitive from '@radix-ui/react-popover'; import { motion } from 'framer-motion'; import { forwardRef, type ComponentPropsWithoutRef } from 'react'; +import { CloseIcon } from '@/components/icons'; import { cn } from '@/lib/utils'; import { dropdown, dropdownTransition } from '../../motion/transitions'; @@ -11,19 +12,6 @@ export { Close as PopoverClose, } from '@radix-ui/react-popover'; -// Close icon -const CloseIcon = () => ( - - - -); - // Content export interface PopoverContentProps extends ComponentPropsWithoutRef< typeof PopoverPrimitive.Content diff --git a/src/components/primitives/Select/Select.tsx b/src/components/primitives/Select/Select.tsx index 7b65634..d902541 100644 --- a/src/components/primitives/Select/Select.tsx +++ b/src/components/primitives/Select/Select.tsx @@ -2,36 +2,12 @@ import * as SelectPrimitive from '@radix-ui/react-select'; import { motion } from 'framer-motion'; import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react'; import { cn } from '@/lib/utils'; +import { CheckIcon, ChevronDownIcon } from '@/components/icons'; import { usePlatform } from '@/platform'; import { dropdown, dropdownTransition } from '../../motion/transitions'; export { Root as Select, Group as SelectGroup } from '@radix-ui/react-select'; -// Icons -const ChevronDownIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - // Trigger export interface SelectTriggerProps extends ComponentPropsWithoutRef< typeof SelectPrimitive.Trigger diff --git a/src/components/primitives/Sheet/Sheet.tsx b/src/components/primitives/Sheet/Sheet.tsx index fe9d95b..33ef43f 100644 --- a/src/components/primitives/Sheet/Sheet.tsx +++ b/src/components/primitives/Sheet/Sheet.tsx @@ -8,6 +8,7 @@ import { useState, useCallback, } from 'react'; +import { CloseIcon } from '@/components/icons'; import { cn } from '@/lib/utils'; import { usePlatform } from '@/platform'; import { @@ -23,19 +24,6 @@ export { Close as SheetClose, } from '@radix-ui/react-dialog'; -// Close icon -const CloseIcon = () => ( - - - -); - // Context for AnimatePresence and control interface SheetContextValue { open: boolean; diff --git a/src/components/subscription/PurchaseCTAButton.tsx b/src/components/subscription/PurchaseCTAButton.tsx index 779d367..3d3b515 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 { ChevronRightIcon, 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}
@@ -96,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/components/wheel/FortuneWheel.tsx b/src/components/wheel/FortuneWheel.tsx index 4563a27..3fd8de1 100644 --- a/src/components/wheel/FortuneWheel.tsx +++ b/src/components/wheel/FortuneWheel.tsx @@ -8,13 +8,6 @@ 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`, -})); - const FortuneWheel = memo(function FortuneWheel({ prizes, isSpinning, @@ -206,6 +199,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 */} @@ -262,9 +270,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} @@ -400,25 +408,6 @@ const FortuneWheel = memo(function FortuneWheel({ /> )}
- - {/* Sparkle effects when spinning - optimized with pre-calculated positions */} - {isSpinning && ( -
- {SPARKLE_POSITIONS.map((pos, i) => ( -
- ))} -
- )}
); }); 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..bf8081d 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": "Не настроено", @@ -1645,6 +1645,7 @@ "totalUpload": "Отдача", "totalTraffic": "Всего", "online": "онлайн", + "realtimeTitle": "Текущий трафик", "inbounds": "Inbounds", "outbounds": "Outbounds" }, @@ -1744,9 +1745,9 @@ "success": "Успешно", "runAutoSyncNow": "Запустить автосинхронизацию", "fromPanel": "Из панели", - "fromPanelDesc": "Импорт пользователей из RemnaWave в бота", + "fromPanelDesc": "Импорт пользователей из Remnawave в бота", "toPanel": "В панель", - "toPanelDesc": "Экспорт пользователей из бота в RemnaWave" + "toPanelDesc": "Экспорт пользователей из бота в Remnawave" } }, "wheel": { @@ -2145,7 +2146,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 +2356,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 +2647,7 @@ "SQLITE": "SQLite", "REDIS": "Redis", "CORE": "Бот", - "REMNAWAVE": "RemnaWave", + "REMNAWAVE": "Remnawave", "SERVER_STATUS": "Статус сервера", "MONITORING": "Мониторинг", "MAINTENANCE": "Обслуживание", @@ -2765,16 +2766,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 +2927,7 @@ "daysShort": "дн.", "serversTitle": "Серверы", "externalSquadTitle": "Внешний сквад", - "externalSquadHint": "Назначьте внешний сквад RemnaWave для пользователей этого тарифа. При создании подписки пользователь будет автоматически добавлен в выбранный сквад.", + "externalSquadHint": "Назначьте внешний сквад Remnawave для пользователей этого тарифа. При создании подписки пользователь будет автоматически добавлен в выбранный сквад.", "noExternalSquad": "Без внешнего сквада", "externalSquadUsers": "пользователей", "serversTabHint": "Выберите серверы, доступные на этом тарифе.", @@ -3005,7 +3006,7 @@ "sync": "Синхронизировать", "syncing": "Синхронизация...", "syncNow": "Синхронизировать сейчас", - "noServers": "Нет серверов. Синхронизируйте с RemnaWave.", + "noServers": "Нет серверов. Синхронизируйте с Remnawave.", "edit": "Редактировать сервер", "originalName": "Оригинальное название", "displayName": "Отображаемое название", @@ -3976,7 +3977,7 @@ "deleteUser": "Удалить пользователей" }, "deleteSubscription": { - "warning": "Выбранные подписки будут безвозвратно удалены из бота и панели RemnaWave!", + "warning": "Выбранные подписки будут безвозвратно удалены из бота и панели Remnawave!", "hint": "Пользователи потеряют доступ к VPN по удалённым подпискам. Это действие нельзя отменить.", "activePaidWarning": "{{count}} из {{total}} выбранных подписок: активные платные", "forceDeleteConfirm": "Подтверждаю удаление активных платных подписок" @@ -3984,7 +3985,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 fb7abe2..27f2647 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(); @@ -10,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, @@ -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')}

@@ -67,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/AdminAuditLog.tsx b/src/pages/AdminAuditLog.tsx index 85197bd..e90bbc8 100644 --- a/src/pages/AdminAuditLog.tsx +++ b/src/pages/AdminAuditLog.tsx @@ -6,78 +6,14 @@ import type { TFunction } from 'i18next'; import { rbacApi, AuditLogEntry, AuditLogFilters } from '@/api/rbac'; import { PermissionGate } from '@/components/auth/PermissionGate'; import { usePlatform } from '@/platform/hooks/usePlatform'; - -// === Icons === - -const BackIcon = () => ( - - - -); - -const DownloadIcon = () => ( - - - -); - -const FilterIcon = () => ( - - - -); - -const ChevronDownIcon = ({ className }: { className?: string }) => ( - - - -); - -const RefreshIcon = ({ className }: { className?: string }) => ( - - - -); - -const SearchIcon = () => ( - - - -); +import { + BackIcon, + ChevronDownIcon, + DownloadIcon, + FilterIcon, + RefreshIcon, + SearchIcon, +} from '@/components/icons'; // === Constants === diff --git a/src/pages/AdminBanSystem.tsx b/src/pages/AdminBanSystem.tsx index 2925d39..46d9842 100644 --- a/src/pages/AdminBanSystem.tsx +++ b/src/pages/AdminBanSystem.tsx @@ -20,137 +20,24 @@ import { type BanHealthResponse, } from '../api/banSystem'; -// Icons -const ShieldIcon = () => ( - - - -); - -const UsersIcon = () => ( - - - -); - -const BanIcon = () => ( - - - -); - -const ServerIcon = () => ( - - - -); - -const AgentIcon = () => ( - - - -); - -const WarningIcon = () => ( - - - -); - -const RefreshIcon = () => ( - - - -); - -const ChartIcon = () => ( - - - -); - -const SearchIcon = () => ( - - - -); - -const SettingsIcon = () => ( - - - - -); - -const TrafficIcon = () => ( - - - -); - -const ReportIcon = () => ( - - - -); - -const HealthIcon = () => ( - - - -); +import { + ShieldIcon, + UsersIcon, + BanIcon, + ServerIcon, + AgentIcon, + WarningIcon, + RefreshIcon, + ChartIcon, + SearchIcon, + SettingsIcon, + TrafficIcon, + ReportIcon, + HealthIcon, + ExclamationIcon, + BackIcon, + XIcon, +} from '@/components/icons'; type TabType = | 'dashboard' @@ -475,7 +362,11 @@ export default function AdminBanSystem() { }; const tabs = [ - { id: 'dashboard' as TabType, label: t('banSystem.tabs.dashboard'), icon: }, + { + id: 'dashboard' as TabType, + label: t('banSystem.tabs.dashboard'), + icon: , + }, { id: 'users' as TabType, label: t('banSystem.tabs.users'), icon: }, { id: 'punishments' as TabType, label: t('banSystem.tabs.punishments'), icon: }, { id: 'nodes' as TabType, label: t('banSystem.tabs.nodes'), icon: }, @@ -505,34 +396,10 @@ export default function AdminBanSystem() {
- - - +
- - - +
@@ -566,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')}
@@ -601,7 +456,7 @@ export default function AdminBanSystem() {
- +

{t('banSystem.title')}

@@ -613,7 +468,7 @@ export default function AdminBanSystem() { disabled={loading} className="flex items-center gap-2 rounded-lg bg-dark-800 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-700 hover:text-dark-100 disabled:opacity-50" > - + {t('common.refresh')}
@@ -683,7 +538,7 @@ export default function AdminBanSystem() { } + icon={} color="accent" /> } + icon={} color="info" />
@@ -930,7 +785,7 @@ export default function AdminBanSystem() { } + icon={} color="accent" /> - - - +
diff --git a/src/pages/AdminBroadcastCreate.tsx b/src/pages/AdminBroadcastCreate.tsx index c175cee..8ed513a 100644 --- a/src/pages/AdminBroadcastCreate.tsx +++ b/src/pages/AdminBroadcastCreate.tsx @@ -11,95 +11,18 @@ import { } from '../api/adminBroadcasts'; import { AdminBackButton } from '../components/admin'; import { TelegramPreview, EmailPreview } from '../components/broadcasts/BroadcastPreview'; - -// Icons -const BroadcastIcon = () => ( - - - -); - -const XIcon = () => ( - - - -); - -const RefreshIcon = () => ( - - - -); - -const PhotoIcon = () => ( - - - -); - -const VideoIcon = () => ( - - - -); - -const DocumentIcon = () => ( - - - -); - -const UsersIcon = () => ( - - - -); - -const ChevronDownIcon = () => ( - - - -); - -const TelegramIcon = () => ( - - - -); - -const EmailIcon = () => ( - - - -); +import { + BroadcastIcon, + ChevronDownIcon, + DocumentIcon, + EmailIcon, + PhotoIcon, + RefreshIcon, + TelegramIcon, + UsersIcon, + VideoIcon, + XIcon, +} from '@/components/icons'; // Filter labels const FILTER_GROUP_LABEL_KEYS: Record = { @@ -173,7 +96,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', 'Подписка'), @@ -538,7 +464,7 @@ export default function AdminBroadcastCreate() { )}
- + {showFilters && ( @@ -581,7 +507,7 @@ export default function AdminBroadcastCreate() {
- +

{t('admin.broadcasts.create')}

@@ -727,7 +653,7 @@ export default function AdminBroadcastCreate() { className="rounded-lg p-2 text-dark-400 hover:bg-dark-700 hover:text-error-400" disabled={isUploading} > - +
{mediaPreview && ( @@ -813,7 +739,7 @@ export default function AdminBroadcastCreate() { onClick={() => removeCustomButton(index)} className="ml-2 shrink-0 rounded p-1 text-dark-400 hover:bg-dark-700 hover:text-error-400" > - +
))} @@ -1013,7 +939,7 @@ export default function AdminBroadcastCreate() { disabled={!isValid || isPending || isUploading} className="btn-primary flex items-center gap-2" > - {isPending ? : } + {isPending ? : } {t('admin.broadcasts.send')} diff --git a/src/pages/AdminBroadcastDetail.tsx b/src/pages/AdminBroadcastDetail.tsx index 86754c7..469809f 100644 --- a/src/pages/AdminBroadcastDetail.tsx +++ b/src/pages/AdminBroadcastDetail.tsx @@ -3,74 +3,15 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts'; import { AdminBackButton } from '../components/admin'; - -// Icons - -const StopIcon = () => ( - - - -); - -const PhotoIcon = () => ( - - - -); - -const VideoIcon = () => ( - - - -); - -const DocumentIcon = () => ( - - - -); - -const RefreshIcon = () => ( - - - -); - -const TelegramIcon = () => ( - - - -); - -const EmailIcon = () => ( - - - -); +import { + DocumentIcon, + EmailIcon, + PhotoIcon, + RefreshIcon, + StopIcon, + TelegramIcon, + VideoIcon, +} from '@/components/icons'; // Channel badge component function ChannelBadge({ channel }: { channel?: BroadcastChannel }) { @@ -240,7 +181,7 @@ export default function AdminBroadcastDetail() { onClick={() => refetch()} className="rounded-lg p-2 transition-colors hover:bg-dark-700" > - + diff --git a/src/pages/AdminBroadcasts.tsx b/src/pages/AdminBroadcasts.tsx index cab9f08..09f752f 100644 --- a/src/pages/AdminBroadcasts.tsx +++ b/src/pages/AdminBroadcasts.tsx @@ -4,76 +4,15 @@ import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { adminBroadcastsApi } from '../api/adminBroadcasts'; import { usePlatform } from '../platform/hooks/usePlatform'; - -// Icons - -const BackIcon = () => ( - - - -); - -const BroadcastIcon = () => ( - - - -); - -const PlusIcon = () => ( - - - -); - -const RefreshIcon = () => ( - - - -); - -const PhotoIcon = () => ( - - - -); - -const VideoIcon = () => ( - - - -); - -const DocumentIcon = () => ( - - - -); +import { + BackIcon, + BroadcastIcon, + DocumentIcon, + PhotoIcon, + PlusIcon, + RefreshIcon, + VideoIcon, +} from '@/components/icons'; // Status badge component const statusConfig: Record = { @@ -194,7 +133,7 @@ export default function AdminBroadcasts() { ) : broadcasts.length === 0 ? (
- +

{t('admin.broadcasts.empty')}

) : ( diff --git a/src/pages/AdminBulkActions.tsx b/src/pages/AdminBulkActions.tsx index 66aba4c..5618a44 100644 --- a/src/pages/AdminBulkActions.tsx +++ b/src/pages/AdminBulkActions.tsx @@ -20,9 +20,18 @@ import { type BulkActionType, type BulkActionParams, } from '../api/adminBulkActions'; +import { PiCaretDown } from 'react-icons/pi'; import { usePlatform } from '../platform/hooks/usePlatform'; import { useCurrency } from '../hooks/useCurrency'; import { cn } from '@/lib/utils'; +import { + BackIcon, + CheckIcon, + ChevronLeftIcon, + ChevronRightIcon, + RefreshIcon, + SearchIcon, +} from '@/components/icons'; import { ActionModal, type ModalState } from '@/components/admin/bulkActions/ActionModal'; import { DropdownSelect, type DropdownOption } from '@/components/admin/bulkActions/DropdownSelect'; import { FloatingActionBar } from '@/components/admin/bulkActions/FloatingActionBar'; @@ -41,77 +50,15 @@ type SubscriptionStatusFilter = '' | 'active' | 'expired' | 'trial' | 'limited' // (ProgressState + ModalState moved into ; ModalState is re-exported.) -// Local CheckIcon — still used by MultiSelectDropdown checkboxes and -// the user-table column defs (not the modal). Kept here rather than -// re-exported from a shared icons module. -const CheckIcon = () => ( - - - -); - // ============ Icons ============ -const BackIcon = () => ( - - - -); - -const SearchIcon = () => ( - - - -); - -const ChevronLeftIcon = () => ( - - - -); - -const ChevronRightIcon = () => ( - - - -); - -const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( - - - -); - +// ChevronExpandIcon keeps a custom `expanded` prop (not the barrel's +// className-only API), so it stays local as a thin wrapper over the panel's +// Phosphor caret, preserving the rotate-on-expand behavior. const ChevronExpandIcon = ({ expanded }: { expanded: boolean }) => ( - - - + /> ); // (SUBSCRIPTION_LEVEL_ACTIONS + isSubscriptionLevelAction moved into ./bulkActions/actionTargets.ts) @@ -632,7 +579,7 @@ export default function AdminBulkActions() { aria-label={t('admin.bulkActions.selectAll')} title={t('admin.bulkActions.selectAll')} > - {table.getIsAllRowsSelected() && } + {table.getIsAllRowsSelected() && } {table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() && (
)} @@ -652,7 +599,7 @@ export default function AdminBulkActions() { aria-label={t('admin.bulkActions.selectAllSubs')} title={t('admin.bulkActions.selectAllSubs')} > - {allSubsSelected && } + {allSubsSelected && } {someSubsSelected &&
} )} @@ -678,7 +625,7 @@ export default function AdminBulkActions() { : t('admin.bulkActions.selectUser', { name: userName }) } > - {row.getIsSelected() && } + {row.getIsSelected() && }
); @@ -988,7 +935,7 @@ export default function AdminBulkActions() { )} aria-pressed={trialOnly} > - {trialOnly && } + {trialOnly && } ( - - - -); - -const CheckIcon = () => ( - - - -); - -const RefreshIcon = () => ( - - - -); +import { CampaignIcon, CheckIcon, LinkIcon, RefreshIcon } from '@/components/icons'; // Bonus type config const bonusTypeConfig: Record< @@ -283,7 +258,7 @@ export default function AdminCampaignCreate() {
- +

@@ -298,19 +273,7 @@ export default function AdminCampaignCreate() { {partnerId && partner && (
- - - +

{t('admin.campaigns.form.partnerAutoAssign', { name: partner.first_name || partner.username || `#${partnerId}`, @@ -556,7 +519,7 @@ export default function AdminCampaignCreate() { disabled={!isValid || createMutation.isPending} className="btn-primary flex items-center gap-2" > - {createMutation.isPending ? : } + {createMutation.isPending ? : } {t('admin.campaigns.form.save')}

diff --git a/src/pages/AdminCampaignStats.tsx b/src/pages/AdminCampaignStats.tsx index 3aa00df..e4eb79b 100644 --- a/src/pages/AdminCampaignStats.tsx +++ b/src/pages/AdminCampaignStats.tsx @@ -10,47 +10,7 @@ import { PARTNER_STATS } from '../constants/partner'; import { useCurrency } from '../hooks/useCurrency'; import { copyToClipboard } from '../utils/clipboard'; import { useHaptic } from '../platform'; - -// Icons -const CopyIcon = () => ( - - - -); - -const LinkIcon = () => ( - - - -); - -const UsersIcon = () => ( - - - -); - -const ChartIcon = () => ( - - - -); +import { ChartIcon, ChevronDownIcon, CopyIcon, LinkIcon, UsersIcon } from '@/components/icons'; // Bonus type config const bonusTypeConfig: Record< @@ -205,7 +165,7 @@ export default function AdminCampaignStats() {
- +

{stats.name}

@@ -240,7 +200,7 @@ export default function AdminCampaignStats() {
- + {stats.deep_link}
- - - + /> {showUsers && ( diff --git a/src/pages/AdminCampaigns.tsx b/src/pages/AdminCampaigns.tsx index 85f09a3..0993485 100644 --- a/src/pages/AdminCampaigns.tsx +++ b/src/pages/AdminCampaigns.tsx @@ -4,7 +4,15 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tansta import { useTranslation } from 'react-i18next'; import i18n from '../i18n'; import { campaignsApi, CampaignListItem, CampaignBonusType } from '../api/campaigns'; -import { PlusIcon, EditIcon, TrashIcon, CheckIcon, XIcon, ChartIcon } from '../components/icons'; +import { + PlusIcon, + EditIcon, + TrashIcon, + CheckIcon, + XIcon, + ChartIcon, + BackIcon, +} from '../components/icons'; import { usePlatform } from '../platform/hooks/usePlatform'; import { useFocusTrap } from '../hooks/useFocusTrap'; @@ -37,19 +45,6 @@ const bonusTypeConfig: Record< }, }; -// Icons -const BackIcon = () => ( - - - -); - // Locale mapping for formatting const localeMap: Record = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' }; diff --git a/src/pages/AdminChannelSubscriptions.tsx b/src/pages/AdminChannelSubscriptions.tsx index a057894..e931751 100644 --- a/src/pages/AdminChannelSubscriptions.tsx +++ b/src/pages/AdminChannelSubscriptions.tsx @@ -12,86 +12,17 @@ import { import { adminSettingsApi, type SettingDefinition } from '../api/adminSettings'; import { AdminBackButton } from '../components/admin'; import { Toggle } from '../components/admin/Toggle'; - -// Icons -const ChannelIcon = () => ( - - - -); - -const PlusIcon = () => ( - - - -); - -const RefreshIcon = () => ( - - - -); - -const TrashIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - -const XIcon = () => ( - - - -); - -const EditIcon = () => ( - - - -); - -const LinkIcon = () => ( - - - -); - -const SettingsIcon = () => ( - - - - -); +import { + ChannelIcon, + PlusIcon, + RefreshIcon, + TrashIcon, + CheckIcon, + XIcon, + EditIcon, + LinkIcon, + SettingsIcon, +} from '@/components/icons'; // Setting toggle row for global settings const CHANNEL_SETTING_KEYS = [ @@ -266,7 +197,7 @@ function ChannelCard({ {/* Link */} {hasLink && ( @@ -688,7 +619,7 @@ export default function AdminChannelSubscriptions() {
- +

@@ -752,7 +683,7 @@ export default function AdminChannelSubscriptions() { ) : channels.length === 0 ? (
- +

{t('admin.channelSubscriptions.empty')}

diff --git a/src/pages/AdminDashboard.tsx b/src/pages/AdminDashboard.tsx index c0fd632..829a5b0 100644 --- a/src/pages/AdminDashboard.tsx +++ b/src/pages/AdminDashboard.tsx @@ -9,152 +9,23 @@ const CABINET_VERSION = __APP_VERSION__; import { useCurrency } from '../hooks/useCurrency'; import { usePlatform } from '../platform/hooks/usePlatform'; -// Icons - styled like main navigation - -const BackIcon = () => ( - - - -); - -const ServerIcon = () => ( - - - -); - -const UsersOnlineIcon = () => ( - - - -); - -const WalletIcon = () => ( - - - -); - -const ChartBarIcon = () => ( - - - -); - -const SparklesIcon = () => ( - - - -); - -const TagIcon = () => ( - - - - -); - -const RefreshIcon = () => ( - - - -); - -const PowerIcon = () => ( - - - -); - -const RestartIcon = () => ( - - - -); - -const ChevronDownIcon = () => ( - - - -); - -const UsersIcon = () => ( - - - -); - -const MegaphoneIcon = () => ( - - - -); - -const BanknotesIcon = () => ( - - - -); - -const ExclamationIcon = () => ( - - - -); +import { + BackIcon, + BanknotesIcon, + ChartBarIcon, + ChevronDownIcon, + ExclamationIcon, + MegaphoneIcon, + PowerIcon, + RefreshIcon, + RestartIcon, + ServerIcon, + SparklesIcon, + TagIcon, + UsersIcon, + UsersOnlineIcon, + WalletIcon, +} from '@/components/icons'; interface StatCardProps { title: string; @@ -265,7 +136,7 @@ function NodeCard({ node, onRestart, onToggle, isLoading }: NodeCardProps) { {hasError && (
- + {node.last_status_message}
@@ -296,7 +167,7 @@ function NodeCard({ node, onRestart, onToggle, isLoading }: NodeCardProps) { : 'bg-warning-500/20 text-warning-400 hover:bg-warning-500/30' } disabled:opacity-50`} > - + {node.is_disabled ? t('adminDashboard.nodes.enable') : t('adminDashboard.nodes.disable')}

@@ -464,7 +335,7 @@ export default function AdminDashboard() { disabled={loading} className="flex items-center gap-2 rounded-lg bg-dark-800 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-700 hover:text-dark-100 disabled:opacity-50" > - + {t('adminDashboard.refresh')}
diff --git a/src/pages/AdminEmailTemplates.tsx b/src/pages/AdminEmailTemplates.tsx index 7fcb07f..34b964d 100644 --- a/src/pages/AdminEmailTemplates.tsx +++ b/src/pages/AdminEmailTemplates.tsx @@ -13,6 +13,7 @@ import { useIsTelegram } from '../platform/hooks/usePlatform'; import { useNativeDialog } from '../platform/hooks/useNativeDialog'; import { useNotify } from '@/platform'; import { copyToClipboard } from '@/utils/clipboard'; +import { MailIcon, SaveIcon, EyeIcon, SendIcon, ResetIcon } from '@/components/icons'; // Hook to check if on mobile function useIsMobile() { @@ -30,55 +31,6 @@ function useIsMobile() { return isMobile; } -// Icons - -const MailIcon = () => ( - - - -); - -const SaveIcon = () => ( - - - -); - -const EyeIcon = () => ( - - - - -); - -const SendIcon = () => ( - - - -); - -const ResetIcon = () => ( - - - -); - const LANG_LABELS: Record = { ru: 'RU', en: 'EN', @@ -390,7 +342,7 @@ function TemplateEditor({ disabled={!isDirty || saveMutation.isPending} className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-accent-500 px-3 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-40 sm:px-4 sm:py-2" > - + {saveMutation.isPending ? t('common.loading') : t('common.save')} @@ -412,7 +364,7 @@ function TemplateEditor({ }} className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 sm:px-4 sm:py-2" > - + {t('admin.emailTemplates.preview')}
@@ -423,7 +375,7 @@ function TemplateEditor({ disabled={testMutation.isPending} className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:px-4 sm:py-2" > - + {testMutation.isPending ? t('common.loading') : t('admin.emailTemplates.sendTest')} @@ -437,7 +389,7 @@ function TemplateEditor({ disabled={resetMutation.isPending} className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-warning-400 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:ml-auto sm:px-4 sm:py-2" > - + {t('admin.emailTemplates.resetDefault')} )} @@ -474,7 +426,7 @@ export default function AdminEmailTemplates() {
- +

diff --git a/src/pages/AdminInfoPageEditor.tsx b/src/pages/AdminInfoPageEditor.tsx index b875985..4fcc4f6 100644 --- a/src/pages/AdminInfoPageEditor.tsx +++ b/src/pages/AdminInfoPageEditor.tsx @@ -18,102 +18,35 @@ import { AdminBackButton } from '../components/admin'; import { Toggle } from '../components/admin/Toggle'; import { useHapticFeedback } from '../platform/hooks/useHaptic'; import { cn } from '../lib/utils'; +import { + BoldIcon, + ItalicIcon, + UnderlineIcon, + StrikeIcon, + ListBulletIcon, + ListOrderedIcon, + QuoteIcon, + CodeBlockIcon, + ImageIcon, + LinkIcon, + AlignLeftIcon, + AlignCenterIcon, + HighlightIcon, + ChevronUpIcon, + ChevronDownIcon, + TrashSmallIcon, + PlusSmallIcon, +} from '@/components/icons'; import type { InfoPageType, FaqItem, ReplacesTab } from '../api/infoPages'; const AVAILABLE_LOCALES = ['ru', 'en', 'zh', 'fa'] as const; type LocaleCode = (typeof AVAILABLE_LOCALES)[number]; // --- Icons --- -const BoldIcon = () => ( - - - -); - -const ItalicIcon = () => ( - - - -); - -const UnderlineIcon = () => ( - - - -); - -const StrikeIcon = () => ( - - - -); - const H1Icon = () => H1; const H2Icon = () => H2; const H3Icon = () => H3; -const ListBulletIcon = () => ( - - - -); - -const ListOrderedIcon = () => ( - - - -); - -const QuoteIcon = () => ( - - - -); - -const CodeBlockIcon = () => ( - - - -); - -const ImageIcon = () => ( - - - -); - -const LinkIcon = () => ( - - - -); - -const AlignLeftIcon = () => ( - - - -); - -const AlignCenterIcon = () => ( - - - -); - -const HighlightIcon = () => ( - - - -); - // --- Toolbar Button --- interface ToolbarButtonProps { onClick: () => void; @@ -206,35 +139,6 @@ function generateSlug(title: string): string { .substring(0, 100); } -// --- FAQ Q&A Item Icons --- -const ChevronUpIcon = () => ( - - - -); - -const ChevronDownIcon = () => ( - - - -); - -const TrashSmallIcon = () => ( - - - -); - -const PlusSmallIcon = () => ( - - - -); - // --- FAQ Answer Rich Editor --- const FAQ_EDITOR_EXTENSIONS = [ @@ -415,28 +319,28 @@ function FaqAnswerEditor({ value, onChange }: { value: string; onChange: (html: isActive={editor.isActive('bold')} title={t('news.admin.toolbar.bold')} > - + editor.chain().focus().toggleItalic().run()} isActive={editor.isActive('italic')} title={t('news.admin.toolbar.italic')} > - + editor.chain().focus().toggleUnderline().run()} isActive={editor.isActive('underline')} title={t('news.admin.toolbar.underline')} > - + editor.chain().focus().toggleStrike().run()} isActive={editor.isActive('strike')} title={t('news.admin.toolbar.strikethrough')} > - +
- + editor.chain().focus().toggleBlockquote().run()} isActive={editor.isActive('blockquote')} title={t('news.admin.toolbar.blockquote')} > - +
- + - + mediaRef.current?.click()} @@ -486,7 +390,7 @@ function FaqAnswerEditor({ value, onChange }: { value: string; onChange: (html: {isUploading ? (
) : ( - + )}
@@ -660,7 +564,7 @@ function FaqBuilder({ items, onChange, locale, localeLabel }: FaqBuilderProps) { className="min-h-[44px] min-w-[44px] rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:cursor-not-allowed disabled:opacity-30" title={t('admin.infoPages.faq.moveUp')} > - +
@@ -1333,28 +1237,28 @@ export default function AdminInfoPageEditor() { isActive={editor.isActive('bold')} title={t('news.admin.toolbar.bold')} > - + editor.chain().focus().toggleItalic().run()} isActive={editor.isActive('italic')} title={t('news.admin.toolbar.italic')} > - + editor.chain().focus().toggleUnderline().run()} isActive={editor.isActive('underline')} title={t('news.admin.toolbar.underline')} > - + editor.chain().focus().toggleStrike().run()} isActive={editor.isActive('strike')} title={t('news.admin.toolbar.strikethrough')} > - +
@@ -1395,21 +1299,21 @@ export default function AdminInfoPageEditor() { isActive={editor.isActive('orderedList')} title={t('news.admin.toolbar.orderedList')} > - + editor.chain().focus().toggleBlockquote().run()} isActive={editor.isActive('blockquote')} title={t('news.admin.toolbar.blockquote')} > - + editor.chain().focus().toggleCodeBlock().run()} isActive={editor.isActive('codeBlock')} title={t('news.admin.toolbar.codeBlock')} > - +
@@ -1419,14 +1323,14 @@ export default function AdminInfoPageEditor() { isActive={editor.isActive({ textAlign: 'left' })} title={t('news.admin.toolbar.alignLeft')} > - + editor.chain().focus().setTextAlign('center').run()} isActive={editor.isActive({ textAlign: 'center' })} title={t('news.admin.toolbar.alignCenter')} > - +
@@ -1436,10 +1340,10 @@ export default function AdminInfoPageEditor() { isActive={editor.isActive('highlight')} title={t('news.admin.toolbar.highlight')} > - + - + mediaInputRef.current?.click()} @@ -1449,7 +1353,7 @@ export default function AdminInfoPageEditor() { {isUploading ? (
) : ( - + )}
diff --git a/src/pages/AdminInfoPages.tsx b/src/pages/AdminInfoPages.tsx index be774f8..02fbc36 100644 --- a/src/pages/AdminInfoPages.tsx +++ b/src/pages/AdminInfoPages.tsx @@ -8,92 +8,11 @@ import { Toggle } from '../components/admin/Toggle'; import { useHapticFeedback } from '../platform/hooks/useHaptic'; import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; import { cn } from '../lib/utils'; +import { FileTextIcon, PencilIcon, PlusIcon, RefreshIcon, TrashIcon } from '@/components/icons'; import type { InfoPageListItem, InfoPageType } from '../api/infoPages'; type FilterTab = 'all' | 'page' | 'faq'; -// Icons -const PlusIcon = () => ( - -); - -const RefreshIcon = () => ( - -); - -const PencilIcon = () => ( - -); - -const TrashIcon = () => ( - -); - -const FileTextIcon = () => ( - -); - // --- Page Row --- const PageRow = memo(function PageRow({ @@ -179,7 +98,7 @@ const PageRow = memo(function PageRow({ title={t('admin.infoPages.delete')} aria-label={t('admin.infoPages.delete')} > - +
@@ -374,7 +293,7 @@ export default function AdminInfoPages() {
) : items.length === 0 ? (
- +

{t('admin.infoPages.noPages')}

) : ( diff --git a/src/pages/AdminLandingEditor.tsx b/src/pages/AdminLandingEditor.tsx index a4d18a7..0a84acc 100644 --- a/src/pages/AdminLandingEditor.tsx +++ b/src/pages/AdminLandingEditor.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; +import { PiCaretDown } from 'react-icons/pi'; import { useNavigate, useParams } from 'react-router'; import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; @@ -52,15 +53,7 @@ function isoToDatetimeLocal(iso: string): string { } const ChevronDownIcon = ({ open }: { open: boolean }) => ( - - - + ); // ============ Collapsible Section ============ diff --git a/src/pages/AdminLandingStats.tsx b/src/pages/AdminLandingStats.tsx index 6a3fd5b..34e3025 100644 --- a/src/pages/AdminLandingStats.tsx +++ b/src/pages/AdminLandingStats.tsx @@ -26,17 +26,15 @@ import { useCurrency } from '../hooks/useCurrency'; import { useChartColors } from '../hooks/useChartColors'; import { CHART_COMMON } from '../constants/charts'; import { AdminBackButton } from '../components/admin'; - -// Icons -const ChartIcon = () => ( - - - -); +import { + ChartIcon, + EmailIcon, + TelegramSmallIcon, + ArrowRightIcon, + GiftIcon, + ChevronLeftIcon as ChevronLeftSmall, + ChevronRightIcon as ChevronRightSmall, +} from '@/components/icons'; const TARIFF_PALETTE = ['#818cf8', '#34d399', '#f59e0b', '#ec4899', '#06b6d4', '#8b5cf6']; const GIFT_COLOR = '#a855f7'; @@ -62,75 +60,15 @@ const PURCHASE_STATUS_OPTIONS: Array = [ const PURCHASES_PAGE_SIZE = 20; -// Small icons for the purchase cards - -const EmailIcon = () => ( - - - -); - -const TelegramSmallIcon = () => ( - - - -); - -const ArrowRightIcon = () => ( - - - -); - -const GiftIcon = () => ( - - - -); - -const ChevronLeftSmall = () => ( - - - -); - -const ChevronRightSmall = () => ( - - - -); - // Contact display helper function ContactDisplay({ type, value }: { type: 'email' | 'telegram'; value: string }) { return ( - {type === 'email' ? : } + {type === 'email' ? ( + + ) : ( + + )} {value} ); @@ -184,7 +122,7 @@ function PurchaseCard({ item, formatPrice, lang, t }: PurchaseCardProps) { {item.is_gift && item.gift_recipient_type && item.gift_recipient_value && ( - + )} @@ -212,7 +150,7 @@ function PurchaseCard({ item, formatPrice, lang, t }: PurchaseCardProps) { {item.is_gift && (
- + {t('admin.landings.purchases.gift')}
@@ -379,7 +317,7 @@ export default function AdminLandingStats() {
- +

{landingTitle}

diff --git a/src/pages/AdminLandings.tsx b/src/pages/AdminLandings.tsx index 440a906..f0a717a 100644 --- a/src/pages/AdminLandings.tsx +++ b/src/pages/AdminLandings.tsx @@ -9,6 +9,15 @@ import { getApiErrorMessage } from '../utils/api-error'; import { usePlatform } from '../platform/hooks/usePlatform'; import { cn } from '../lib/utils'; import { BackIcon, PlusIcon, TrashIcon, GripIcon } from '../components/icons/LandingIcons'; +import { + EditIcon, + CheckIcon, + XIcon, + GiftIcon, + SaveIcon, + CopyIcon, + StatsChartIcon, +} from '@/components/icons'; import { DndContext, KeyboardSensor, @@ -26,69 +35,6 @@ import { } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; -// Icons (non-shared, page-specific) -const EditIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - -const XIcon = () => ( - - - -); - -const GiftIcon = () => ( - - - -); - -const SaveIcon = () => ( - - - -); - -const CopyIcon = () => ( - - - -); - -const StatsChartIcon = () => ( - - - -); - // ============ Sortable Landing Card ============ interface SortableLandingCardProps { @@ -211,7 +157,7 @@ function SortableLandingCard({ className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100" title={t('admin.landings.statistics')} > - + diff --git a/src/pages/AdminNews.tsx b/src/pages/AdminNews.tsx index 65668a2..8e91bdc 100644 --- a/src/pages/AdminNews.tsx +++ b/src/pages/AdminNews.tsx @@ -8,105 +8,14 @@ import { Toggle } from '../components/admin/Toggle'; import { useHapticFeedback } from '../platform/hooks/useHaptic'; import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; import type { NewsListItem } from '../types/news'; - -// Icons -const PlusIcon = () => ( - -); - -const RefreshIcon = () => ( - -); - -const PencilIcon = () => ( - -); - -const TrashIcon = () => ( - -); - -const StarIcon = ({ filled }: { filled: boolean }) => ( - -); - -const NewsIcon = () => ( - -); +import { + PlusIcon, + RefreshIcon, + PencilIcon, + TrashIcon, + StarIcon, + NewsIcon, +} from '@/components/icons'; // --- Security: hex color validation to prevent CSS injection --- const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; @@ -195,7 +104,7 @@ const ArticleRow = memo(function ArticleRow({ title={t('news.admin.featured')} aria-label={t('news.admin.featured')} > - + - +
@@ -401,7 +310,7 @@ export default function AdminNews() {
) : articles.length === 0 ? (
- +

{t('news.noNews')}

) : ( diff --git a/src/pages/AdminNewsCreate.tsx b/src/pages/AdminNewsCreate.tsx index db37f71..e0c863d 100644 --- a/src/pages/AdminNewsCreate.tsx +++ b/src/pages/AdminNewsCreate.tsx @@ -19,109 +19,25 @@ import { Toggle } from '../components/admin/Toggle'; import { useHapticFeedback } from '../platform/hooks/useHaptic'; import { cn } from '../lib/utils'; import type { NewsCategory, NewsTag, NewsCreateRequest } from '../types/news'; - -// --- Icons --- -const BoldIcon = () => ( - - - -); - -const ItalicIcon = () => ( - - - -); - -const UnderlineIcon = () => ( - - - -); - -const StrikeIcon = () => ( - - - -); - -const H1Icon = () => H1; - -const H2Icon = () => H2; - -const H3Icon = () => H3; - -const ListBulletIcon = () => ( - - - -); - -const ListOrderedIcon = () => ( - - - -); - -const QuoteIcon = () => ( - - - -); - -const CodeBlockIcon = () => ( - - - -); - -const ImageIcon = () => ( - - - -); - -const LinkIcon = () => ( - - - -); - -const AlignLeftIcon = () => ( - - - -); - -const AlignCenterIcon = () => ( - - - -); - -const HighlightIcon = () => ( - - - -); - -const UploadIcon = () => ( - - - -); +import { + BoldIcon, + ItalicIcon, + UnderlineIcon, + StrikeIcon, + H1Icon, + H2Icon, + H3Icon, + ListBulletIcon, + ListOrderedIcon, + QuoteIcon, + CodeBlockIcon, + ImageIcon, + LinkIcon, + AlignLeftIcon, + AlignCenterIcon, + HighlightIcon, + UploadIcon, +} from '@/components/icons'; // --- Toolbar Button --- interface ToolbarButtonProps { @@ -765,7 +681,7 @@ export default function AdminNewsCreate() { {isFeaturedImageUploading ? (
) : ( - + )} {t('news.admin.uploadFeaturedImage')} @@ -848,28 +764,28 @@ export default function AdminNewsCreate() { isActive={editor.isActive('bold')} title={t('news.admin.toolbar.bold')} > - + editor.chain().focus().toggleItalic().run()} isActive={editor.isActive('italic')} title={t('news.admin.toolbar.italic')} > - + editor.chain().focus().toggleUnderline().run()} isActive={editor.isActive('underline')} title={t('news.admin.toolbar.underline')} > - + editor.chain().focus().toggleStrike().run()} isActive={editor.isActive('strike')} title={t('news.admin.toolbar.strikethrough')} > - +
@@ -879,7 +795,7 @@ export default function AdminNewsCreate() { isActive={editor.isActive('heading', { level: 1 })} title={t('news.admin.toolbar.heading1')} > - + editor.chain().focus().toggleHeading({ level: 2 }).run()} @@ -910,21 +826,21 @@ export default function AdminNewsCreate() { isActive={editor.isActive('orderedList')} title={t('news.admin.toolbar.orderedList')} > - + editor.chain().focus().toggleBlockquote().run()} isActive={editor.isActive('blockquote')} title={t('news.admin.toolbar.blockquote')} > - + editor.chain().focus().toggleCodeBlock().run()} isActive={editor.isActive('codeBlock')} title={t('news.admin.toolbar.codeBlock')} > - +
@@ -934,14 +850,14 @@ export default function AdminNewsCreate() { isActive={editor.isActive({ textAlign: 'left' })} title={t('news.admin.toolbar.alignLeft')} > - + editor.chain().focus().setTextAlign('center').run()} isActive={editor.isActive({ textAlign: 'center' })} title={t('news.admin.toolbar.alignCenter')} > - +
@@ -951,10 +867,10 @@ export default function AdminNewsCreate() { isActive={editor.isActive('highlight')} title={t('news.admin.toolbar.highlight')} > - + - + mediaInputRef.current?.click()} @@ -964,7 +880,7 @@ export default function AdminNewsCreate() { {isUploading ? (
) : ( - + )}
diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index 44121da..f29d839 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -7,365 +7,91 @@ import { statsApi, type SystemInfo, type DashboardStats } from '@/api/admin'; import { useAnimatedNumber } from '@/hooks/useAnimatedNumber'; import { useTelegramSDK } from '@/hooks/useTelegramSDK'; import { cn } from '@/lib/utils'; +import { + ArrowUpIcon, + BroadcastIcon, + CabinetIcon, + ChartBarIcon, + ChevronRightIcon, + ClipboardIcon, + CreditCardIcon, + FileTextIcon, + GiftIcon, + HistoryIcon, + LockIcon, + MailIcon, + MegaphoneIcon, + NewsIcon, + PartnerIcon, + PercentIcon, + PinIcon, + RemnawaveIcon, + SearchIcon, + SendIcon, + ServerIcon, + SettingsIcon, + ShareIcon, + ShieldIcon, + SparklesIcon, + StatBotIcon, + StatCabinetIcon, + StatPaidIcon, + StatsChartIcon, + StatTrialIcon, + StatUptimeIcon, + SyncIcon, + TagIcon, + TicketIcon, + TrafficIcon, + UserPlusIcon, + UsersIcon, + WalletIcon, + WheelIcon, + XIcon, +} from '@/components/icons'; const CABINET_VERSION = __APP_VERSION__; const IS_MAC = /Mac|iPhone|iPod|iPad/i.test(navigator.userAgent); -// ─── Inline SVG Icons (lightweight, no external deps) ─── - -const SvgIcon = ({ - children, - className, - ...props -}: React.SVGProps & { children: React.ReactNode }) => ( - -); - -// Stats bar icons (16x16 viewBox) -const StatUptimeIcon = () => ( - -); -const StatBotIcon = () => ( - -); -const StatCabinetIcon = () => ( - -); -const StatTrialIcon = () => ( - -); -const StatPaidIcon = () => ( - -); - // Section nav icons (24x24 viewBox) const icons = { - 'bar-chart': ( - - - - - - - - ), - 'credit-card': ( - - - - - ), - activity: ( - - - - ), - trending: ( - - - - - ), - users: ( - - - - - - - ), - ticket: ( - - - - - ), - 'shield-alert': ( - - - - - - ), - tag: ( - - - - - ), - gift: ( - - - - - - ), - percent: ( - - - - - - ), - sparkle: ( - - - - ), - wallet: ( - - - - - - ), - layout: ( - - - - - - ), - newspaper: ( - - - - - - ), - megaphone: ( - - - - - ), - send: ( - - - - - ), - pin: ( - - - - - ), - 'circle-dot': ( - - - - - ), - handshake: ( - - - - - - - ), - 'arrow-up': ( - - - - - - ), - network: ( - - - - - - - - ), - radio: ( - - - - - - - - ), - settings: ( - - - - - ), - app: ( - - - - - ), - server: ( - - - - - - - ), - remnawave: ( - - ), - mail: ( - - - - - ), - refresh: ( - - - - - - - ), - shield: ( - - - - ), - 'user-check': ( - - - - - - ), - lock: ( - - - - - ), - scroll: ( - - - - - - ), - 'list-checks': ( - - - - - ), - search: ( - - - - - ), - 'file-text': ( - - - - - - - - ), - chevron: ( - - - - ), - x: ( - - - - - ), + 'bar-chart': , + 'credit-card': , + activity: , + trending: , + users: , + ticket: , + 'shield-alert': , + tag: , + gift: , + percent: , + sparkle: , + wallet: , + layout: , + newspaper: , + megaphone: , + send: , + pin: , + 'circle-dot': , + handshake: , + 'arrow-up': , + network: , + radio: , + settings: , + app: , + server: , + remnawave: , + mail: , + refresh: , + shield: , + 'user-check': , + lock: , + scroll: , + 'list-checks': , + search: , + 'file-text': , + chevron: , + x: , } as const; type IconName = keyof typeof icons; @@ -657,25 +383,25 @@ const StatsBar = memo(function StatsBar({ systemInfo, dashboardStats, loading }: colorClass: 'text-success-400 bg-success-400/10 border-success-400/20', }, { - icon: , + icon: , label: t('admin.panel.statsBot'), value: systemInfo?.bot_version ?? '--', colorClass: 'text-accent-400 bg-accent-400/10 border-accent-400/20', }, { - icon: , + icon: , label: t('admin.panel.statsCabinet'), value: `v${CABINET_VERSION}`, colorClass: 'text-accent-300 bg-accent-300/10 border-accent-300/20', }, { - icon: , + icon: , label: t('admin.panel.statsTrials'), numericValue: trial, colorClass: 'text-warning-400 bg-warning-400/10 border-warning-400/20', }, { - icon: , + icon: , label: t('admin.panel.statsPaid'), numericValue: paid, delta: purchasedToday > 0 ? `+${purchasedToday}` : undefined, @@ -912,19 +638,19 @@ export default function AdminPanel() { cls: 'text-success-400', }, { - icon: , + icon: , label: t('admin.panel.statsBot'), value: systemInfo?.bot_version ?? '--', cls: 'text-accent-400', }, { - icon: , + icon: , label: t('admin.panel.statsTrials'), value: dashboardStats?.subscriptions.trial?.toLocaleString() ?? '--', cls: 'text-warning-400', }, { - icon: , + icon: , label: t('admin.panel.statsPaid'), value: dashboardStats?.subscriptions.paid?.toLocaleString() ?? '--', delta: 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/AdminPartnerSettings.tsx b/src/pages/AdminPartnerSettings.tsx index ee96514..6adff1f 100644 --- a/src/pages/AdminPartnerSettings.tsx +++ b/src/pages/AdminPartnerSettings.tsx @@ -5,20 +5,10 @@ import { useTranslation } from 'react-i18next'; import { partnerApi } from '../api/partners'; import { AdminBackButton } from '../components/admin'; import { toNumber } from '../utils/inputHelpers'; +import { SettingsIcon } from '@/components/icons'; type NumberOrEmpty = number | ''; -const SettingsIcon = () => ( - - - - -); - export default function AdminPartnerSettings() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -126,7 +116,7 @@ export default function AdminPartnerSettings() {
- +

{t('admin.partners.settings')}

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/AdminPaymentMethodEdit.tsx b/src/pages/AdminPaymentMethodEdit.tsx index bf6c750..f5fa4b2 100644 --- a/src/pages/AdminPaymentMethodEdit.tsx +++ b/src/pages/AdminPaymentMethodEdit.tsx @@ -7,33 +7,7 @@ import { METHOD_LABELS } from '../constants/paymentMethods'; import type { PromoGroupSimple } from '../types'; import { usePlatform } from '../platform/hooks/usePlatform'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; -const BackIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - -const SaveIcon = () => ( - - - -); +import { BackIcon, CheckIcon, SaveIcon } from '@/components/icons'; export default function AdminPaymentMethodEdit() { const { t } = useTranslation(); @@ -462,7 +436,7 @@ export default function AdminPaymentMethodEdit() { {updateMethodMutation.isPending ? (
) : ( - + )} {t('admin.paymentMethods.saveButton')} diff --git a/src/pages/AdminPaymentMethods.tsx b/src/pages/AdminPaymentMethods.tsx index 10aa5b6..8667dd1 100644 --- a/src/pages/AdminPaymentMethods.tsx +++ b/src/pages/AdminPaymentMethods.tsx @@ -23,43 +23,7 @@ import { import { CSS } from '@dnd-kit/utilities'; import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'; import type { PaymentMethodConfig } from '../types'; -const BackIcon = () => ( - - - -); - -const GripIcon = () => ( - - - -); - -const ChevronRightIcon = () => ( - - - -); - -const SaveIcon = () => ( - - - -); +import { BackIcon, GripIcon, ChevronRightIcon, SaveIcon } from '@/components/icons'; interface SortableCardProps { config: PaymentMethodConfig; @@ -267,7 +231,7 @@ export default function AdminPaymentMethods() { {saveOrderMutation.isPending ? (
) : ( - + )} {t('admin.paymentMethods.saveOrder')} diff --git a/src/pages/AdminPayments.tsx b/src/pages/AdminPayments.tsx index fc4c7a6..76daaab 100644 --- a/src/pages/AdminPayments.tsx +++ b/src/pages/AdminPayments.tsx @@ -6,41 +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'; - -// BackIcon -const BackIcon = () => ( - - - -); - -// SearchIcon -const SearchIcon = () => ( - - - -); - -// CalendarIcon -const CalendarIcon = () => ( - - - -); +import { + BackIcon, + SearchIcon, + CalendarIcon, + RefreshIcon, + CheckCircleIcon, +} from '@/components/icons'; interface StatusBadgeProps { status: string; @@ -244,19 +216,7 @@ export default function AdminPayments() {

@@ -338,7 +298,7 @@ export default function AdminPayments() { : 'bg-dark-800 text-dark-300 hover:bg-dark-700' }`} > - + {t('admin.payments.periodCustom')} @@ -608,19 +568,7 @@ export default function AdminPayments() { ) : (
- - - +
{t('admin.payments.noPayments')}
diff --git a/src/pages/AdminPinnedMessageCreate.tsx b/src/pages/AdminPinnedMessageCreate.tsx index b2f4cd4..52bef4d 100644 --- a/src/pages/AdminPinnedMessageCreate.tsx +++ b/src/pages/AdminPinnedMessageCreate.tsx @@ -8,60 +8,7 @@ import { PinnedMessageUpdateRequest, } from '../api/adminPinnedMessages'; import { AdminBackButton, Toggle } from '../components/admin'; - -// Icons -const PinIcon = () => ( - - - - -); - -const XIcon = () => ( - - - -); - -const RefreshIcon = () => ( - - - -); - -const PhotoIcon = () => ( - - - -); - -const VideoIcon = () => ( - - - -); - -const SaveIcon = () => ( - - - -); +import { PinIcon, XIcon, RefreshIcon, PhotoIcon, VideoIcon, SaveIcon } from '@/components/icons'; export default function AdminPinnedMessageCreate() { const { t } = useTranslation(); @@ -231,7 +178,7 @@ export default function AdminPinnedMessageCreate() {
- +

@@ -290,7 +237,7 @@ export default function AdminPinnedMessageCreate() { className="rounded-lg p-2 text-dark-400 hover:bg-dark-700 hover:text-error-400" disabled={isUploading} > - +

{mediaPreview && ( diff --git a/src/pages/AdminPinnedMessages.tsx b/src/pages/AdminPinnedMessages.tsx index 2c3668c..1d13b05 100644 --- a/src/pages/AdminPinnedMessages.tsx +++ b/src/pages/AdminPinnedMessages.tsx @@ -5,122 +5,21 @@ import { useTranslation } from 'react-i18next'; import { adminPinnedMessagesApi, PinnedMessageResponse } from '../api/adminPinnedMessages'; import { AdminBackButton } from '../components/admin'; import { useNativeDialog } from '../platform/hooks/useNativeDialog'; - -// Icons -const PinIcon = () => ( - - - - -); - -const PlusIcon = () => ( - - - -); - -const RefreshIcon = () => ( - - - -); - -const PhotoIcon = () => ( - - - -); - -const VideoIcon = () => ( - - - -); - -const MenuIcon = () => ( - - - -); - -const RepeatIcon = () => ( - - - -); - -const BroadcastIcon = () => ( - - - -); - -const TrashIcon = () => ( - - - -); - -const EditIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - -const XIcon = () => ( - - - -); - -const UnpinIcon = () => ( - - - -); +import { + BroadcastIcon, + CheckIcon, + EditIcon, + MenuIcon, + PhotoIcon, + PinIcon, + PlusIcon, + RefreshIcon, + RepeatIcon, + TrashIcon, + UnpinIcon, + VideoIcon, + XIcon, +} from '@/components/icons'; // Message card component function PinnedMessageCard({ @@ -168,17 +67,21 @@ function PinnedMessageCard({ #{message.id} {message.media_type && ( - {message.media_type === 'photo' ? : } + {message.media_type === 'photo' ? ( + + ) : ( + + )} )} {message.send_before_menu && ( - + )} {message.send_on_every_start && ( - + )}
@@ -218,7 +121,7 @@ function PinnedMessageCard({ onClick={onUnpin} className="flex items-center gap-1.5 rounded-lg bg-error-500/20 px-3 py-1.5 text-xs text-error-400 transition-colors hover:bg-error-500/30" > - + {t('admin.pinnedMessages.unpinAll')} @@ -236,7 +139,7 @@ function PinnedMessageCard({ onClick={() => onBroadcast(message.id)} className="flex items-center gap-1.5 rounded-lg bg-accent-500/20 px-3 py-1.5 text-xs text-accent-400 transition-colors hover:bg-accent-500/30" > - + {t('admin.pinnedMessages.broadcastToAll')} @@ -245,7 +148,7 @@ function PinnedMessageCard({ onClick={() => onDelete(message.id)} className="flex items-center gap-1.5 rounded-lg bg-error-500/20 px-3 py-1.5 text-xs text-error-400 transition-colors hover:bg-error-500/30" > - + {t('admin.pinnedMessages.delete')} )} @@ -349,7 +252,7 @@ export default function AdminPinnedMessages() {
- +

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

@@ -383,7 +286,7 @@ export default function AdminPinnedMessages() { ) : messages.length === 0 ? (
- +

{t('admin.pinnedMessages.empty')}

diff --git a/src/pages/AdminPolicies.tsx b/src/pages/AdminPolicies.tsx index b3ecd7f..0e7f723 100644 --- a/src/pages/AdminPolicies.tsx +++ b/src/pages/AdminPolicies.tsx @@ -6,94 +6,17 @@ import { rbacApi, AccessPolicy, AdminRole } from '@/api/rbac'; import { PermissionGate } from '@/components/auth/PermissionGate'; import { usePlatform } from '@/platform/hooks/usePlatform'; import { useFocusTrap } from '@/hooks/useFocusTrap'; - -const BackIcon = () => ( - - - -); - -const PlusIcon = () => ( - - - -); - -const EditIcon = () => ( - - - -); - -const TrashIcon = () => ( - - - -); - -const ShieldIcon = () => ( - - - -); - -const ClockIcon = () => ( - - - -); - -const GlobeIcon = () => ( - - - -); - -const BoltIcon = () => ( - - - -); - -const CalendarIcon = () => ( - - - -); +import { + BackIcon, + BoltIcon, + CalendarIcon, + ClockIcon, + EditIcon, + GlobeIcon, + PlusIcon, + ShieldIcon, + TrashIcon, +} from '@/components/icons'; interface PolicyConditions { time_range?: { start: string; end: string }; @@ -218,7 +141,7 @@ export default function AdminPolicies() { className="inline-flex items-center gap-1 rounded bg-dark-700 px-1.5 py-0.5 text-xs text-dark-300" title={t('admin.policies.conditions.timeRange')} > - + {parsed.time_range.start}-{parsed.time_range.end} , ); @@ -231,7 +154,7 @@ export default function AdminPolicies() { className="inline-flex items-center gap-1 rounded bg-dark-700 px-1.5 py-0.5 text-xs text-dark-300" title={t('admin.policies.conditions.ipWhitelist')} > - + {t('admin.policies.conditions.ipCount', { count: parsed.ip_whitelist.length })} , ); @@ -244,7 +167,7 @@ export default function AdminPolicies() { className="inline-flex items-center gap-1 rounded bg-dark-700 px-1.5 py-0.5 text-xs text-dark-300" title={t('admin.policies.conditions.rateLimit')} > - + {t('admin.policies.conditions.rateValue', { count: parsed.rate_limit })} , ); @@ -260,7 +183,7 @@ export default function AdminPolicies() { className="inline-flex items-center gap-1 rounded bg-dark-700 px-1.5 py-0.5 text-xs text-dark-300" title={t('admin.policies.conditions.weekdays')} > - + {dayNames.join(', ')} , ); @@ -434,7 +357,7 @@ export default function AdminPolicies() { className="flex-1 rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-error-500/20 hover:text-error-400 sm:flex-none" title={t('admin.policies.actions.delete')} > - +
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/AdminPromoGroupCreate.tsx b/src/pages/AdminPromoGroupCreate.tsx index 3adafe0..4064acf 100644 --- a/src/pages/AdminPromoGroupCreate.tsx +++ b/src/pages/AdminPromoGroupCreate.tsx @@ -9,39 +9,7 @@ import { PromoGroupUpdateRequest, } from '../api/promocodes'; import { AdminBackButton } from '../components/admin'; - -// Icons -const PlusIcon = () => ( - - - -); - -const TrashIcon = () => ( - - - -); - -const RefreshIcon = () => ( - - - -); +import { PlusIcon, RefreshIcon, TrashIcon } from '@/components/icons'; interface PeriodDiscount { days: number | ''; @@ -340,7 +308,7 @@ export default function AdminPromoGroupCreate() { onClick={() => removePeriodDiscount(index)} className="p-1 text-dark-400 transition-colors hover:text-error-400" > - +
))} @@ -429,7 +397,7 @@ export default function AdminPromoGroupCreate() { disabled={!isValid || isLoading} className="btn-primary flex items-center gap-2" > - {isLoading && } + {isLoading && } {isLoading ? t('admin.promoGroups.form.saving') : t('admin.promoGroups.form.save')}
diff --git a/src/pages/AdminPromoGroups.tsx b/src/pages/AdminPromoGroups.tsx index 3c81e8e..382d599 100644 --- a/src/pages/AdminPromoGroups.tsx +++ b/src/pages/AdminPromoGroups.tsx @@ -5,55 +5,7 @@ import { useTranslation } from 'react-i18next'; import { promocodesApi, PromoGroup } from '../api/promocodes'; import { usePlatform } from '../platform/hooks/usePlatform'; import { useFocusTrap } from '../hooks/useFocusTrap'; - -// Icons -const BackIcon = () => ( - - - -); - -const PlusIcon = () => ( - - - -); - -const EditIcon = () => ( - - - -); - -const TrashIcon = () => ( - - - -); - -const UsersIcon = () => ( - - - -); +import { BackIcon, PlusIcon, EditIcon, TrashIcon, UsersIcon } from '@/components/icons'; export default function AdminPromoGroups() { const { t } = useTranslation(); @@ -208,7 +160,7 @@ export default function AdminPromoGroups() { title={t('admin.promoGroups.actions.delete')} disabled={group.is_default} > - +

diff --git a/src/pages/AdminPromoOfferSend.tsx b/src/pages/AdminPromoOfferSend.tsx index 2bea33d..2c8f5a2 100644 --- a/src/pages/AdminPromoOfferSend.tsx +++ b/src/pages/AdminPromoOfferSend.tsx @@ -12,59 +12,15 @@ import { } from '../api/promoOffers'; import { adminUsersApi, UserListItem } from '../api/adminUsers'; import { AdminBackButton } from '../components/admin'; - -// Icons -const SendIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - -const UsersIcon = () => ( - - - -); - -const UserIcon = () => ( - - - -); - -const SearchIcon = () => ( - - - -); - -const CloseIcon = () => ( - - - -); +import { + SendIcon, + CheckIcon, + UsersIcon, + UserIcon, + SearchIcon, + CloseIcon, + XIcon, +} from '@/components/icons'; const getOfferTypeIcon = (offerType: string): string => { return OFFER_TYPE_CONFIG[offerType as OfferType]?.icon || '🎁'; @@ -233,25 +189,9 @@ export default function AdminPromoOfferSend() { }`} > {result.isSuccess ? ( - - - + ) : ( - - - + )}

{result.title}

@@ -420,7 +360,7 @@ export default function AdminPromoOfferSend() { onClick={handleClearUser} className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-600 hover:text-dark-100" > - + ) : ( @@ -428,7 +368,7 @@ export default function AdminPromoOfferSend() { <>
- +
( - - - -); - -const EditIcon = () => ( - - - -); - -const SendIcon = () => ( - - - -); - -const ClockIcon = () => ( - - - -); - -const UserIcon = () => ( - - - -); +import { BackIcon, EditIcon, SendIcon, ClockIcon, UserIcon } from '@/components/icons'; // Helper functions const formatDateTime = (date: string | null): string => { @@ -295,7 +243,7 @@ export default function AdminPromoOffers() {
- +
@@ -317,7 +265,7 @@ export default function AdminPromoOffers() {
- + {formatDateTime(log.created_at)}
diff --git a/src/pages/AdminPromocodeCreate.tsx b/src/pages/AdminPromocodeCreate.tsx index 1c4d291..7c32fe3 100644 --- a/src/pages/AdminPromocodeCreate.tsx +++ b/src/pages/AdminPromocodeCreate.tsx @@ -13,35 +13,7 @@ import { } from '../api/promocodes'; import { tariffsApi } from '../api/tariffs'; import { usePlatform } from '../platform/hooks/usePlatform'; - -// Icons -const BackIcon = () => ( - - - -); - -const RefreshIcon = () => ( - - - -); +import { BackIcon, RefreshIcon } from '@/components/icons'; export default function AdminPromocodeCreate() { const { t } = useTranslation(); @@ -557,7 +529,7 @@ export default function AdminPromocodeCreate() { disabled={!isValid() || isLoading} className="btn-primary flex items-center gap-2" > - {isLoading && } + {isLoading && } {isLoading ? t('admin.promocodes.form.saving') : t('admin.promocodes.form.save')}
diff --git a/src/pages/AdminPromocodeStats.tsx b/src/pages/AdminPromocodeStats.tsx index bf922e5..e52e240 100644 --- a/src/pages/AdminPromocodeStats.tsx +++ b/src/pages/AdminPromocodeStats.tsx @@ -4,37 +4,7 @@ import { useTranslation } from 'react-i18next'; import i18n from '../i18n'; import { promocodesApi, PromoCodeType } from '../api/promocodes'; import { AdminBackButton } from '../components/admin'; - -// Icons -const EditIcon = () => ( - - - -); - -const ClockIcon = () => ( - - - -); - -const UserIcon = () => ( - - - -); +import { EditIcon, ClockIcon, UserIcon } from '@/components/icons'; // Helper functions const getTypeLabel = (type: PromoCodeType): string => { @@ -253,7 +223,7 @@ export default function AdminPromocodeStats() { {/* Usage History */}

- + {t('admin.promocodes.stats.usageHistory')}

{promocode.recent_uses.length === 0 ? ( @@ -269,7 +239,7 @@ export default function AdminPromocodeStats() { >
- +
diff --git a/src/pages/AdminPromocodes.tsx b/src/pages/AdminPromocodes.tsx index 76667b0..afedc38 100644 --- a/src/pages/AdminPromocodes.tsx +++ b/src/pages/AdminPromocodes.tsx @@ -6,72 +6,15 @@ import i18n from '../i18n'; import { promocodesApi, PromoCode, PromoCodeType } from '../api/promocodes'; import { usePlatform } from '../platform/hooks/usePlatform'; import { copyToClipboard } from '../utils/clipboard'; - -// Icons - -const BackIcon = () => ( - - - -); - -const PlusIcon = () => ( - - - -); - -const EditIcon = () => ( - - - -); - -const TrashIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - -const CopyIcon = () => ( - - - -); - -const ChartIcon = () => ( - - - -); +import { + BackIcon, + PlusIcon, + EditIcon, + TrashIcon, + CheckIcon, + CopyIcon, + ChartIcon, +} from '@/components/icons'; // Helper functions const getTypeLabel = (type: PromoCodeType): string => { @@ -298,7 +241,7 @@ export default function AdminPromocodes() { className="flex-1 rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-error-500/20 hover:text-error-400 sm:flex-none" title={t('admin.promocodes.actions.delete')} > - +
diff --git a/src/pages/AdminRemnawave.tsx b/src/pages/AdminRemnawave.tsx index 4f040f6..3c7c592 100644 --- a/src/pages/AdminRemnawave.tsx +++ b/src/pages/AdminRemnawave.tsx @@ -9,6 +9,11 @@ import { SquadWithLocalInfo, SystemStatsResponse, AutoSyncStatus, + RecapResponse, + DevicesStatsResponse, + TopConsumersResponse, + HealthResponse, + SubscriptionRequestStatsResponse, } from '../api/adminRemnawave'; import { usePlatform } from '../platform/hooks/usePlatform'; import { formatUptime } from '../utils/format'; @@ -18,27 +23,38 @@ import { ServerIcon, ChartIcon, GlobeIcon, + HeartbeatIcon, + PowerIcon, + WarningCircleIcon, + CalendarIcon, + CalendarBlankIcon, + CalendarStarIcon, + ChartPieIcon, + ChartDonutIcon, + CpuIcon, + MemoryIcon, + PulseIcon, + DevicesIcon, + StatUptimeIcon, UsersIcon, + CheckCircleIcon, + BanIcon, + TrafficIcon, + ClockIcon, SyncIcon, RefreshIcon, PlayIcon, StopIcon, ArrowPathIcon, RemnawaveIcon, + XrayIcon, + DownloadIcon, + UploadIcon, + SubscriptionIcon, + BackIcon, + ChevronRightIcon, } from '../components/icons'; -const BackIcon = () => ( - - - -); - const formatBytes = (bytes: number): string => { if (bytes === 0) return '0 B'; const k = 1024; @@ -51,6 +67,47 @@ 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; + } +}; + +// 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". +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; @@ -83,75 +140,148 @@ 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, onAction, isLoading }: NodeCardProps) { +function NodeCard({ node, providerName, realtime, onAction, isLoading }: NodeCardProps) { const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); - 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; + + // 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); + + // 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; + + const ramColorClass = + ramPct === null + ? '' + : ramPct > 85 + ? 'text-error-400' + : ramPct > 65 + ? 'text-warning-400' + : 'text-dark-400'; + + return ( +
setExpanded((v) => !v) : undefined} + > + {/* Identity + actions */} +
+
+ + + + {node.users_online ?? 0} + + + {getCountryFlag(node.country_code)} + +

{node.name}

+ {(providerLabel || providerFavicon) && ( + + {providerFavicon && ( + { + e.currentTarget.style.display = 'none'; + }} + /> + )} + {providerLabel && {providerLabel}} - {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}} -
+ )}
-
+
+ {hasBreakdown && ( + + )}
+ + {/* Address + traffic + uptime */} +
+ + + {node.address} + + + {formatBytes(used)} + {trafficPct !== null && ( + + + + )} + / {limit > 0 ? formatBytes(limit) : '∞'} + + {node.xray_uptime > 0 && ( + + + {formatUptime(node.xray_uptime)} + + )} +
+ + {/* 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) && ( + <> + {/* 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} + + )} +
+ )} +
+ + {/* Desktop: single wrap row (original) */} +
+ {ramPct !== null && ( + + + {ramPct}% + + + + + )} + {loadAvg && ( + + + {loadAvg} + + )} + + + + {formatSpeed(rx)} + + + + {formatSpeed(tx)} + + + {(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) */} + {expanded && hasBreakdown && ( +
e.stopPropagation()} + > + {inbounds.length > 0 && ( + + )} + {outbounds.length > 0 && ( + + )} +
+ )}
); } @@ -231,15 +527,7 @@ function SquadCard({ squad, onClick }: SquadCardProps) {
- - - +
); @@ -285,13 +573,69 @@ function SyncCard({ title, description, onAction, isLoading, lastResult }: SyncC ); } +const formatUptimeSince = (iso: string): string => { + const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000); + if (Number.isNaN(days)) return '—'; + if (days < 1) return '<1d'; + if (days < 30) return `${days}d`; + if (days < 365) return `${Math.floor(days / 30)}mo`; + return `${Math.floor(days / 365)}y`; +}; + +function BreakdownCard({ + title, + items, + wide = false, +}: { + title: string; + items: { label: string; count: number }[]; + wide?: boolean; +}) { + const max = Math.max(1, ...items.map((i) => i.count)); + return ( +
+

{title}

+
+ {items.slice(0, wide ? 16 : 8).map((it) => ( +
+
+ {it.label} + {it.count} +
+
+
+
+
+ ))} +
+
+ ); +} + interface OverviewTabProps { stats: SystemStatsResponse | undefined; + recap?: RecapResponse; + devicesStats?: DevicesStatsResponse; + topConsumers?: TopConsumersResponse; + health?: HealthResponse; + subRequests?: SubscriptionRequestStatsResponse; isLoading: boolean; onRefresh: () => void; } -function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) { +function OverviewTab({ + stats, + recap, + devicesStats, + topConsumers, + health, + subRequests, + isLoading, + onRefresh, +}: OverviewTabProps) { const { t } = useTranslation(); if (isLoading) { @@ -326,7 +670,7 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) { {t('admin.remnawave.overview.system', 'System')} -
+
} - color="purple" + color={stats.system.nodes_online < stats.system.total_nodes ? 'orange' : 'purple'} /> } color="orange" /> @@ -356,26 +700,27 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) { {/* Bandwidth */}
-

+

+ {t('admin.remnawave.overview.bandwidth', 'Inbound Traffic')}

-
+
↓} + icon={} color="green" /> ↑} + icon={} color="blue" /> ⇅} + icon={} color="purple" />
@@ -383,27 +728,28 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) { {/* Server Info */}
-

+

+ {t('admin.remnawave.overview.server', 'Server')}

-
+
⚡} + icon={} color="accent" /> 💾} + icon={} color={memoryUsedPercent > 80 ? 'red' : memoryUsedPercent > 60 ? 'orange' : 'green'} /> ⏱️} + icon={} color="blue" />
@@ -411,38 +757,39 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) { {/* Traffic Periods */}
-

+

+ {t('admin.remnawave.overview.traffic', 'Traffic Statistics')}

-
+
📊} + icon={} color="accent" /> 📊} + icon={} color="blue" /> 📊} + icon={} color="green" /> 📊} + icon={} color="purple" /> 📊} + icon={} color="orange" />
@@ -450,27 +797,184 @@ function OverviewTab({ stats, isLoading, onRefresh }: OverviewTabProps) { {/* Users by Status */}
-

+

+ {t('admin.remnawave.overview.usersByStatus', 'Users by Status')}

-
+
{Object.entries(stats.users_by_status).map(([status, count]) => ( } + icon={userStatusIcon(status)} color={status === 'ACTIVE' ? 'green' : status === 'DISABLED' ? 'red' : 'accent'} /> ))}
+ + {/* Panel recap */} + {recap && ( +
+

+ + {t('admin.remnawave.overview.panel', 'Панель')} +

+
+ } + color="purple" + /> + } + color="blue" + /> + } + color="green" + /> + } + color="accent" + /> +
+
+ )} + + {/* Devices breakdown */} + {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', 'юзер')}) +

+
+ ({ label: p.platform, count: p.count }))} + /> + ({ label: a.app, count: a.count }))} + /> + {devicesStats.top_users.length > 0 && ( + ({ + label: u.username, + count: u.devices_count, + }))} + /> + )} +
+
+ )} + + {/* Top consumers */} + {topConsumers && topConsumers.users.length > 0 && ( +
+

+ + {t('admin.remnawave.overview.topConsumers', 'Топ потребителей')} ·{' '} + {topConsumers.period_days} + {t('admin.remnawave.overview.daysShort', 'д')} +

+
+ {topConsumers.users.map((u, i) => ( +
+ + {i + 1} + {u.username} + + + {formatBytes(u.total_bytes)} + +
+ ))} +
+
+ )} + + {/* Panel health */} + {health && health.instances > 0 && ( +
+

+ + {t('admin.remnawave.overview.panelHealth', 'Здоровье панели')} + {health.instances > 1 ? ` · ${health.instances}` : ''} +

+
+ } + color="blue" + /> + } + color="purple" + /> + } + color={health.event_loop_p99_ms > 50 ? 'red' : 'green'} + /> + } + color="accent" + /> +
+
+ )} + + {/* Subscription requests by app */} + {subRequests && subRequests.by_app.length > 0 && ( +
+

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

+ ({ label: a.app, count: a.count }))} + /> +
+ )}
); } interface NodesTabProps { nodes: NodeInfo[]; + providerByUuid: Record; + realtimeByUuid: Record; isLoading: boolean; onRefresh: () => void; onAction: (uuid: string, action: 'enable' | 'disable' | 'restart') => void; @@ -480,6 +984,8 @@ interface NodesTabProps { function NodesTab({ nodes, + providerByUuid, + realtimeByUuid, isLoading, onRefresh, onAction, @@ -499,6 +1005,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 (
@@ -510,29 +1023,29 @@ function NodesTab({ return (
{/* Stats */} -
+
} + icon={} color="accent" /> } + icon={} color="green" /> } + icon={} color="red" /> } + icon={} color="accent" />
+ {/* 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 ? ( @@ -570,7 +1103,14 @@ function NodesTab({

) : ( nodes.map((node) => ( - + )) )}
@@ -616,7 +1156,7 @@ function SquadsTab({ return (
{/* Stats */} -
+
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(); @@ -980,6 +1389,46 @@ export default function AdminRemnawave() { refetchInterval: 30000, }); + const { data: recap } = useQuery({ + queryKey: ['admin-remnawave-recap'], + queryFn: adminRemnawaveApi.getRecap, + enabled: activeTab === 'overview', + refetchInterval: 60000, + staleTime: 60000, + }); + + const { data: devicesStats } = useQuery({ + queryKey: ['admin-remnawave-devices-stats'], + queryFn: adminRemnawaveApi.getDevicesStats, + enabled: activeTab === 'overview', + refetchInterval: 60000, + staleTime: 60000, + }); + + const { data: topConsumers } = useQuery({ + queryKey: ['admin-remnawave-top-consumers'], + queryFn: () => adminRemnawaveApi.getTopConsumers(7, 10), + enabled: activeTab === 'overview', + refetchInterval: 60000, + staleTime: 60000, + }); + + const { data: health } = useQuery({ + queryKey: ['admin-remnawave-health'], + queryFn: adminRemnawaveApi.getHealth, + enabled: activeTab === 'overview', + refetchInterval: 30000, + staleTime: 15000, + }); + + const { data: subRequests } = useQuery({ + queryKey: ['admin-remnawave-sub-requests'], + queryFn: adminRemnawaveApi.getSubscriptionRequests, + enabled: activeTab === 'overview', + refetchInterval: 60000, + staleTime: 60000, + }); + const { data: nodesData, isLoading: isLoadingNodes, @@ -988,7 +1437,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 { @@ -1001,17 +1451,33 @@ 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, - enabled: 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, }); + // 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]); + + // 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, @@ -1086,11 +1552,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'), @@ -1112,7 +1573,7 @@ export default function AdminRemnawave() { 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" > - + )}
@@ -1120,7 +1581,7 @@ export default function AdminRemnawave() {

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

{t('admin.remnawave.subtitle', 'Panel management and statistics')} @@ -1172,6 +1633,11 @@ export default function AdminRemnawave() { {activeTab === 'overview' && ( refetchStats()} /> @@ -1180,6 +1646,8 @@ export default function AdminRemnawave() { {activeTab === 'nodes' && ( refetchNodes()} onAction={handleNodeAction} @@ -1188,14 +1656,6 @@ export default function AdminRemnawave() { /> )} - {activeTab === 'traffic' && ( - refetchRealtime()} - /> - )} - {activeTab === 'squads' && ( ( - - - -); - -const SearchIcon = () => ( - - - -); - -const ChevronDownIcon = ({ className }: { className?: string }) => ( - - - -); - -const ChevronLeftIcon = () => ( - - - -); - -const ChevronRightIcon = () => ( - - - -); - -const XCircleIcon = () => ( - - - -); - -const UserPlusIcon = () => ( - - - -); +import { + BackIcon, + SearchIcon, + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, + XCircleIcon, + UserPlusIcon, +} from '@/components/icons'; // === Constants === @@ -134,7 +75,7 @@ function UserSearchDropdown({ className="shrink-0 text-dark-400 transition-colors hover:text-dark-200" aria-label={t('admin.roleAssign.clearUser')} > - +

); @@ -647,7 +588,7 @@ export default function AdminRoleAssign() { role: assignment.role_name, })} > - + )} diff --git a/src/pages/AdminRoleEdit.tsx b/src/pages/AdminRoleEdit.tsx index 9833c63..429f010 100644 --- a/src/pages/AdminRoleEdit.tsx +++ b/src/pages/AdminRoleEdit.tsx @@ -4,20 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { rbacApi, PermissionSection, CreateRolePayload, UpdateRolePayload } from '@/api/rbac'; import { AdminBackButton } from '@/components/admin'; - -// === Icons === - -const ChevronDownIcon = ({ className }: { className?: string }) => ( - - - -); +import { ChevronDownIcon } from '@/components/icons'; // === Constants === diff --git a/src/pages/AdminRoles.tsx b/src/pages/AdminRoles.tsx index f0dbf23..7407add 100644 --- a/src/pages/AdminRoles.tsx +++ b/src/pages/AdminRoles.tsx @@ -7,54 +7,7 @@ import { PermissionGate } from '@/components/auth/PermissionGate'; import { usePermissionStore } from '@/store/permissions'; import { usePlatform } from '@/platform/hooks/usePlatform'; import { useFocusTrap } from '@/hooks/useFocusTrap'; - -const BackIcon = () => ( - - - -); - -const PlusIcon = () => ( - - - -); - -const EditIcon = () => ( - - - -); - -const TrashIcon = () => ( - - - -); - -const ShieldIcon = () => ( - - - -); +import { BackIcon, PlusIcon, EditIcon, TrashIcon, ShieldIcon } from '@/components/icons'; export default function AdminRoles() { const { t } = useTranslation(); @@ -234,7 +187,7 @@ export default function AdminRoles() { className="flex-1 rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-error-500/20 hover:text-error-400 disabled:cursor-not-allowed disabled:opacity-40 sm:flex-none" title={t('admin.roles.actions.delete')} > - +
diff --git a/src/pages/AdminServers.tsx b/src/pages/AdminServers.tsx index 9080cde..98dde9c 100644 --- a/src/pages/AdminServers.tsx +++ b/src/pages/AdminServers.tsx @@ -2,23 +2,18 @@ import { useNavigate } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { serversApi, ServerListItem } from '../api/servers'; -import { SyncIcon, EditIcon, CheckIcon, XIcon, UsersIcon, GiftIcon } from '../components/icons'; +import { + SyncIcon, + EditIcon, + CheckIcon, + XIcon, + UsersIcon, + GiftIcon, + BackIcon, +} from '../components/icons'; import { usePlatform } from '../platform/hooks/usePlatform'; import Twemoji from 'react-twemoji'; -// BackIcon -const BackIcon = () => ( - - - -); - // Country flags (simple emoji mapping) import { getFlagEmoji as getCountryFlag } from '../utils/subscriptionHelpers'; diff --git a/src/pages/AdminSettings.tsx b/src/pages/AdminSettings.tsx index 53799ec..2e54953 100644 --- a/src/pages/AdminSettings.tsx +++ b/src/pages/AdminSettings.tsx @@ -16,32 +16,7 @@ import { SettingsTab } from '../components/admin/SettingsTab'; import { SettingsTreeSidebar } from '../components/admin/SettingsTreeSidebar'; import { SettingsMobileTabs } from '../components/admin/SettingsMobileTabs'; import { SettingsSearchMobile, SettingsSearchResults } from '../components/admin/SettingsSearch'; - -// BackIcon -const BackIcon = () => ( - - - -); - -// ChevronRight for breadcrumbs -const ChevronRightIcon = () => ( - - - -); +import { BackIcon, ChevronRightIcon } from '@/components/icons'; // Settings that require SALES_MODE=tariffs to be visible const TARIFF_MODE_SETTINGS = ['MULTI_TARIFF_ENABLED', 'MAX_ACTIVE_SUBSCRIPTIONS']; @@ -298,7 +273,7 @@ export default function AdminSettings() { > {t(`admin.settings.groups.${activeTreeInfo.group.id}`)} - + {t(`admin.settings.tree.${activeTreeInfo.child.id}`)} diff --git a/src/pages/AdminTariffCreate.tsx b/src/pages/AdminTariffCreate.tsx index 7ae0ea3..39a49d6 100644 --- a/src/pages/AdminTariffCreate.tsx +++ b/src/pages/AdminTariffCreate.tsx @@ -14,75 +14,15 @@ import { import { AdminBackButton } from '../components/admin'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; import Twemoji from 'react-twemoji'; - -// Icons -const PlusIcon = () => ( - - - -); - -const TrashIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - -const InfinityIcon = () => ( - - - -); - -const CalendarIcon = () => ( - - - -); - -const SunIcon = () => ( - - - -); - -const RefreshIcon = () => ( - - - -); +import { + CalendarIcon, + CheckIcon, + InfinityIcon, + PlusIcon, + RefreshIcon, + SunIcon, + TrashIcon, +} from '@/components/icons'; type TariffType = 'period' | 'daily' | null; @@ -352,7 +292,7 @@ export default function AdminTariffCreate() { >
- +

{t('admin.tariffs.periodTariff')}

@@ -366,7 +306,7 @@ export default function AdminTariffCreate() { >
- +

{t('admin.tariffs.dailyTariff')}

@@ -392,7 +332,7 @@ export default function AdminTariffCreate() { isDaily ? 'bg-warning-500/20 text-warning-400' : 'bg-accent-500/20 text-accent-400' }`} > - {isDaily ? : } + {isDaily ? : }

@@ -538,7 +478,7 @@ export default function AdminTariffCreate() { {t('admin.tariffs.gbUnit')} {(trafficLimitGb === 0 || trafficLimitGb === '') && ( - + {t('admin.tariffs.unlimited')} )} @@ -686,7 +626,7 @@ export default function AdminTariffCreate() { onClick={() => removePeriod(period.days)} className="rounded-lg p-2 text-dark-400 transition-colors hover:bg-error-500/20 hover:text-error-400" > - +

))} @@ -1035,7 +975,7 @@ export default function AdminTariffCreate() { }} className="rounded-lg p-2 text-dark-400 transition-colors hover:bg-error-500/20 hover:text-error-400" > - +
))} @@ -1204,7 +1144,7 @@ export default function AdminTariffCreate() { disabled={!isValid || isLoading} className="btn-primary flex items-center gap-2" > - {isLoading && } + {isLoading && } {isLoading ? t('admin.tariffs.savingButton') : t('admin.tariffs.saveButton')}
diff --git a/src/pages/AdminTariffs.tsx b/src/pages/AdminTariffs.tsx index f66d013..dc139c2 100644 --- a/src/pages/AdminTariffs.tsx +++ b/src/pages/AdminTariffs.tsx @@ -21,87 +21,17 @@ import { verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; - -// Icons -const PlusIcon = () => ( - - - -); - -const EditIcon = () => ( - - - -); - -const TrashIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - -const XIcon = () => ( - - - -); - -const GiftIcon = () => ( - - - -); - -const BackIcon = () => ( - - - -); - -const GripIcon = () => ( - - - -); - -const SaveIcon = () => ( - - - -); +import { + BackIcon, + CheckIcon, + EditIcon, + GiftIcon, + GripIcon, + PlusIcon, + SaveIcon, + TrashIcon, + XIcon, +} from '@/components/icons'; // ============ Sortable Tariff Card ============ @@ -175,19 +105,7 @@ function SortableTariffCard({ )} {tariff.show_in_gift && ( - - - + {t('admin.tariffs.giftBadge')} )} @@ -257,7 +175,7 @@ function SortableTariffCard({ className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-error-500/20 hover:text-error-400" title={t('admin.tariffs.delete')} > - +
@@ -403,7 +321,7 @@ export default function AdminTariffs() { {saveOrderMutation.isPending ? (
) : ( - + )} {t('admin.tariffs.saveOrder')} diff --git a/src/pages/AdminTicketSettings.tsx b/src/pages/AdminTicketSettings.tsx index bc3d0c9..5997d79 100644 --- a/src/pages/AdminTicketSettings.tsx +++ b/src/pages/AdminTicketSettings.tsx @@ -4,21 +4,11 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { adminApi } from '../api/admin'; import { AdminBackButton } from '../components/admin'; +import { SettingsIcon } from '@/components/icons'; import { toNumber } from '../utils/inputHelpers'; type NumberOrEmpty = number | ''; -const SettingsIcon = () => ( - - - - -); - export default function AdminTicketSettings() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -132,7 +122,7 @@ export default function AdminTicketSettings() {
- +

{t('admin.tickets.settings')}

diff --git a/src/pages/AdminTickets.tsx b/src/pages/AdminTickets.tsx index c1703b1..d33bc9d 100644 --- a/src/pages/AdminTickets.tsx +++ b/src/pages/AdminTickets.tsx @@ -9,6 +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, SettingsIcon, TicketIcon, XIcon } from '@/components/icons'; interface MediaAttachment { id: string; @@ -39,19 +40,6 @@ const ALLOWED_FILE_TYPES: Record = { const ACCEPT_STRING = Object.keys(ALLOWED_FILE_TYPES).join(','); const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB -// BackIcon -const BackIcon = () => ( - - - -); - export default function AdminTickets() { const { t } = useTranslation(); const navigate = useNavigate(); @@ -282,24 +270,7 @@ export default function AdminTickets() { onClick={() => navigate('/admin/tickets/settings')} className="btn-secondary flex items-center gap-2" > - - - - + {t('admin.tickets.settings')}
@@ -445,19 +416,7 @@ export default function AdminTickets() { {!selectedTicketId ? (
- - - +
{t('admin.tickets.selectTicket')}
@@ -605,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/AdminTrafficUsage.tsx b/src/pages/AdminTrafficUsage.tsx index 84d90e9..93870d2 100644 --- a/src/pages/AdminTrafficUsage.tsx +++ b/src/pages/AdminTrafficUsage.tsx @@ -768,7 +768,7 @@ export default function AdminTrafficUsage() { {/* Threshold inputs */}
- + setTotalThreshold('')} className="text-dark-500 hover:text-dark-300" > - + )}
- + setNodeThreshold('')} className="text-dark-500 hover:text-dark-300" > - + )}
diff --git a/src/pages/AdminUpdates.tsx b/src/pages/AdminUpdates.tsx index c09abe5..1c57e4a 100644 --- a/src/pages/AdminUpdates.tsx +++ b/src/pages/AdminUpdates.tsx @@ -4,96 +4,18 @@ import { useQuery } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import DOMPurify from 'dompurify'; import { adminUpdatesApi, ReleaseItem, ProjectReleasesInfo } from '../api/adminUpdates'; +import { + BackIcon, + RefreshIcon, + BotIcon, + CabinetIcon, + TagIcon, + CalendarIcon, + ExternalLinkIcon, +} from '@/components/icons'; declare const __APP_VERSION__: string; -// ============ Icons ============ - -const BackIcon = () => ( - - - -); - -const RefreshIcon = () => ( - - - -); - -const BotIcon = () => ( - - - -); - -const CabinetIcon = () => ( - - - -); - -const TagIcon = () => ( - - - - -); - -const CalendarIcon = () => ( - - - -); - -const ExternalLinkIcon = () => ( - - - -); - // ============ Helpers ============ function formatDate(iso: string): string { @@ -202,7 +124,7 @@ function ReleaseCard({ release }: { release: ReleaseItem }) {
- + {release.tag_name}
{release.name !== release.tag_name && ( @@ -214,7 +136,7 @@ function ReleaseCard({ release }: { release: ReleaseItem }) { )}
- + {formatDate(release.published_at)}
@@ -269,7 +191,7 @@ function ProjectSection({ className="flex items-center gap-1 rounded-lg border border-dark-700/50 px-2.5 py-1.5 text-xs text-dark-400 transition-colors hover:border-dark-600 hover:text-dark-300" > GitHub - +
diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index 9ea40f1..e03816a 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -18,6 +18,7 @@ import { type SubscriptionRequestRecord, } from '../api/adminUsers'; import { promocodesApi, type PromoGroup } from '../api/promocodes'; +import { RefreshIcon, TelegramSmallIcon as TelegramIcon } from '@/components/icons'; import { AdminBackButton } from '../components/admin'; import { GiftsTab } from '../components/admin/userDetail/GiftsTab'; import { SyncTab } from '../components/admin/userDetail/SyncTab'; @@ -33,23 +34,6 @@ import { usePermissionStore } from '../store/permissions'; // StatusBadge / GiftStatusBadge / GiftCard moved to // components/admin/userDetail/{SubscriptionTab,GiftsTab,InfoTab,SyncTab}.tsx) -// RefreshIcon stays here — the page header's reload button uses it. -const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( - - - -); - -const TelegramIcon = () => ( - - - -); - // ============ Main Page ============ export default function AdminUserDetail() { diff --git a/src/pages/AdminUsers.tsx b/src/pages/AdminUsers.tsx index 31e8636..e32f2f4 100644 --- a/src/pages/AdminUsers.tsx +++ b/src/pages/AdminUsers.tsx @@ -5,56 +5,14 @@ import { useQuery } from '@tanstack/react-query'; import { useCurrency } from '../hooks/useCurrency'; import { adminUsersApi, type UserListItem } from '../api/adminUsers'; import { usePlatform } from '../platform/hooks/usePlatform'; - -const BackIcon = () => ( - - - -); - -const SearchIcon = () => ( - - - -); - -const ChevronLeftIcon = () => ( - - - -); - -const ChevronRightIcon = () => ( - - - -); - -const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( - - - -); - -const TelegramIcon = () => ( - - - -); +import { + BackIcon, + SearchIcon, + ChevronLeftIcon, + ChevronRightIcon, + RefreshIcon, + TelegramSmallIcon as TelegramIcon, +} from '@/components/icons'; interface StatCardProps { title: string; diff --git a/src/pages/AdminWheel.tsx b/src/pages/AdminWheel.tsx index d14bc25..d64f0b3 100644 --- a/src/pages/AdminWheel.tsx +++ b/src/pages/AdminWheel.tsx @@ -26,149 +26,25 @@ import { useDestructiveConfirm } from '@/platform'; import { useNotify } from '@/platform/hooks/useNotify'; import FortuneWheel from '../components/wheel/FortuneWheel'; import { ColorPicker } from '@/components/ColorPicker'; +import { + AdjustmentsIcon, + BackIcon, + ChartIcon, + CheckIcon, + ChevronDownIcon, + ChevronUpIcon, + CogIcon, + GiftIcon, + GripVerticalIcon, + PlusIcon, + StarIcon, + TicketIcon, + TrashIcon, + XMarkIcon, +} from '@/components/icons'; import { usePlatform } from '../platform/hooks/usePlatform'; import { toNumber } from '../utils/inputHelpers'; -const BackIcon = () => ( - - - -); - -const CogIcon = () => ( - - - -); - -const GiftIcon = () => ( - - - -); - -const ChartIcon = () => ( - - - -); - -const PlusIcon = () => ( - - - -); - -const TrashIcon = () => ( - - - -); - -const GripVerticalIcon = () => ( - - - -); - -const ChevronDownIcon = () => ( - - - -); - -const ChevronUpIcon = () => ( - - - -); - -const XMarkIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - -const StarIcon = ({ className = 'h-4 w-4' }: { className?: string }) => ( - - - -); - -const AdjustmentsIcon = ({ className = 'h-4 w-4' }: { className?: string }) => ( - - - -); - -const TicketIcon = ({ className = 'h-4 w-4' }: { className?: string }) => ( - - - -); - const PRIZE_TYPE_KEYS = [ { value: 'subscription_days', key: 'subscription_days', emoji: '📅' }, { value: 'balance_bonus', key: 'balance_bonus', emoji: '💰' }, @@ -541,7 +417,7 @@ export default function AdminWheel() { : 'text-dark-400 hover:text-dark-200' }`} > - + {t('admin.wheel.tabs.prizes')} ({config.prizes.length})
@@ -834,7 +710,7 @@ export default function AdminWheel() { {updateConfigMutation.isPending ? (
) : ( - + )} {t('common.save')} @@ -1285,7 +1161,7 @@ function InlinePrizeForm({ {isLoading ? (
) : ( - + )} {prize ? t('common.save') : t('common.confirm')} 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')}

@@ -255,7 +229,7 @@ export default function Contests() {
) : (
- +

{t('contests.noContests')}

)} diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 1ba9ff6..18db455 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -21,12 +21,7 @@ import { promoApi } from '../api/promo'; import PendingGiftCard from '../components/dashboard/PendingGiftCard'; import SubscriptionListCard from '../components/subscription/SubscriptionListCard'; import { API } from '../config/constants'; - -const ChevronRightIcon = () => ( - - - -); +import { ChevronRightIcon, StarIcon } from '@/components/icons'; export default function Dashboard() { const { t } = useTranslation(); @@ -268,16 +263,7 @@ export default function Dashboard() { color: 'rgb(var(--color-accent-400))', }} > - + {promoGroupData.group_name} )} @@ -360,13 +346,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/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..2fea2d8 100644 --- a/src/pages/GiftResult.tsx +++ b/src/pages/GiftResult.tsx @@ -4,11 +4,13 @@ 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'; 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 @@ -53,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:'), @@ -147,32 +161,12 @@ function CodeOnlySuccessState({ > {copied ? ( <> - - - + {t('common.copied', 'Copied!')} ) : ( <> - - - + {t('gift.copyMessage', 'Copy message')} )} @@ -272,19 +266,7 @@ function PendingActivationState({ > {/* Info icon */}
- - - +
@@ -372,19 +354,7 @@ function PollErrorState() { className="flex flex-col items-center gap-6 text-center" >
- - - +
@@ -420,19 +390,7 @@ function PollTimedOutState({ onRetry }: { onRetry: () => void }) { className="flex flex-col items-center gap-6 text-center" >
- - - +

@@ -467,19 +425,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..9dc2002 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,112 +22,17 @@ import { getApiErrorMessage } from '../utils/api-error'; import { formatPrice } from '../utils/format'; import { useCurrency } from '../hooks/useCurrency'; import { usePlatform, useHaptic } from '@/platform'; - -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, @@ -193,19 +99,7 @@ function ErrorState({ message }: { message: string }) {
- - - +

{t('gift.failedTitle')}

{message}

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

{t('gift.featureDisabled')}

{t('gift.redirecting')}

@@ -686,17 +568,7 @@ function BuyTabContent({ {config.promo_group_name && (
- - - +
@@ -713,17 +585,7 @@ function BuyTabContent({ {config.active_discount_percent != null && config.active_discount_percent > 0 && (
- - - +
{t('promo.discountApplied')} -{config.active_discount_percent}% @@ -1010,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); @@ -1022,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'), '', @@ -1035,7 +907,7 @@ function SentGiftCard({ gift }: { gift: SentGift }) { ] .filter(Boolean) .join('\n'); - }, [shortCode, t]); + }, [shortCode, botUsername, t]); const handleShare = useCallback(async () => { const message = buildShareMessage(); @@ -1089,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')} @@ -1334,7 +1206,7 @@ export default function GiftSubscription() { return (
-
+
{/* Header */} ( - - - -); - -const QuestionIcon = () => ( - - - -); - -const DocumentIcon = () => ( - - - -); - -const ShieldIcon = () => ( - - - -); - -const StarIcon = () => ( - - - -); +import { DocumentIcon, InfoIcon, QuestionIcon, ShieldIcon, StarIcon } from '@/components/icons'; const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( - - - + ); const BUILTIN_TABS = new Set(['faq', 'rules', 'privacy', 'offer', 'loyalty']); @@ -860,7 +804,7 @@ export default function Info() { return (
- +

{t('info.title')}

diff --git a/src/pages/InfoPageView.tsx b/src/pages/InfoPageView.tsx index d146b87..5f8bdf7 100644 --- a/src/pages/InfoPageView.tsx +++ b/src/pages/InfoPageView.tsx @@ -3,23 +3,12 @@ import { useParams, useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useQuery } from '@tanstack/react-query'; import DOMPurify from 'dompurify'; +import { PiCaretDown } from 'react-icons/pi'; +import { BackIcon, SearchIcon } from '@/components/icons'; import { infoPagesApi } from '../api/infoPages'; import { usePlatform } from '../platform/hooks/usePlatform'; import type { FaqItem } from '../api/infoPages'; -// Icons -const BackIcon = () => ( - - - -); - /** * Sanitization config — same strict allowlist as NewsArticlePage. * All HTML content is sanitized with DOMPurify before rendering. @@ -171,31 +160,9 @@ function sanitizeHtml(html: string): string { // --- FAQ Accordion --- const ChevronIcon = ({ open }: { open: boolean }) => ( - - - -); - -const SearchIcon = () => ( - - - + /> ); function FaqAccordionItem({ @@ -275,7 +242,7 @@ function FaqView({ items }: { items: FaqItem[] }) { {items.length > 3 && (
- +
- - - + {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/NewsArticle.tsx b/src/pages/NewsArticle.tsx index 979ec06..7a0f711 100644 --- a/src/pages/NewsArticle.tsx +++ b/src/pages/NewsArticle.tsx @@ -6,39 +6,7 @@ import { motion } from 'framer-motion'; import DOMPurify from 'dompurify'; import { newsApi } from '../api/news'; import { usePlatform } from '../platform/hooks/usePlatform'; - -// Icons -const BackIcon = () => ( - - - -); - -const ClockIcon = () => ( - - - -); - -const CalendarIcon = () => ( - - - -); +import { BackIcon, ClockIcon, CalendarIcon } from '@/components/icons'; /** * Sanitizes HTML content using DOMPurify to prevent XSS attacks. @@ -386,12 +354,12 @@ export default function NewsArticlePage() {

{article.published_at && ( - + {new Date(article.published_at).toLocaleDateString(i18n.language)} )} - + {article.read_time_minutes} {t('news.readTime')}
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 7a44de8..2b46fba 100644 --- a/src/pages/Polls.tsx +++ b/src/pages/Polls.tsx @@ -3,32 +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'; - -const ClipboardIcon = () => ( - - - -); - -const GiftIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); +import { ClipboardIcon, GiftIcon, CheckIcon, CloseIcon } from '@/components/icons'; export default function Polls() { const { t } = useTranslation(); @@ -131,7 +106,7 @@ export default function Polls() { return (
- +

{t('polls.title')}

@@ -160,14 +135,7 @@ export default function Polls() { aria-label={t('common.close')} className="text-dark-400 hover:text-dark-200" > - - - +
@@ -180,7 +148,7 @@ export default function Polls() { {completionMessage && (
- +

{completionMessage.message}

{completionMessage.reward && (

@@ -252,7 +220,7 @@ export default function Polls() {

{poll.reward_amount && (
- + +{poll.reward_amount}
)} @@ -261,7 +229,7 @@ export default function Polls() {
{poll.is_completed ? ( ) : ( @@ -275,7 +243,7 @@ export default function Polls() {
) : (
- +

{t('polls.noPolls')}

)} diff --git a/src/pages/Profile.tsx b/src/pages/Profile.tsx index 58a2725..fe82370 100644 --- a/src/pages/Profile.tsx +++ b/src/pages/Profile.tsx @@ -21,46 +21,7 @@ import { Card } from '@/components/data-display/Card'; import { Button } from '@/components/primitives/Button'; import { Switch } from '@/components/primitives/Switch'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; - -// Icons -const CopyIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - -const ShareIcon = () => ( - - - - -); - -const ArrowRightIcon = () => ( - - - -); - -const PencilIcon = () => ( - - - -); +import { CopyIcon, CheckIcon, ShareIcon, ArrowRightIcon, PencilIcon } from '@/components/icons'; export default function Profile() { const { t } = useTranslation(); @@ -358,15 +319,7 @@ export default function Profile() {

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

- - - +
@@ -382,7 +335,7 @@ export default function Profile() { className="flex items-center gap-1 text-accent-400 transition-colors hover:text-accent-300" > {t('referral.title')} - +
@@ -401,7 +354,7 @@ export default function Profile() {
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 574c1a9..e669e67 100644 --- a/src/pages/Referral.tsx +++ b/src/pages/Referral.tsx @@ -10,69 +10,18 @@ import { partnerApi } from '../api/partners'; import { withdrawalApi } from '../api/withdrawals'; import { CampaignCard } from '../components/partner/CampaignCard'; import { useCurrency } from '../hooks/useCurrency'; - -const LinkIcon = () => ( - - - -); - -const CopyIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); - -const ShareIcon = () => ( - - - - -); - -const PartnerIcon = () => ( - - - -); - -const ClockIcon = () => ( - - - -); - -const WalletIcon = () => ( - - - -); +import { + CheckIcon, + ClockIcon, + CopyIcon, + ExclamationIcon, + LinkIcon, + PartnerIcon, + ShareIcon, + TelegramIcon, + UsersIcon, + WalletIcon, +} from '@/components/icons'; function getWithdrawalStatusBadge(status: string): string { switch (status) { @@ -266,19 +215,7 @@ export default function Referral() { return (
- - - +

{t('referral.title')}

@@ -327,9 +264,7 @@ export default function Referral() { {botReferralLink && (
- - - + {t('referral.botLink')}
@@ -356,19 +291,7 @@ export default function Referral() { {/* Cabinet link */}
- - - + {t('referral.cabinetLink')}
@@ -393,7 +316,7 @@ export default function Referral() { !referralLink ? 'cursor-not-allowed opacity-50' : '' }`} > - + {t('referral.shareButton')}
@@ -437,19 +360,7 @@ export default function Referral() { ) : (
- - - +
{t('referral.noReferrals')}
@@ -495,7 +406,7 @@ export default function Referral() {
- +

@@ -546,7 +457,7 @@ export default function Referral() {
- +
@@ -573,19 +484,7 @@ export default function Referral() {
- - - +

@@ -638,7 +537,7 @@ export default function Referral() {
- +

{t('referral.withdrawal.title')} 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..1fcd783 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')}
@@ -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'); @@ -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', 'Новый тариф')} )} @@ -157,13 +138,26 @@ export default function Subscriptions() { {/* Empty state: показываем триал, если доступен; иначе — обычный empty */} {hasNoSubscriptions && !trialLoading && trialInfo?.is_available && ( - +
+ + {/* Новый пользователь не обязан активировать триал, чтобы попасть + в витрину — даём явный путь к покупке подписки. Раньше при + доступном триале это был единственный экран без кнопки «Купить» + (Telegram-баг #605056/#605063). */} + +
)} {hasNoSubscriptions && !trialLoading && !trialInfo?.is_available && ( navigate('/subscription/purchase')} /> @@ -171,7 +165,7 @@ export default function Subscriptions() { {/* Subscription grid */} {subscriptions.length > 0 && ( -
+
{subscriptions.map((sub) => ( ( - - - -); - -const SendIcon = () => ( - - - -); - -const ImageIcon = () => ( - - - -); - -const CloseIcon = () => ( - - - -); - // Media attachment state interface MediaAttachment { id: string; @@ -297,19 +266,7 @@ export default function Support() {
- - - +

{supportMessage.title}

{supportMessage.message}

@@ -360,7 +317,7 @@ export default function Support() { onClick={() => onRemove(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" > - +
))} @@ -397,19 +354,7 @@ export default function Support() {
- - - +
{t('support.contactUs')}
@@ -471,19 +416,7 @@ export default function Support() { ) : (
- - - +
{t('support.noTickets')}
@@ -593,7 +526,7 @@ export default function Support() { disabled={createAttachments.some((a) => a.uploading)} loading={createMutation.isPending} > - + {t('support.send')}
{rateLimitError && ( 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 3ea1a48..a4cc3d7 100644 --- a/src/pages/TopUpAmount.tsx +++ b/src/pages/TopUpAmount.tsx @@ -15,65 +15,16 @@ import BentoCard from '../components/ui/BentoCard'; import { saveTopUpPendingInfo } from '../utils/topUpStorage'; import { getSafeRedirectPath } from '../utils/safeRedirect'; import { copyToClipboard } from '@/utils/clipboard'; - -// Icons -const StarIcon = () => ( - - - -); - -const CardIcon = () => ( - - - -); - -const CryptoIcon = () => ( - - - -); - -const SparklesIcon = () => ( - - - -); - -const ExternalLinkIcon = () => ( - - - -); - -const CopyIcon = () => ( - - - -); - -const CheckIcon = () => ( - - - -); +import { + CardIcon, + CheckIcon, + CopyIcon, + CryptoIcon, + ExclamationIcon, + ExternalLinkIcon, + SparklesIcon, + StarIcon, +} from '@/components/icons'; const getMethodIcon = (methodId: string) => { const id = methodId.toLowerCase(); @@ -519,7 +470,7 @@ export default function TopUpAmount() { ) : ( <> - + {t('balance.topUp')} )} @@ -570,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} )} @@ -594,7 +533,7 @@ export default function TopUpAmount() { className="space-y-3 rounded-2xl border border-success-500/20 bg-success-500/10 p-4" >
- + {t('balance.paymentReady')}
@@ -623,7 +562,7 @@ export default function TopUpAmount() { }`} title={t('common.copy')} > - {copied ? : } + {copied ? : }
diff --git a/src/pages/Wheel.tsx b/src/pages/Wheel.tsx index 5e86cb6..9f6d4d0 100644 --- a/src/pages/Wheel.tsx +++ b/src/pages/Wheel.tsx @@ -10,50 +10,15 @@ import { Card } from '@/components/data-display/Card/Card'; import { Button } from '@/components/primitives/Button/Button'; import { motion, AnimatePresence } from 'framer-motion'; import { staggerContainer, staggerItem } from '@/components/motion/transitions'; +import { PiCaretDown } from 'react-icons/pi'; +import { StarIcon, CalendarIcon, HistoryIcon, CloseIcon } from '@/components/icons'; +import { cn } from '@/lib/utils'; // Icons -const StarIcon = () => ( - - - -); - -const CalendarIcon = () => ( - - - -); - -const HistoryIcon = () => ( - - - -); - -const CloseIcon = () => ( - - - -); - const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( - - - + ); /** @@ -732,7 +697,7 @@ export default function Wheel() { onClick={closeResultModal} className="shrink-0 rounded-lg p-2 text-dark-400 transition-colors hover:bg-white/5 hover:text-dark-200" > - +
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/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; 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',