Merge pull request #445 from BEDOLAGA-DEV/dev

Релиз: миграция иконок, /admin/remnawave, колесо, флаги, подарки
This commit is contained in:
c0mrade
2026-06-01 18:34:17 +03:00
committed by GitHub
162 changed files with 2854 additions and 7606 deletions

10
package-lock.json generated
View File

@@ -55,6 +55,7 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",
"react-icons": "^5.6.0",
"react-router": "^7.13.0", "react-router": "^7.13.0",
"react-twemoji": "^0.7.2", "react-twemoji": "^0.7.2",
"recharts": "^3.7.0", "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": { "node_modules/react-is": {
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",

View File

@@ -63,6 +63,7 @@
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",
"react-icons": "^5.6.0",
"react-router": "^7.13.0", "react-router": "^7.13.0",
"react-twemoji": "^0.7.2", "react-twemoji": "^0.7.2",
"recharts": "^3.7.0", "recharts": "^3.7.0",

Binary file not shown.

View File

@@ -1,11 +1,12 @@
import { useEffect, useRef, useCallback } from 'react'; import { useEffect, useRef, useCallback } from 'react';
import { BrowserRouter, useLocation, useNavigate } from 'react-router'; import { BrowserRouter, useLocation, useNavigate, useNavigationType } from 'react-router';
import { import {
showBackButton, showBackButton,
hideBackButton, hideBackButton,
onBackButtonClick, onBackButtonClick,
offBackButtonClick, offBackButtonClick,
} from '@telegram-apps/sdk-react'; } from '@telegram-apps/sdk-react';
import { useQuery } from '@tanstack/react-query';
import Twemoji from 'react-twemoji'; import Twemoji from 'react-twemoji';
import App from './App'; import App from './App';
import { ErrorBoundary } from './components/ErrorBoundary'; import { ErrorBoundary } from './components/ErrorBoundary';
@@ -15,7 +16,8 @@ import { WebSocketProvider } from './providers/WebSocketProvider';
import { ToastProvider } from './components/Toast'; import { ToastProvider } from './components/Toast';
import { TooltipProvider } from './components/primitives/Tooltip'; import { TooltipProvider } from './components/primitives/Tooltip';
import { isInTelegramWebApp } from './hooks/useTelegramSDK'; 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; 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). */ /** Pages reachable from bottom nav — treat as top-level (no back button). */
const BOTTOM_NAV_PATHS = ['/', '/subscriptions', '/balance', '/referral', '/support', '/wheel']; 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() { function TelegramBackButton() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const navType = useNavigationType();
const navigateRef = useRef(navigate); const navigateRef = useRef(navigate);
navigateRef.current = navigate; navigateRef.current = navigate;
const pathnameRef = useRef(location.pathname); const pathnameRef = useRef(location.pathname);
pathnameRef.current = 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<string | null>(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(() => { useEffect(() => {
const isTopLevel = location.pathname === '' || BOTTOM_NAV_PATHS.includes(location.pathname); const isTopLevel = location.pathname === '' || BOTTOM_NAV_PATHS.includes(location.pathname);
const isSingleTariffDetailDeepLink =
!isMultiTariff && SUBSCRIPTION_DETAIL_RE.test(location.pathname) && depthRef.current === 0;
try { try {
if (isTopLevel) { if (isTopLevel || isSingleTariffDetailDeepLink) {
hideBackButton(); hideBackButton();
} else { } else {
showBackButton(); showBackButton();
} }
} catch {} } catch {}
}, [location]); }, [location, isMultiTariff]);
// Stable handler — ref prevents re-subscription on every render // Stable handler — ref prevents re-subscription on every render
const handler = useCallback(() => { const handler = useCallback(() => {
// When opened via a bot deep-link directly on a nested route, there is no // Real in-app history (depth > 0): a normal back. Otherwise we were opened
// in-app history and navigate(-1) is a no-op — the back button looks dead. // directly on this route via a deep-link — navigate(-1) is a no-op, so fall
// Fall back to the parent route so it always navigates somewhere sensible. // back to a sensible parent route instead.
if (hasInAppHistory()) { if (depthRef.current > 0) {
navigateRef.current(-1); navigateRef.current(-1);
} else { return;
navigateRef.current(getFallbackParentPath(pathnameRef.current), { replace: true });
} }
// /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(() => { useEffect(() => {

View File

@@ -9,7 +9,7 @@ export interface LocalizedText {
} }
export const adminAppsApi = { export const adminAppsApi = {
// Get RemnaWave config status // Get Remnawave config status
getRemnaWaveStatus: async (): Promise<{ enabled: boolean; config_uuid: string | null }> => { getRemnaWaveStatus: async (): Promise<{ enabled: boolean; config_uuid: string | null }> => {
const response = await apiClient.get<{ enabled: boolean; config_uuid: string | null }>( const response = await apiClient.get<{ enabled: boolean; config_uuid: string | null }>(
'/cabinet/admin/apps/remnawave/status', '/cabinet/admin/apps/remnawave/status',
@@ -17,7 +17,7 @@ export const adminAppsApi = {
return response.data; return response.data;
}, },
// Set RemnaWave config UUID // Set Remnawave config UUID
setRemnaWaveUuid: async ( setRemnaWaveUuid: async (
uuid: string | null, uuid: string | null,
): Promise<{ enabled: boolean; config_uuid: string | null }> => { ): Promise<{ enabled: boolean; config_uuid: string | null }> => {
@@ -28,7 +28,7 @@ export const adminAppsApi = {
return response.data; return response.data;
}, },
// List available RemnaWave configs // List available Remnawave configs
listRemnaWaveConfigs: async (): Promise< listRemnaWaveConfigs: async (): Promise<
{ uuid: string; name: string; view_position: number }[] { uuid: string; name: string; view_position: number }[]
> => { > => {
@@ -38,7 +38,7 @@ export const adminAppsApi = {
return response.data; return response.data;
}, },
// Get RemnaWave subscription config // Get Remnawave subscription config
getRemnaWaveConfig: async (): Promise<RemnawaveConfig> => { getRemnaWaveConfig: async (): Promise<RemnawaveConfig> => {
const response = await apiClient.get<{ const response = await apiClient.get<{
uuid: string; uuid: string;
@@ -50,7 +50,7 @@ export const adminAppsApi = {
}, },
}; };
// ============== RemnaWave Format Types ============== // ============== Remnawave Format Types ==============
export interface RemnawaveButton { export interface RemnawaveButton {
url: string; url: string;

View File

@@ -21,12 +21,57 @@ export interface SystemSummary {
total_users: number; total_users: number;
active_connections: number; active_connections: number;
nodes_online: number; nodes_online: number;
total_nodes: number;
users_last_day: number; users_last_day: number;
users_last_week: number; users_last_week: number;
users_never_online: number; users_never_online: number;
total_user_traffic: 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 { export interface ServerInfo {
cpu_cores: number; cpu_cores: number;
memory_total: number; memory_total: number;
@@ -89,6 +134,8 @@ export interface NodeInfo {
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
provider_uuid?: string; provider_uuid?: string;
provider_name?: string | null;
provider_favicon?: string | null;
versions?: { xray: string; node: string } | null; versions?: { xray: string; node: string } | null;
system?: { system?: {
info: { info: {
@@ -292,6 +339,34 @@ export const adminRemnawaveApi = {
return response.data; return response.data;
}, },
// Panel recap / devices / top consumers
getRecap: async (): Promise<RecapResponse> => {
const response = await apiClient.get('/cabinet/admin/remnawave/recap');
return response.data;
},
getDevicesStats: async (): Promise<DevicesStatsResponse> => {
const response = await apiClient.get('/cabinet/admin/remnawave/devices-stats');
return response.data;
},
getTopConsumers: async (days = 7, limit = 10): Promise<TopConsumersResponse> => {
const response = await apiClient.get('/cabinet/admin/remnawave/top-consumers', {
params: { days, limit },
});
return response.data;
},
getHealth: async (): Promise<HealthResponse> => {
const response = await apiClient.get('/cabinet/admin/remnawave/health');
return response.data;
},
getSubscriptionRequests: async (): Promise<SubscriptionRequestStatsResponse> => {
const response = await apiClient.get('/cabinet/admin/remnawave/subscription-requests');
return response.data;
},
// Nodes // Nodes
getNodes: async (): Promise<NodesListResponse> => { getNodes: async (): Promise<NodesListResponse> => {
const response = await apiClient.get('/cabinet/admin/remnawave/nodes'); const response = await apiClient.get('/cabinet/admin/remnawave/nodes');

View File

@@ -698,7 +698,7 @@ export const adminUsersApi = {
return response.data; return response.data;
}, },
// Get subscription request history from RemnaWave panel // Get subscription request history from Remnawave panel
getSubscriptionRequestHistory: async ( getSubscriptionRequestHistory: async (
userId: number, userId: number,
subscriptionId?: number, subscriptionId?: number,

View File

@@ -134,7 +134,7 @@ export const serversApi = {
return response.data; return response.data;
}, },
// Sync servers with RemnaWave // Sync servers with Remnawave
syncServers: async (): Promise<ServerSyncResponse> => { syncServers: async (): Promise<ServerSyncResponse> => {
const response = await apiClient.post('/cabinet/admin/servers/sync'); const response = await apiClient.post('/cabinet/admin/servers/sync');
return response.data; return response.data;

View File

@@ -87,7 +87,7 @@ export interface TariffDetail {
daily_price_kopeks: number; daily_price_kopeks: number;
// Режим сброса трафика // Режим сброса трафика
traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'MONTH_ROLLING', 'NO_RESET', null = глобальная настройка traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'MONTH_ROLLING', 'NO_RESET', null = глобальная настройка
// Внешний сквад RemnaWave // Внешний сквад Remnawave
external_squad_uuid: string | null; external_squad_uuid: string | null;
created_at: string; created_at: string;
updated_at: string | null; updated_at: string | null;
@@ -126,7 +126,7 @@ export interface TariffCreateRequest {
daily_price_kopeks?: number; daily_price_kopeks?: number;
// Режим сброса трафика // Режим сброса трафика
traffic_reset_mode?: string | null; traffic_reset_mode?: string | null;
// Внешний сквад RemnaWave // Внешний сквад Remnawave
external_squad_uuid?: string | null; external_squad_uuid?: string | null;
} }
@@ -170,7 +170,7 @@ export interface TariffUpdateRequest {
daily_price_kopeks?: number; daily_price_kopeks?: number;
// Режим сброса трафика // Режим сброса трафика
traffic_reset_mode?: string | null; traffic_reset_mode?: string | null;
// Внешний сквад RemnaWave // Внешний сквад Remnawave
external_squad_uuid?: string | null; external_squad_uuid?: string | null;
} }
@@ -266,7 +266,7 @@ export const tariffsApi = {
return response.data; return response.data;
}, },
// Get available external squads from RemnaWave // Get available external squads from Remnawave
getAvailableExternalSquads: async (): Promise<ExternalSquadInfo[]> => { getAvailableExternalSquads: async (): Promise<ExternalSquadInfo[]> => {
const response = await apiClient.get('/cabinet/admin/tariffs/available-external-squads'); const response = await apiClient.get('/cabinet/admin/tariffs/available-external-squads');
return response.data; return response.data;

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router'; import { useNavigate, useLocation } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
import { InfoIcon, WalletIcon, PlusIcon } from '@/components/icons';
interface InsufficientBalancePromptProps { interface InsufficientBalancePromptProps {
/** Amount missing in kopeks */ /** 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}`} className={`flex items-center justify-between gap-3 rounded-xl border border-error-500/30 bg-error-500/10 p-3 ${className}`}
> >
<div className="flex items-center gap-2 text-sm text-error-400"> <div className="flex items-center gap-2 text-sm text-error-400">
<svg <InfoIcon className="h-4 w-4 flex-shrink-0" />
className="h-4 w-4 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
<span> <span>
{message || t('balance.insufficientFunds')}:{' '} {message || t('balance.insufficientFunds')}:{' '}
<span className="font-semibold"> <span className="font-semibold">
@@ -96,19 +85,7 @@ export default function InsufficientBalancePrompt({
> >
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-error-500/20"> <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-error-500/20">
<svg <WalletIcon className="h-5 w-5 text-error-400" />
className="h-5 w-5 text-error-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
/>
</svg>
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="mb-1 font-medium text-error-400">{t('balance.insufficientFunds')}</div> <div className="mb-1 font-medium text-error-400">{t('balance.insufficientFunds')}</div>
@@ -132,15 +109,7 @@ export default function InsufficientBalancePrompt({
<span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : ( ) : (
<> <>
<svg <PlusIcon className="h-5 w-5" />
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{t('balance.topUpBalance')} {t('balance.topUpBalance')}
</> </>
)} )}

View File

@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { infoApi, type LanguageInfo } from '@/api/info'; import { infoApi, type LanguageInfo } from '@/api/info';
import { ChevronDownIcon } from '@/components/icons';
export default function LanguageSwitcher() { export default function LanguageSwitcher() {
const { i18n } = useTranslation(); const { i18n } = useTranslation();
@@ -57,14 +58,9 @@ export default function LanguageSwitcher() {
> >
<span>{currentLang.flag}</span> <span>{currentLang.flag}</span>
<span className="font-medium text-dark-200">{currentLang.code.toUpperCase()}</span> <span className="font-medium text-dark-200">{currentLang.code.toUpperCase()}</span>
<svg <ChevronDownIcon
className={`h-3.5 w-3.5 text-dark-400 transition-transform ${isOpen ? 'rotate-180' : ''}`} className={`h-3.5 w-3.5 text-dark-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none" />
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button> </button>
{isOpen && ( {isOpen && (

View File

@@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { promoApi, PromoOffer } from '../api/promo'; import { promoApi, PromoOffer } from '../api/promo';
import { ClockIcon, CheckIcon } from './icons'; import { ClockIcon, CheckIcon, XCircleIcon } from './icons';
import { useDestructiveConfirm } from '@/platform/hooks/useNativeDialog'; import { useDestructiveConfirm } from '@/platform/hooks/useNativeDialog';
// Helper functions // Helper functions
@@ -69,17 +69,6 @@ const getOfferDescription = (
return t('promo.offers.activateDiscountHint'); return t('promo.offers.activateDiscountHint');
}; };
// Icons for deactivation
const XCircleIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
interface PromoOffersSectionProps { interface PromoOffersSectionProps {
className?: string; className?: string;
} }
@@ -237,7 +226,7 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
onClick={handleDeactivateClick} 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" 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"
> >
<XCircleIcon /> <XCircleIcon className="h-4 w-4" />
<span>{t('promo.deactivate.button')}</span> <span>{t('promo.deactivate.button')}</span>
</button> </button>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { EmailIcon as CentralEmailIcon } from '@/components/icons';
import OAuthProviderIcon from './OAuthProviderIcon'; import OAuthProviderIcon from './OAuthProviderIcon';
export function TelegramIcon({ className = 'h-5 w-5' }: { className?: string }) { 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 }) { export function EmailIcon({ className = 'h-5 w-5' }: { className?: string }) {
return ( return <CentralEmailIcon className={className} />;
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
);
} }
export default function ProviderIcon({ export default function ProviderIcon({

View File

@@ -12,69 +12,14 @@ import { useCurrency } from '../hooks/useCurrency';
import { useTelegramSDK } from '../hooks/useTelegramSDK'; import { useTelegramSDK } from '../hooks/useTelegramSDK';
import { useFocusTrap } from '../hooks/useFocusTrap'; import { useFocusTrap } from '../hooks/useFocusTrap';
import { useHaptic } from '@/platform'; import { useHaptic } from '@/platform';
import {
// Icons CheckCircleIcon,
const CheckCircleIcon = () => ( CloseIcon,
<svg DevicesIcon,
className="h-16 w-16" RocketIcon,
fill="none" TrafficIcon,
viewBox="0 0 24 24" WalletIcon,
stroke="currentColor" } from '@/components/icons';
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
const WalletIcon = () => (
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
/>
</svg>
);
const RocketIcon = () => (
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.59 14.37a6 6 0 01-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 006.16-12.12A14.98 14.98 0 009.631 8.41m5.96 5.96a14.926 14.926 0 01-5.841 2.58m-.119-8.54a6 6 0 00-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 00-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 01-2.448-2.448 14.9 14.9 0 01.06-.312m-2.24 2.39a4.493 4.493 0 00-1.757 4.306 4.493 4.493 0 004.306-1.758M16.5 9a1.5 1.5 0 11-3 0 1.5 1.5 0 013 0z"
/>
</svg>
);
const DevicesIcon = () => (
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
/>
</svg>
);
const TrafficIcon = () => (
<svg className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/>
</svg>
);
const CloseIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
export default function SuccessNotificationModal() { export default function SuccessNotificationModal() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -161,33 +106,33 @@ export default function SuccessNotificationModal() {
// Determine title and message // Determine title and message
let title = data.title; let title = data.title;
const message = data.message; const message = data.message;
let icon = <CheckCircleIcon />; let icon = <CheckCircleIcon className="h-16 w-16" />;
let gradientClass = 'from-success-500 to-success-600'; let gradientClass = 'from-success-500 to-success-600';
if (!title) { if (!title) {
if (isBalanceTopup) { if (isBalanceTopup) {
title = t('successNotification.balanceTopup.title', 'Balance topped up!'); title = t('successNotification.balanceTopup.title', 'Balance topped up!');
icon = <WalletIcon />; icon = <WalletIcon className="h-8 w-8" />;
gradientClass = 'from-success-500 to-success-600'; gradientClass = 'from-success-500 to-success-600';
} else if (data.type === 'subscription_activated') { } else if (data.type === 'subscription_activated') {
title = t('successNotification.subscriptionActivated.title', 'Subscription activated!'); title = t('successNotification.subscriptionActivated.title', 'Subscription activated!');
icon = <RocketIcon />; icon = <RocketIcon className="h-8 w-8" />;
gradientClass = 'from-accent-500 to-purple-600'; gradientClass = 'from-accent-500 to-purple-600';
} else if (data.type === 'subscription_renewed') { } else if (data.type === 'subscription_renewed') {
title = t('successNotification.subscriptionRenewed.title', 'Subscription renewed!'); title = t('successNotification.subscriptionRenewed.title', 'Subscription renewed!');
icon = <RocketIcon />; icon = <RocketIcon className="h-8 w-8" />;
gradientClass = 'from-accent-500 to-purple-600'; gradientClass = 'from-accent-500 to-purple-600';
} else if (data.type === 'subscription_purchased') { } else if (data.type === 'subscription_purchased') {
title = t('successNotification.subscriptionPurchased.title', 'Subscription purchased!'); title = t('successNotification.subscriptionPurchased.title', 'Subscription purchased!');
icon = <RocketIcon />; icon = <RocketIcon className="h-8 w-8" />;
gradientClass = 'from-accent-500 to-purple-600'; gradientClass = 'from-accent-500 to-purple-600';
} else if (data.type === 'devices_purchased') { } else if (data.type === 'devices_purchased') {
title = t('successNotification.devicesPurchased.title', 'Devices added!'); title = t('successNotification.devicesPurchased.title', 'Devices added!');
icon = <DevicesIcon />; icon = <DevicesIcon className="h-8 w-8" />;
gradientClass = 'from-blue-500 to-cyan-600'; gradientClass = 'from-blue-500 to-cyan-600';
} else if (data.type === 'traffic_purchased') { } else if (data.type === 'traffic_purchased') {
title = t('successNotification.trafficPurchased.title', 'Traffic added!'); title = t('successNotification.trafficPurchased.title', 'Traffic added!');
icon = <TrafficIcon />; icon = <TrafficIcon className="h-8 w-8" />;
gradientClass = 'from-success-500 to-success-600'; gradientClass = 'from-success-500 to-success-600';
} }
} }
@@ -334,7 +279,7 @@ export default function SuccessNotificationModal() {
onClick={handleGoToSubscription} 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" 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"
> >
<RocketIcon /> <RocketIcon className="h-8 w-8" />
<span>{t('successNotification.goToSubscription', 'Go to Subscription')}</span> <span>{t('successNotification.goToSubscription', 'Go to Subscription')}</span>
</button> </button>
)} )}
@@ -344,7 +289,7 @@ export default function SuccessNotificationModal() {
onClick={handleGoToBalance} 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" 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"
> >
<WalletIcon /> <WalletIcon className="h-8 w-8" />
<span>{t('successNotification.goToBalance', 'Go to Balance')}</span> <span>{t('successNotification.goToBalance', 'Go to Balance')}</span>
</button> </button>
)} )}
@@ -354,7 +299,7 @@ export default function SuccessNotificationModal() {
onClick={handleGoToSubscription} 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" 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"
> >
<DevicesIcon /> <DevicesIcon className="h-8 w-8" />
<span>{t('successNotification.goToSubscription', 'Go to Subscription')}</span> <span>{t('successNotification.goToSubscription', 'Go to Subscription')}</span>
</button> </button>
)} )}
@@ -364,7 +309,7 @@ export default function SuccessNotificationModal() {
onClick={handleGoToSubscription} 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" 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"
> >
<TrafficIcon /> <TrafficIcon className="h-8 w-8" />
<span>{t('successNotification.goToSubscription', 'Go to Subscription')}</span> <span>{t('successNotification.goToSubscription', 'Go to Subscription')}</span>
</button> </button>
)} )}

View File

@@ -8,22 +8,7 @@ import { useToast } from './Toast';
import { useWebSocket, WSMessage } from '../hooks/useWebSocket'; import { useWebSocket, WSMessage } from '../hooks/useWebSocket';
import { useHeaderHeight } from '../hooks/useHeaderHeight'; import { useHeaderHeight } from '../hooks/useHeaderHeight';
import type { TicketNotification } from '../types'; import type { TicketNotification } from '../types';
import { BellIcon, CheckIcon } from '@/components/icons';
const BellIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
/>
</svg>
);
const CheckIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
);
interface TicketNotificationBellProps { interface TicketNotificationBellProps {
isAdmin?: boolean; isAdmin?: boolean;

View File

@@ -8,6 +8,8 @@ import {
ReactNode, ReactNode,
} from 'react'; } from 'react';
import { CheckIcon, XIcon, ExclamationIcon, InfoIcon } from '@/components/icons';
interface ToastOptions { interface ToastOptions {
type?: 'success' | 'error' | 'info' | 'warning'; type?: 'success' | 'error' | 'info' | 'warning';
message: string; message: string;
@@ -163,58 +165,10 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
}; };
const defaultIcons = { const defaultIcons = {
success: ( success: <CheckIcon className="h-5 w-5" />,
<svg error: <XIcon className="h-5 w-5" />,
className="h-5 w-5" warning: <ExclamationIcon className="h-5 w-5" />,
fill="none" info: <InfoIcon className="h-5 w-5" />,
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
),
error: (
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
),
warning: (
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
),
info: (
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
}; };
return ( return (

View File

@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { brandingApi } from '../../api/branding'; import { brandingApi } from '../../api/branding';
import { CheckIcon, CloseIcon } from './icons'; import { CheckIcon, CloseIcon, PencilIcon } from './icons';
export function AnalyticsTab() { export function AnalyticsTab() {
const { t } = useTranslation(); 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" className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
> >
<svg <PencilIcon className="h-4 w-4" />
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</button> </button>
</div> </div>
)} )}
@@ -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" className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
> >
<svg <PencilIcon className="h-4 w-4" />
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</button> </button>
</div> </div>
)} )}
@@ -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" className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
> >
<svg <PencilIcon className="h-4 w-4" />
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</button> </button>
</div> </div>
)} )}

View File

@@ -9,6 +9,7 @@ import {
ButtonSection, ButtonSection,
BOT_LOCALES, BOT_LOCALES,
} from '../../api/buttonStyles'; } from '../../api/buttonStyles';
import { ChevronDownIcon } from '@/components/icons';
import { Toggle } from './Toggle'; import { Toggle } from './Toggle';
import { useNotify } from '../../platform/hooks/useNotify'; import { useNotify } from '../../platform/hooks/useNotify';
import { useNativeDialog } from '../../platform/hooks/useNativeDialog'; import { useNativeDialog } from '../../platform/hooks/useNativeDialog';
@@ -284,15 +285,9 @@ export function ButtonsTab() {
{t('admin.buttons.customLabels')} {t('admin.buttons.customLabels')}
{hasCustomLabels && <span className="h-1.5 w-1.5 rounded-full bg-accent-500" />} {hasCustomLabels && <span className="h-1.5 w-1.5 rounded-full bg-accent-500" />}
</span> </span>
<svg <ChevronDownIcon
className={`h-3.5 w-3.5 transition-transform ${isExpanded ? 'rotate-180' : ''}`} className={`h-3.5 w-3.5 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none" />
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button> </button>
{isExpanded && ( {isExpanded && (
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">

View File

@@ -1,5 +1,6 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CheckIcon, ChevronDownIcon, CloseIcon, PlusIcon } from '@/components/icons';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { useHapticFeedback } from '../../platform/hooks/useHaptic'; 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" className="shrink-0 rounded p-0.5 text-dark-500 transition-colors hover:text-dark-300"
aria-label={t('news.admin.combobox.clear')} aria-label={t('news.admin.combobox.clear')}
> >
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"> <CloseIcon className="h-4 w-4" />
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
</button> </button>
</> </>
) : ( ) : (
@@ -222,16 +221,12 @@ export function ColoredItemCombobox({
</span> </span>
</> </>
)} )}
<svg <ChevronDownIcon
className={cn( className={cn(
'h-4 w-4 shrink-0 text-dark-500 transition-transform duration-200', 'h-4 w-4 shrink-0 text-dark-500 transition-transform duration-200',
isOpen && 'rotate-180', isOpen && 'rotate-180',
)} )}
viewBox="0 0 24 24" />
fill="currentColor"
>
<path d="M7 10l5 5 5-5z" />
</svg>
</button> </button>
{/* Dropdown */} {/* Dropdown */}
@@ -279,13 +274,7 @@ export function ColoredItemCombobox({
/> />
<span className="flex-1 truncate">{item.name}</span> <span className="flex-1 truncate">{item.name}</span>
{value?.id === item.id && ( {value?.id === item.id && (
<svg <CheckIcon className="h-4 w-4 shrink-0 text-accent-400" />
className="h-4 w-4 shrink-0 text-accent-400"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
</svg>
)} )}
{onDelete && ( {onDelete && (
<button <button
@@ -298,9 +287,7 @@ export function ColoredItemCombobox({
{deletingId === item.id ? ( {deletingId === item.id ? (
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-error-400 border-t-transparent" /> <div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-error-400 border-t-transparent" />
) : ( ) : (
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor"> <CloseIcon className="h-3.5 w-3.5" />
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
)} )}
</button> </button>
)} )}
@@ -365,9 +352,7 @@ export function ColoredItemCombobox({
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
) : ( ) : (
<> <>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"> <PlusIcon className="h-4 w-4" />
<path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z" />
</svg>
{t('news.admin.combobox.create')} {t('news.admin.combobox.create')}
</> </>
)} )}

View File

@@ -18,6 +18,15 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { PiCaretDown } from 'react-icons/pi';
import {
GripIcon,
TrashIcon,
PlusIcon,
LinkIcon,
ArrowUpIcon,
ArrowDownIcon,
} from '@/components/icons';
import { import {
menuLayoutApi, menuLayoutApi,
type MenuConfig, type MenuConfig,
@@ -31,82 +40,8 @@ import { Toggle } from './Toggle';
import { useNotify } from '../../platform/hooks/useNotify'; import { useNotify } from '../../platform/hooks/useNotify';
import { useNativeDialog } from '../../platform/hooks/useNativeDialog'; import { useNativeDialog } from '../../platform/hooks/useNativeDialog';
const GripIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
);
const TrashIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
const PlusIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
<svg <PiCaretDown className={`h-3.5 w-3.5 transition-transform ${expanded ? 'rotate-180' : ''}`} />
className={`h-3.5 w-3.5 transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
);
const LinkIcon = () => (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.54a4.5 4.5 0 00-1.242-7.244l-4.5-4.5a4.5 4.5 0 00-6.364 6.364L4.03 8.591"
/>
</svg>
);
const ArrowUpIcon = () => (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
</svg>
);
const ArrowDownIcon = () => (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
); );
function generateId(): string { function generateId(): string {
@@ -200,7 +135,7 @@ function ButtonChip({
: 'cursor-default text-dark-700' : 'cursor-default text-dark-700'
}`} }`}
> >
<ArrowUpIcon /> <ArrowUpIcon className="h-3.5 w-3.5" />
</button> </button>
<button <button
onClick={onMoveDown ?? undefined} onClick={onMoveDown ?? undefined}
@@ -212,7 +147,7 @@ function ButtonChip({
: 'cursor-default text-dark-700' : 'cursor-default text-dark-700'
}`} }`}
> >
<ArrowDownIcon /> <ArrowDownIcon className="h-3.5 w-3.5" />
</button> </button>
</div> </div>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${colorDotClass}`} /> <span className={`h-2.5 w-2.5 shrink-0 rounded-full ${colorDotClass}`} />
@@ -221,7 +156,7 @@ function ButtonChip({
</span> </span>
{!isBuiltin && ( {!isBuiltin && (
<span className="text-dark-500" title="URL"> <span className="text-dark-500" title="URL">
<LinkIcon /> <LinkIcon className="h-3.5 w-3.5" />
</span> </span>
)} )}
<Toggle checked={button.enabled} onChange={() => onUpdate({ enabled: !button.enabled })} /> <Toggle checked={button.enabled} onChange={() => onUpdate({ enabled: !button.enabled })} />
@@ -236,7 +171,7 @@ function ButtonChip({
onClick={onRemove} onClick={onRemove}
className="rounded-lg p-1 text-dark-500 transition-colors hover:bg-error-500/10 hover:text-error-400" className="rounded-lg p-1 text-dark-500 transition-colors hover:bg-error-500/10 hover:text-error-400"
> >
<TrashIcon /> <TrashIcon className="h-4 w-4" />
</button> </button>
)} )}
</div> </div>
@@ -425,7 +360,7 @@ function SortableRow({
onClick={() => onRemoveRow(row.id)} onClick={() => onRemoveRow(row.id)}
className="rounded-lg p-1.5 text-dark-500 transition-colors hover:bg-error-500/10 hover:text-error-400" className="rounded-lg p-1.5 text-dark-500 transition-colors hover:bg-error-500/10 hover:text-error-400"
> >
<TrashIcon /> <TrashIcon className="h-4 w-4" />
</button> </button>
)} )}
</div> </div>
@@ -481,7 +416,7 @@ function InlineAddPanel({ rowId, usedBuiltinIds, onAddBuiltin, onAddCustom }: In
onClick={() => setIsOpen(true)} 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" 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"
> >
<PlusIcon /> <PlusIcon className="h-4 w-4" />
{t('admin.menuEditor.addButton')} {t('admin.menuEditor.addButton')}
</button> </button>
); );
@@ -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" 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"
> >
<LinkIcon /> <LinkIcon className="h-3.5 w-3.5" />
{t('admin.menuEditor.addUrlButton')} {t('admin.menuEditor.addUrlButton')}
</button> </button>
<button <button
@@ -843,7 +778,7 @@ export function MenuEditorTab() {
onClick={addRow} onClick={addRow}
className="flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-dark-700/50 py-4 text-sm font-medium text-dark-500 transition-colors hover:border-dark-600 hover:text-dark-400" className="flex w-full items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-dark-700/50 py-4 text-sm font-medium text-dark-500 transition-colors hover:border-dark-600 hover:text-dark-400"
> >
<PlusIcon /> <PlusIcon className="h-4 w-4" />
{t('admin.menuEditor.addRow')} {t('admin.menuEditor.addRow')}
</button> </button>

View File

@@ -1,8 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { PiCaretDown } from 'react-icons/pi';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { CheckIcon } from '@/components/icons';
import { GripIcon, TrashIcon } from '../icons/LandingIcons'; import { GripIcon, TrashIcon } from '../icons/LandingIcons';
import type { AdminLandingPaymentMethod, EditableMethodField } from '../../api/landings'; import type { AdminLandingPaymentMethod, EditableMethodField } from '../../api/landings';
import type { PaymentMethodSubOptionInfo } from '../../types'; import type { PaymentMethodSubOptionInfo } from '../../types';
@@ -10,15 +12,7 @@ import type { PaymentMethodSubOptionInfo } from '../../types';
export type MethodWithId = AdminLandingPaymentMethod & { _id: string }; export type MethodWithId = AdminLandingPaymentMethod & { _id: string };
const ChevronDownIcon = ({ open }: { open: boolean }) => ( const ChevronDownIcon = ({ open }: { open: boolean }) => (
<svg <PiCaretDown className={cn('h-5 w-5 transition-transform', open && 'rotate-180')} />
className={cn('h-5 w-5 transition-transform', open && 'rotate-180')}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
); );
interface SortableSelectedMethodProps { interface SortableSelectedMethodProps {
@@ -225,17 +219,7 @@ export function SortableSelectedMethodCard({
: 'border border-dark-600 bg-dark-700', : 'border border-dark-600 bg-dark-700',
)} )}
> >
{enabled && ( {enabled && <CheckIcon className="h-2.5 w-2.5" />}
<svg
className="h-2.5 w-2.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div> </div>
{opt.name} {opt.name}
</button> </button>

View File

@@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { CheckIcon, ChevronDownIcon, XCloseIcon, XIcon } from '@/components/icons';
import { useFocusTrap } from '@/hooks/useFocusTrap'; import { useFocusTrap } from '@/hooks/useFocusTrap';
import { DropdownSelect } from './DropdownSelect'; import { DropdownSelect } from './DropdownSelect';
import type { UserListItem } from '../../../api/adminUsers'; import type { UserListItem } from '../../../api/adminUsers';
@@ -48,24 +49,6 @@ export interface ModalState {
progress: ProgressState | null; progress: ProgressState | null;
} }
const CheckIcon = () => (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
const XCloseIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
function ProgressView({ progress }: { progress: ProgressState }) { function ProgressView({ progress }: { progress: ProgressState }) {
const { t } = useTranslation(); const { t } = useTranslation();
const logEndRef = useRef<HTMLDivElement>(null); const logEndRef = useRef<HTMLDivElement>(null);
@@ -115,31 +98,11 @@ function ProgressView({ progress }: { progress: ProgressState }) {
<div key={idx} className="flex items-start gap-2 text-xs"> <div key={idx} className="flex items-start gap-2 text-xs">
{entry.success ? ( {entry.success ? (
<span className="mt-0.5 shrink-0 text-success-400" aria-hidden="true"> <span className="mt-0.5 shrink-0 text-success-400" aria-hidden="true">
<svg <CheckIcon className="h-3 w-3" />
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
</span> </span>
) : ( ) : (
<span className="mt-0.5 shrink-0 text-error-400" aria-hidden="true"> <span className="mt-0.5 shrink-0 text-error-400" aria-hidden="true">
<svg <XIcon className="h-3 w-3" />
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</span> </span>
)} )}
<span className="font-mono text-dark-400"> <span className="font-mono text-dark-400">
@@ -176,18 +139,12 @@ function ErrorDetails({ result }: { result: BulkActionResult }) {
<span className="text-sm font-medium text-error-400"> <span className="text-sm font-medium text-error-400">
{t('admin.bulkActions.errors.title', { count: result.error_count })} {t('admin.bulkActions.errors.title', { count: result.error_count })}
</span> </span>
<svg <ChevronDownIcon
className={cn( className={cn(
'h-4 w-4 text-error-400 transition-transform duration-200', 'h-4 w-4 text-error-400 transition-transform duration-200',
expanded && 'rotate-180', expanded && 'rotate-180',
)} )}
fill="none" />
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button> </button>
{expanded && ( {expanded && (
<div className="max-h-48 overflow-y-auto border-t border-error-500/20 px-4 py-3"> <div className="max-h-48 overflow-y-auto border-t border-error-500/20 px-4 py-3">
@@ -195,15 +152,7 @@ function ErrorDetails({ result }: { result: BulkActionResult }) {
{result.errors.map((err, idx) => ( {result.errors.map((err, idx) => (
<div key={idx} className="flex items-start gap-2 text-xs"> <div key={idx} className="flex items-start gap-2 text-xs">
<span className="mt-0.5 shrink-0 text-error-400" aria-hidden="true"> <span className="mt-0.5 shrink-0 text-error-400" aria-hidden="true">
<svg <XIcon className="h-3 w-3" />
className="h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</span> </span>
<span className="shrink-0 font-mono text-dark-400"> <span className="shrink-0 font-mono text-dark-400">
{err.username ? `@${err.username}` : `#${err.user_id}`} {err.username ? `@${err.username}` : `#${err.user_id}`}
@@ -520,7 +469,7 @@ export function ActionModal({
)} )}
aria-pressed={forceDeleteActivePaid} aria-pressed={forceDeleteActivePaid}
> >
{forceDeleteActivePaid && <CheckIcon />} {forceDeleteActivePaid && <CheckIcon className="h-3 w-3" />}
</button> </button>
<span <span
className={cn( className={cn(
@@ -558,7 +507,7 @@ export function ActionModal({
)} )}
aria-pressed={deleteFromPanel} aria-pressed={deleteFromPanel}
> >
{deleteFromPanel && <CheckIcon />} {deleteFromPanel && <CheckIcon className="h-3 w-3" />}
</button> </button>
<span <span
className={cn( className={cn(

View File

@@ -1,3 +1,4 @@
import { ChevronDownIcon } from '@/components/icons';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// ────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────
@@ -7,11 +8,10 @@ import { cn } from '@/lib/utils';
// chrome (border, focus ring, chevron). // chrome (border, focus ring, chevron).
// ────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────
export const ChevronDownIcon = () => ( // Re-exported so the sibling FloatingActionBar / MultiSelectDropdown keep
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> // importing the chevron from this module while the glyph itself now comes
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" /> // from the central Phosphor barrel instead of a hand-written SVG.
</svg> export { ChevronDownIcon };
);
export interface DropdownOption { export interface DropdownOption {
value: string; value: string;
@@ -40,7 +40,7 @@ export function DropdownSelect({ value, options, onChange, className }: Dropdown
))} ))}
</select> </select>
<div className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-dark-500"> <div className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 text-dark-500">
<ChevronDownIcon /> <ChevronDownIcon className="h-4 w-4" />
</div> </div>
</div> </div>
); );

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { TrashIcon } from '@/components/icons';
import { ChevronDownIcon } from './DropdownSelect'; import { ChevronDownIcon } from './DropdownSelect';
import { isSubscriptionLevelAction } from './actionTargets'; import { isSubscriptionLevelAction } from './actionTargets';
import type { BulkActionType } from '../../../api/adminBulkActions'; import type { BulkActionType } from '../../../api/adminBulkActions';
@@ -112,21 +113,7 @@ export function FloatingActionBar({
{ {
type: 'delete_subscription', type: 'delete_subscription',
labelKey: 'admin.bulkActions.actions.deleteSubscription', labelKey: 'admin.bulkActions.actions.deleteSubscription',
icon: ( icon: <TrashIcon className="h-3.5 w-3.5" />,
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
),
colorClass: 'text-error-400 hover:bg-error-500/10', colorClass: 'text-error-400 hover:bg-error-500/10',
}, },
{ {

View File

@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { CheckIcon } from '@/components/icons';
import { ChevronDownIcon } from './DropdownSelect'; import { ChevronDownIcon } from './DropdownSelect';
// ────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────
@@ -135,21 +136,7 @@ export function MultiSelectDropdown({
: 'border-dark-500 bg-dark-700/60', : 'border-dark-500 bg-dark-700/60',
)} )}
> >
{isChecked && ( {isChecked && <CheckIcon className="h-2.5 w-2.5 text-white" />}
<svg
className="h-2.5 w-2.5 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={4}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</div> </div>
<span className={cn('text-sm', isChecked ? 'text-dark-100' : 'text-dark-300')}> <span className={cn('text-sm', isChecked ? 'text-dark-100' : 'text-dark-300')}>
{option.label} {option.label}

View File

@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { CheckIcon } from '@/components/icons';
import type { UserListItemSubscription } from '../../../api/adminUsers'; import type { UserListItemSubscription } from '../../../api/adminUsers';
// ────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────
@@ -68,17 +69,7 @@ export function SubscriptionSubRow({
: t('admin.bulkActions.selectUser', { name: subscription.tariff_name || '' }) : t('admin.bulkActions.selectUser', { name: subscription.tariff_name || '' })
} }
> >
{isSelected && ( {isSelected && <CheckIcon className="h-3 w-3 text-white" />}
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={4}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</button> </button>
</div> </div>
)} )}

View File

@@ -1,164 +1,35 @@
// ────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────
// TrafficIcons — the inline SVG set used across AdminTrafficUsage // TrafficIcons — the icon set used across AdminTrafficUsage (header
// (header controls, filter buttons, sort indicator, etc.). Page-local // controls, filter buttons, sort indicator, etc.). The glyphs now come
// in spirit; co-located here so the parent page imports a single // from the central Phosphor barrel (`@/components/icons`); only the
// flat barrel rather than redefining 15 inline icons. // 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';
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
);
export const ChevronLeftIcon = () => ( export {
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> CalendarIcon,
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" /> ChevronDownIcon,
</svg> ChevronLeftIcon,
); ChevronRightIcon,
DownloadIcon,
FilterIcon,
GlobeIcon,
RefreshIcon,
SearchIcon,
ServerIcon,
ServerSmallIcon,
ShieldIcon,
StatusIcon,
XIcon,
} from '@/components/icons';
export const ChevronRightIcon = () => ( export const SortIcon = ({ direction }: { direction: false | 'asc' | 'desc' }) =>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> direction === 'asc' ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> <PiCaretUp className="ml-1 inline h-3 w-3" />
</svg> ) : direction === 'desc' ? (
); <PiCaretDown className="ml-1 inline h-3 w-3" />
) : (
export const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => ( <PiCaretUpDown className="ml-1 inline h-3 w-3" />
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> );
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
export const DownloadIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
);
export const SortIcon = ({ direction }: { direction: false | 'asc' | 'desc' }) => (
<svg
className="ml-1 inline h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
{direction === 'asc' ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
) : direction === 'desc' ? (
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9"
/>
)}
</svg>
);
export const FilterIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
/>
</svg>
);
export const ChevronDownIcon = () => (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
export const ServerIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 17.25v-.228a4.5 4.5 0 00-.12-1.03l-2.268-9.64a3.375 3.375 0 00-3.285-2.602H7.923a3.375 3.375 0 00-3.285 2.602l-2.268 9.64a4.5 4.5 0 00-.12 1.03v.228m19.5 0a3 3 0 01-3 3H5.25a3 3 0 01-3-3m19.5 0a3 3 0 00-3-3H5.25a3 3 0 00-3 3m16.5 0h.008v.008h-.008v-.008zm-3 0h.008v.008h-.008v-.008z"
/>
</svg>
);
export const CalendarIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
/>
</svg>
);
export const XIcon = () => (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
export const StatusIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
);
export const GlobeIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
/>
</svg>
);
export const ShieldIcon = () => (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
);
export const ServerSmallIcon = () => (
<svg
className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
/>
</svg>
);

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { CheckIcon } from '@/components/icons';
import { ChevronDownIcon, GlobeIcon } from '../TrafficIcons'; import { ChevronDownIcon, GlobeIcon } from '../TrafficIcons';
import { getFlagEmoji } from '../trafficUsageHelpers'; 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' : 'border-dark-700 bg-dark-800 text-dark-200 hover:border-dark-600 hover:bg-dark-700'
}`} }`}
> >
<GlobeIcon /> <GlobeIcon className="h-4 w-4" />
{activeCount > 0 && ( {activeCount > 0 && (
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white"> <span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white">
{activeCount} {activeCount}
</span> </span>
)} )}
<ChevronDownIcon /> <ChevronDownIcon className="h-3 w-3" />
</button> </button>
{open && ( {open && (
@@ -71,17 +72,7 @@ export function CountryFilter({
allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600' allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
}`} }`}
> >
{allSelected && ( {allSelected && <CheckIcon className="h-3 w-3 text-white" />}
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</span> </span>
All All
</button> </button>
@@ -102,21 +93,7 @@ export function CountryFilter({
checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600' checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
}`} }`}
> >
{checked && ( {checked && <CheckIcon className="h-3 w-3 text-white" />}
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</span> </span>
{getFlagEmoji(code)} {code.toUpperCase()} {getFlagEmoji(code)} {code.toUpperCase()}
<span className="ml-auto text-dark-500">{count}</span> <span className="ml-auto text-dark-500">{count}</span>

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ChevronDownIcon, ServerIcon } from '../TrafficIcons'; import { ChevronDownIcon, ServerIcon } from '../TrafficIcons';
import { CheckIcon } from '@/components/icons';
import { getFlagEmoji } from '../trafficUsageHelpers'; import { getFlagEmoji } from '../trafficUsageHelpers';
import type { TrafficNodeInfo } from '../../../../api/adminTraffic'; 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' : 'border-dark-700 bg-dark-800 text-dark-200 hover:border-dark-600 hover:bg-dark-700'
}`} }`}
> >
<ServerIcon /> <ServerIcon className="h-4 w-4" />
{t('admin.trafficUsage.nodes')} {t('admin.trafficUsage.nodes')}
{activeCount > 0 && ( {activeCount > 0 && (
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white"> <span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white">
{activeCount} {activeCount}
</span> </span>
)} )}
<ChevronDownIcon /> <ChevronDownIcon className="h-3 w-3" />
</button> </button>
{open && ( {open && (
@@ -75,17 +76,7 @@ export function NodeFilter({
allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600' allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
}`} }`}
> >
{allSelected && ( {allSelected && <CheckIcon className="h-3 w-3 text-white" />}
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</span> </span>
{t('admin.trafficUsage.allNodes')} {t('admin.trafficUsage.allNodes')}
</button> </button>
@@ -106,21 +97,7 @@ export function NodeFilter({
checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600' checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
}`} }`}
> >
{checked && ( {checked && <CheckIcon className="h-3 w-3 text-white" />}
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</span> </span>
{getFlagEmoji(node.country_code)} {node.node_name} {getFlagEmoji(node.country_code)} {node.node_name}
</button> </button>

View File

@@ -42,7 +42,7 @@ export function PeriodSelector({
if (dateMode) { if (dateMode) {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CalendarIcon /> <CalendarIcon className="h-4 w-4" />
<span className="text-xs text-dark-400">{t('admin.trafficUsage.dateFrom')}</span> <span className="text-xs text-dark-400">{t('admin.trafficUsage.dateFrom')}</span>
<input <input
type="date" type="date"
@@ -66,7 +66,7 @@ export function PeriodSelector({
className="rounded-lg p-1 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200" className="rounded-lg p-1 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
title={t('admin.trafficUsage.period')} title={t('admin.trafficUsage.period')}
> >
<XIcon /> <XIcon className="h-3 w-3" />
</button> </button>
</div> </div>
); );
@@ -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" 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')} title={t('admin.trafficUsage.customDates')}
> >
<CalendarIcon /> <CalendarIcon className="h-4 w-4" />
</button> </button>
</div> </div>
); );

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CheckIcon } from '@/components/icons';
import { ChevronDownIcon, StatusIcon } from '../TrafficIcons'; import { ChevronDownIcon, StatusIcon } from '../TrafficIcons';
// Status colour pills shared with the StatusFilter dropdown. // 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' : 'border-dark-700 bg-dark-800 text-dark-200 hover:border-dark-600 hover:bg-dark-700'
}`} }`}
> >
<StatusIcon /> <StatusIcon className="h-4 w-4" />
{t('admin.trafficUsage.status')} {t('admin.trafficUsage.status')}
{activeCount > 0 && ( {activeCount > 0 && (
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white"> <span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white">
{activeCount} {activeCount}
</span> </span>
)} )}
<ChevronDownIcon /> <ChevronDownIcon className="h-3 w-3" />
</button> </button>
{open && ( {open && (
@@ -86,17 +87,7 @@ export function StatusFilter({
allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600' allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
}`} }`}
> >
{allSelected && ( {allSelected && <CheckIcon className="h-3 w-3 text-white" />}
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</span> </span>
{t('admin.trafficUsage.allStatuses')} {t('admin.trafficUsage.allStatuses')}
</button> </button>
@@ -117,21 +108,7 @@ export function StatusFilter({
checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600' checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
}`} }`}
> >
{checked && ( {checked && <CheckIcon className="h-3 w-3 text-white" />}
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</span> </span>
<span className={`h-2 w-2 rounded-full ${STATUS_COLORS[s] || 'bg-dark-500'}`} /> <span className={`h-2 w-2 rounded-full ${STATUS_COLORS[s] || 'bg-dark-500'}`} />
{statusLabel(s)} {statusLabel(s)}

View File

@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ChevronDownIcon, FilterIcon } from '../TrafficIcons'; import { ChevronDownIcon, FilterIcon } from '../TrafficIcons';
import { CheckIcon } from '@/components/icons';
export function TariffFilter({ export function TariffFilter({
available, 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' : 'border-dark-700 bg-dark-800 text-dark-200 hover:border-dark-600 hover:bg-dark-700'
}`} }`}
> >
<FilterIcon /> <FilterIcon className="h-4 w-4" />
{t('admin.trafficUsage.tariff')} {t('admin.trafficUsage.tariff')}
{activeCount > 0 && ( {activeCount > 0 && (
<span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white"> <span className="rounded-full bg-accent-500 px-1.5 text-[10px] text-white">
{activeCount} {activeCount}
</span> </span>
)} )}
<ChevronDownIcon /> <ChevronDownIcon className="h-3 w-3" />
</button> </button>
{open && ( {open && (
@@ -73,17 +74,7 @@ export function TariffFilter({
allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600' allSelected ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
}`} }`}
> >
{allSelected && ( {allSelected && <CheckIcon className="h-3 w-3 text-white" />}
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
)}
</span> </span>
{t('admin.trafficUsage.allTariffs')} {t('admin.trafficUsage.allTariffs')}
</button> </button>
@@ -104,21 +95,7 @@ export function TariffFilter({
checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600' checked ? 'border-accent-500 bg-accent-500' : 'border-dark-600'
}`} }`}
> >
{checked && ( {checked && <CheckIcon className="h-3 w-3 text-white" />}
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.5 12.75l6 6 9-13.5"
/>
</svg>
)}
</span> </span>
{tariff} {tariff}
</button> </button>

View File

@@ -6,22 +6,7 @@ import { adminUsersApi, type UserDetailResponse } from '../../../api/adminUsers'
import { promocodesApi } from '../../../api/promocodes'; import { promocodesApi } from '../../../api/promocodes';
import { promoOffersApi } from '../../../api/promoOffers'; import { promoOffersApi } from '../../../api/promoOffers';
import { createNumberInputHandler, toNumber } from '../../../utils/inputHelpers'; import { createNumberInputHandler, toNumber } from '../../../utils/inputHelpers';
import { PlusIcon, MinusIcon } from '@/components/icons';
// ──────────────────────────────────────────────────────────────────
// Icons — local; balance is the only consumer.
// ──────────────────────────────────────────────────────────────────
const PlusIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
const MinusIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12h-15" />
</svg>
);
// ────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────
// Balance tab — current balance, add/subtract form, active promo // Balance tab — current balance, add/subtract form, active promo
@@ -168,14 +153,14 @@ export function BalanceTab({
disabled={actionLoading || balanceAmount === ''} 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" 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"
> >
<PlusIcon /> {t('admin.users.detail.balance.add')} <PlusIcon className="h-4 w-4" /> {t('admin.users.detail.balance.add')}
</button> </button>
<button <button
onClick={() => handleUpdateBalance(false)} onClick={() => handleUpdateBalance(false)}
disabled={actionLoading || balanceAmount === ''} disabled={actionLoading || balanceAmount === ''}
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-error-500 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50" className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-error-500 py-2 text-white transition-colors hover:bg-error-600 disabled:opacity-50"
> >
<MinusIcon /> {t('admin.users.detail.balance.subtract')} <MinusIcon className="h-4 w-4" /> {t('admin.users.detail.balance.subtract')}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { SendIcon } from '@/components/icons';
import { useCurrency } from '../../../hooks/useCurrency'; import { useCurrency } from '../../../hooks/useCurrency';
import type { AdminUserGiftItem, AdminUserGiftsResponse } from '../../../api/adminUsers'; import type { AdminUserGiftItem, AdminUserGiftsResponse } from '../../../api/adminUsers';
@@ -224,19 +225,7 @@ export function GiftsTab({ giftsLoading, giftsData, locale, onNavigateToUser }:
{/* Sent Gifts */} {/* Sent Gifts */}
<div> <div>
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-dark-200"> <h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-dark-200">
<svg <SendIcon className="h-4 w-4 text-accent-400" />
className="h-4 w-4 text-accent-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
/>
</svg>
{t('admin.users.detail.gifts.sentTitle')} {t('admin.users.detail.gifts.sentTitle')}
<span className="text-dark-500">({giftsData.sent_total})</span> <span className="text-dark-500">({giftsData.sent_total})</span>
</h3> </h3>

View File

@@ -9,6 +9,7 @@ import type {
UserSubscriptionInfo, UserSubscriptionInfo,
} from '../../../api/adminUsers'; } from '../../../api/adminUsers';
import type { PromoGroup } from '../../../api/promocodes'; import type { PromoGroup } from '../../../api/promocodes';
import { ServerIcon } from '@/components/icons';
// ────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────
// Local status badge (parent has its own — duplicating here to keep // 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')} {t('admin.users.detail.lastNode')}
</div> </div>
<div className="flex items-center gap-2 text-sm text-dark-100"> <div className="flex items-center gap-2 text-sm text-dark-100">
<svg <ServerIcon className="h-4 w-4 shrink-0 text-dark-400" />
className="h-4 w-4 shrink-0 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
/>
</svg>
{panelInfo.last_connected_node_name} {panelInfo.last_connected_node_name}
</div> </div>
</div> </div>

View File

@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router';
import { useCurrency } from '../../../hooks/useCurrency'; import { useCurrency } from '../../../hooks/useCurrency';
import { useNotify } from '../../../platform/hooks/useNotify'; import { useNotify } from '../../../platform/hooks/useNotify';
import { adminUsersApi, type UserDetailResponse, type UserListItem } from '../../../api/adminUsers'; import { adminUsersApi, type UserDetailResponse, type UserListItem } from '../../../api/adminUsers';
import { XIcon } from '@/components/icons';
// ────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────
// Referrals tab — top-of-graph referrer + stats + referrals list, // 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" 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')} title={t('admin.users.detail.referrals.removeReferral')}
> >
<svg <XIcon className="h-4 w-4" />
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
</div> </div>
))} ))}

View File

@@ -1,4 +1,15 @@
import { useTranslation } from 'react-i18next'; 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 { DEVICE_ALIAS_MAX_LENGTH } from '../../../constants/devices';
import { createNumberInputHandler } from '../../../utils/inputHelpers'; import { createNumberInputHandler } from '../../../utils/inputHelpers';
import { getFlagEmoji } from '../../../utils/subscriptionHelpers'; import { getFlagEmoji } from '../../../utils/subscriptionHelpers';
@@ -37,28 +48,6 @@ function StatusBadge({ status }: { status: string }) {
); );
} }
const PlusIcon = () => (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
const MinusIcon = () => (
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 12h-15" />
</svg>
);
const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
// Local device row type (matches the parent's inline type) // Local device row type (matches the parent's inline type)
type DeviceRow = { type DeviceRow = {
hwid: string; hwid: string;
@@ -238,19 +227,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
</span> </span>
<StatusBadge status={sub.status} /> <StatusBadge status={sub.status} />
</div> </div>
<svg <ChevronRightIcon className="h-4 w-4 text-dark-500" />
className="h-4 w-4 text-dark-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</div> </div>
<div className="mt-2 flex items-center gap-4 text-xs text-dark-400"> <div className="mt-2 flex items-center gap-4 text-xs text-dark-400">
<span> <span>
@@ -330,15 +307,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
onClick={() => onSubscriptionDetailViewChange(false)} onClick={() => onSubscriptionDetailViewChange(false)}
className="flex items-center gap-1.5 text-sm text-dark-400 transition-colors hover:text-dark-200" className="flex items-center gap-1.5 text-sm text-dark-400 transition-colors hover:text-dark-200"
> >
<svg <BackIcon className="h-4 w-4" />
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
{t('admin.users.detail.subscription.backToList', 'Все подписки')} {t('admin.users.detail.subscription.backToList', 'Все подписки')}
</button> </button>
)} )}
@@ -390,7 +359,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
disabled={actionLoading || selectedSub.device_limit <= 1} 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" 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"
> >
<MinusIcon /> <MinusIcon className="h-3 w-3" />
</button> </button>
<span className="min-w-[2ch] text-center text-dark-100"> <span className="min-w-[2ch] text-center text-dark-100">
{selectedSub.device_limit} {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" 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"
> >
<PlusIcon /> <PlusIcon className="h-3 w-3" />
</button> </button>
</div> </div>
</div> </div>
@@ -964,19 +933,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
)} )}
aria-label={t('admin.users.detail.devices.renameSave', 'Сохранить')} aria-label={t('admin.users.detail.devices.renameSave', 'Сохранить')}
> >
<svg <CheckIcon className="h-3.5 w-3.5" />
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M5 13l4 4L19 7" />
</svg>
</button> </button>
<button <button
type="button" type="button"
@@ -989,19 +946,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
title={t('common.cancel', 'Отмена')} title={t('common.cancel', 'Отмена')}
aria-label={t('common.cancel', 'Отмена')} aria-label={t('common.cancel', 'Отмена')}
> >
<svg <XIcon className="h-3.5 w-3.5" />
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
</> </>
) : ( ) : (
@@ -1016,19 +961,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
title={t('admin.users.detail.devices.rename', 'Переименовать')} title={t('admin.users.detail.devices.rename', 'Переименовать')}
aria-label={t('admin.users.detail.devices.rename', 'Переименовать')} aria-label={t('admin.users.detail.devices.rename', 'Переименовать')}
> >
<svg <EditIcon className="h-3.5 w-3.5" />
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125" />
</svg>
</button> </button>
<button <button
onClick={() => onClick={() =>
@@ -1081,19 +1014,9 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
</span> </span>
)} )}
</div> </div>
<svg <ChevronDownIcon
className={`h-4 w-4 text-dark-500 transition-transform ${requestHistoryExpanded ? 'rotate-180' : ''}`} className={`h-4 w-4 text-dark-500 transition-transform ${requestHistoryExpanded ? 'rotate-180' : ''}`}
fill="none" />
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 8.25l-7.5 7.5-7.5-7.5"
/>
</svg>
</button> </button>
{requestHistoryExpanded && ( {requestHistoryExpanded && (

View File

@@ -1,26 +1,11 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ArrowDownIcon, ArrowUpIcon } from '@/components/icons';
import type { import type {
UserDetailResponse, UserDetailResponse,
UserSubscriptionInfo, UserSubscriptionInfo,
PanelSyncStatusResponse, PanelSyncStatusResponse,
} from '../../../api/adminUsers'; } from '../../../api/adminUsers';
// ──────────────────────────────────────────────────────────────────
// Icons (sync-tab-local — not used outside this view)
// ──────────────────────────────────────────────────────────────────
const ArrowDownIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 13.5L12 21m0 0l-7.5-7.5M12 21V3" />
</svg>
);
const ArrowUpIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 10.5L12 3m0 0l7.5 7.5M12 3v18" />
</svg>
);
// ────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────
// Sync tab — compares bot DB vs panel data, offers a 2-way push // Sync tab — compares bot DB vs panel data, offers a 2-way push
// ────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────

View File

@@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query';
import { adminApi, type AdminTicket, type AdminTicketDetail } from '../../../api/admin'; import { adminApi, type AdminTicket, type AdminTicketDetail } from '../../../api/admin';
import { MessageMediaGrid } from '../../tickets/MessageMediaGrid'; import { MessageMediaGrid } from '../../tickets/MessageMediaGrid';
import { linkifyText } from '../../../utils/linkify'; import { linkifyText } from '../../../utils/linkify';
import { ChatIcon, BackIcon, SendIcon } from '@/components/icons';
// ────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────
// Tickets tab — list view + chat view (selected ticket replaces list). // Tickets tab — list view + chat view (selected ticket replaces list).
@@ -155,19 +156,7 @@ function EmptyState() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="flex flex-col items-center justify-center rounded-xl bg-dark-800/50 py-12"> <div className="flex flex-col items-center justify-center rounded-xl bg-dark-800/50 py-12">
<svg <ChatIcon className="mb-3 h-12 w-12 text-dark-600" />
className="mb-3 h-12 w-12 text-dark-600"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 01-.825-.242m9.345-8.334a2.126 2.126 0 00-.476-.095 48.64 48.64 0 00-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0011.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
/>
</svg>
<p className="text-dark-400">{t('admin.users.detail.noTickets')}</p> <p className="text-dark-400">{t('admin.users.detail.noTickets')}</p>
</div> </div>
); );
@@ -265,15 +254,7 @@ function ChatView({
aria-label={t('common.back', 'Back')} 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" 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"
> >
<svg <BackIcon className="h-4 w-4 text-dark-400" />
className="h-4 w-4 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button> </button>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate font-medium text-dark-100"> <div className="truncate font-medium text-dark-100">
@@ -372,19 +353,7 @@ function ChatView({
{replySending ? ( {replySending ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : ( ) : (
<svg <SendIcon className="h-5 w-5" />
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
/>
</svg>
)} )}
</button> </button>
</div> </div>

View File

@@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { usePlatform } from '@/platform'; import { usePlatform } from '@/platform';
import { InfoIcon } from '@/components/icons';
import { useBlockingStore } from '../../store/blocking'; import { useBlockingStore } from '../../store/blocking';
import { useFocusTrap } from '../../hooks/useFocusTrap'; import { useFocusTrap } from '../../hooks/useFocusTrap';
@@ -55,20 +56,7 @@ export default function AccountDeletedScreen() {
<div className="w-full max-w-md text-center"> <div className="w-full max-w-md text-center">
<div className="mb-8"> <div className="mb-8">
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800"> <div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
<svg <InfoIcon className="h-12 w-12 text-warning-400" />
className="h-12 w-12 text-warning-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking'; import { useBlockingStore } from '../../store/blocking';
import { useFocusTrap } from '../../hooks/useFocusTrap'; import { useFocusTrap } from '../../hooks/useFocusTrap';
import { BanIcon } from '@/components/icons';
export default function BlacklistedScreen() { export default function BlacklistedScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -20,19 +21,7 @@ export default function BlacklistedScreen() {
{/* Icon */} {/* Icon */}
<div className="mb-8"> <div className="mb-8">
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800"> <div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
<svg <BanIcon className="h-12 w-12 text-error-500" />
className="h-12 w-12 text-error-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
</div> </div>
</div> </div>

View File

@@ -4,6 +4,7 @@ import { useBlockingStore } from '../../store/blocking';
import { apiClient, isChannelSubscriptionError } from '../../api/client'; import { apiClient, isChannelSubscriptionError } from '../../api/client';
import { usePlatform } from '../../platform'; import { usePlatform } from '../../platform';
import { useFocusTrap } from '../../hooks/useFocusTrap'; import { useFocusTrap } from '../../hooks/useFocusTrap';
import { TelegramIcon, ClockIcon, CheckIcon } from '@/components/icons';
const CHECK_COOLDOWN_SECONDS = 5; const CHECK_COOLDOWN_SECONDS = 5;
@@ -96,9 +97,7 @@ export default function ChannelSubscriptionScreen() {
{/* Icon */} {/* Icon */}
<div className="mb-8"> <div className="mb-8">
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br from-blue-500/20 to-cyan-500/20"> <div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br from-blue-500/20 to-cyan-500/20">
<svg className="h-12 w-12 text-blue-400" fill="currentColor" viewBox="0 0 24 24"> <TelegramIcon className="h-12 w-12 text-blue-400" />
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
</div> </div>
</div> </div>
@@ -183,31 +182,12 @@ export default function ChannelSubscriptionScreen() {
</> </>
) : cooldown > 0 ? ( ) : cooldown > 0 ? (
<> <>
<svg <ClockIcon className="h-5 w-5 text-dark-500" />
className="h-5 w-5 text-dark-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
{t('blocking.channel.waitSeconds', { seconds: cooldown })} {t('blocking.channel.waitSeconds', { seconds: cooldown })}
</> </>
) : ( ) : (
<> <>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <CheckIcon className="h-5 w-5" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
{t('blocking.channel.checkSubscription')} {t('blocking.channel.checkSubscription')}
</> </>
)} )}

View File

@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking'; import { useBlockingStore } from '../../store/blocking';
import { useFocusTrap } from '../../hooks/useFocusTrap'; import { useFocusTrap } from '../../hooks/useFocusTrap';
import { WrenchIcon } from '@/components/icons';
export default function MaintenanceScreen() { export default function MaintenanceScreen() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -20,19 +21,7 @@ export default function MaintenanceScreen() {
{/* Icon */} {/* Icon */}
<div className="mb-8"> <div className="mb-8">
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800"> <div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
<svg <WrenchIcon className="h-12 w-12 text-warning-500" />
className="h-12 w-12 text-warning-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z"
/>
</svg>
</div> </div>
</div> </div>

View File

@@ -12,6 +12,7 @@ import { useTheme } from '@/hooks/useTheme';
import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock, BlockButtons } from './blocks'; import { CardsBlock, TimelineBlock, AccordionBlock, MinimalBlock, BlockButtons } from './blocks';
import type { BlockRendererProps } from './blocks'; import type { BlockRendererProps } from './blocks';
import TvQuickConnect from './TvQuickConnect'; import TvQuickConnect from './TvQuickConnect';
import { BackIcon, BookOpenIcon, ChevronIcon } from '@/components/icons';
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']; const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'];
@@ -33,12 +34,6 @@ const RENDERERS: Record<string, React.ComponentType<BlockRendererProps>> = {
minimal: MinimalBlock, minimal: MinimalBlock,
}; };
const BackIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
);
interface Props { interface Props {
appConfig: AppConfig; appConfig: AppConfig;
onOpenDeepLink: (url: string) => void; onOpenDeepLink: (url: string) => void;
@@ -200,7 +195,7 @@ export default function InstallationGuide({
aria-label={t('common.back', 'Back')} 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" 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"
> >
<BackIcon /> <BackIcon className="h-6 w-6" />
</button> </button>
)} )}
<h2 className="flex-1 text-lg font-bold text-dark-100"> <h2 className="flex-1 text-lg font-bold text-dark-100">
@@ -264,15 +259,7 @@ export default function InstallationGuide({
))} ))}
</select> </select>
<div className="pointer-events-none absolute right-2.5 text-dark-400"> <div className="pointer-events-none absolute right-2.5 text-dark-400">
<svg <ChevronIcon className="h-4 w-4" />
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 9l4-4 4 4M8 15l4 4 4-4" />
</svg>
</div> </div>
</div> </div>
)} )}
@@ -320,19 +307,7 @@ export default function InstallationGuide({
rel="noopener noreferrer" rel="noopener noreferrer"
className="btn-secondary w-full justify-center" className="btn-secondary w-full justify-center"
> >
<svg <BookOpenIcon className="h-5 w-5" />
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6.042A8.967 8.967 0 006 3.75c-1.052 0-2.062.18-3 .512v14.25A8.987 8.987 0 016 18c2.305 0 4.408.867 6 2.292m0-14.25a8.966 8.966 0 016-2.292c1.052 0 2.062.18 3 .512v14.25A8.987 8.987 0 0018 18a8.967 8.967 0 00-6 2.292m0-14.25v14.25"
/>
</svg>
{getBaseTranslation('tutorial', 'subscription.connection.tutorial')} {getBaseTranslation('tutorial', 'subscription.connection.tutorial')}
</a> </a>
)} )}

View File

@@ -1,4 +1,5 @@
import { useState } from 'react'; import { useState } from 'react';
import { ChevronDownIcon } from '@/components/icons';
import { getColorGradient } from '@/utils/colorParser'; import { getColorGradient } from '@/utils/colorParser';
import { ThemeIcon } from './ThemeIcon'; import { ThemeIcon } from './ThemeIcon';
import type { BlockRendererProps } from './types'; import type { BlockRendererProps } from './types';
@@ -52,15 +53,9 @@ export function AccordionBlock({
<span className="min-w-0 flex-1 truncate font-semibold text-dark-100"> <span className="min-w-0 flex-1 truncate font-semibold text-dark-100">
{getLocalizedText(block.title)} {getLocalizedText(block.title)}
</span> </span>
<svg <ChevronDownIcon
className={`h-[18px] w-[18px] shrink-0 text-dark-400 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} className={`h-[18px] w-[18px] shrink-0 text-dark-400 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
fill="none" />
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button> </button>
{/* Panel */} {/* Panel */}
<div <div

View File

@@ -1,5 +1,6 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { CheckIcon, CopyIcon } from '@/components/icons';
import type { RemnawaveButtonClient, LocalizedText } from '@/types'; import type { RemnawaveButtonClient, LocalizedText } from '@/types';
import { copyToClipboard } from '@/utils/clipboard'; import { copyToClipboard } from '@/utils/clipboard';
@@ -20,22 +21,6 @@ function isValidExternalUrl(url: string | undefined): boolean {
return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://'); return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://');
} }
const CopyIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
/>
</svg>
);
const CheckIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
);
interface BlockButtonsProps { interface BlockButtonsProps {
buttons: RemnawaveButtonClient[] | undefined; buttons: RemnawaveButtonClient[] | undefined;
variant: 'light' | 'subtle'; variant: 'light' | 'subtle';

View File

@@ -1,6 +1,7 @@
import { Link } from 'react-router'; import { Link } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { GiftIcon } from '@/components/icons';
import type { PendingGift } from '../../api/gift'; import type { PendingGift } from '../../api/gift';
interface PendingGiftCardProps { interface PendingGiftCardProps {
@@ -28,19 +29,7 @@ export default function PendingGiftCard({ gifts, className }: PendingGiftCardPro
<div className="relative flex items-start gap-4"> <div className="relative flex items-start gap-4">
{/* Gift icon */} {/* Gift icon */}
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-accent-500/20"> <div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-accent-500/20">
<svg <GiftIcon className="h-6 w-6 text-accent-400" />
className="h-6 w-6 text-accent-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
</div> </div>
{/* Content */} {/* Content */}

View File

@@ -1,3 +1,4 @@
import { PiCaretRight } from 'react-icons/pi';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { useCurrency } from '../../hooks/useCurrency'; import { useCurrency } from '../../hooks/useCurrency';
@@ -12,22 +13,7 @@ interface StatsGridProps {
} }
const ChevronIcon = ({ color }: { color: string }) => ( const ChevronIcon = ({ color }: { color: string }) => (
<svg <PiCaretRight width={16} height={16} style={{ flexShrink: 0, color }} aria-hidden="true" />
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
style={{ flexShrink: 0 }}
aria-hidden="true"
>
<path
d="M6 4l4 4-4 4"
stroke={color}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
); );
export default function StatsGrid({ export default function StatsGrid({

View File

@@ -10,6 +10,7 @@ import { useTrafficZone } from '../../hooks/useTrafficZone';
import { formatTraffic } from '../../utils/formatTraffic'; import { formatTraffic } from '../../utils/formatTraffic';
import { getGlassColors } from '../../utils/glassTheme'; import { getGlassColors } from '../../utils/glassTheme';
import { HoverBorderGradient } from '../ui/hover-border-gradient'; import { HoverBorderGradient } from '../ui/hover-border-gradient';
import { CalendarIcon, RefreshIcon } from '@/components/icons';
import { useHaptic } from '../../platform'; import { useHaptic } from '../../platform';
import type { Subscription } from '../../types'; import type { Subscription } from '../../types';
@@ -25,23 +26,6 @@ interface SubscriptionCardActiveProps {
connectedDevices: number; connectedDevices: number;
} }
const RefreshIcon = ({ className = 'w-4 h-4' }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
export default function SubscriptionCardActive({ export default function SubscriptionCardActive({
subscription, subscription,
trafficData, trafficData,
@@ -325,20 +309,14 @@ export default function SubscriptionCardActive({
background: daysLeft <= 3 ? 'rgba(var(--color-warning-400), 0.1)' : g.hoverBg, background: daysLeft <= 3 ? 'rgba(var(--color-warning-400), 0.1)' : g.hoverBg,
}} }}
> >
<svg <span
width="13" style={{
height="13" color: daysLeft <= 3 ? 'rgb(var(--color-warning-400))' : g.textSecondary,
viewBox="0 0 24 24" }}
fill="none"
stroke={daysLeft <= 3 ? 'rgb(var(--color-warning-400))' : g.textSecondary}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true" aria-hidden="true"
> >
<rect x="3" y="4" width="18" height="18" rx="2" /> <CalendarIcon className="h-[13px] w-[13px]" />
<path d="M16 2v4M8 2v4M3 10h18" /> </span>
</svg>
</div> </div>
{t('dashboard.remaining')} {t('dashboard.remaining')}
</div> </div>

View File

@@ -10,6 +10,7 @@ import { useCurrency } from '../../hooks/useCurrency';
import { useHapticFeedback } from '../../platform/hooks/useHaptic'; import { useHapticFeedback } from '../../platform/hooks/useHaptic';
import { getGlassColors } from '../../utils/glassTheme'; import { getGlassColors } from '../../utils/glassTheme';
import { getInsufficientBalanceError } from '../../utils/subscriptionHelpers'; import { getInsufficientBalanceError } from '../../utils/subscriptionHelpers';
import { ClockIcon, ExclamationIcon, PlusIcon, SubscriptionIcon } from '@/components/icons';
interface SubscriptionCardExpiredProps { interface SubscriptionCardExpiredProps {
subscription: Subscription; subscription: Subscription;
@@ -167,39 +168,13 @@ export default function SubscriptionCardExpired({
style={{ style={{
background: `rgba(${accent.r},${accent.g},${accent.b},0.1)`, background: `rgba(${accent.r},${accent.g},${accent.b},0.1)`,
border: `1px solid rgba(${accent.r},${accent.g},${accent.b},0.15)`, border: `1px solid rgba(${accent.r},${accent.g},${accent.b},0.15)`,
color: accent.hex,
}} }}
> >
{isLimited ? ( {isLimited ? (
<svg <ExclamationIcon className="h-[22px] w-[22px]" />
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke={accent.hex}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
) : ( ) : (
<svg <ClockIcon className="h-[22px] w-[22px]" />
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
stroke={accent.hex}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 6v6l4 2" />
</svg>
)} )}
</div> </div>
<h2 className="text-lg font-bold tracking-tight text-dark-50"> <h2 className="text-lg font-bold tracking-tight text-dark-50">
@@ -274,19 +249,7 @@ export default function SubscriptionCardExpired({
boxShadow: `0 4px 20px rgba(${accent.r},${accent.g},${accent.b},0.2)`, boxShadow: `0 4px 20px rgba(${accent.r},${accent.g},${accent.b},0.2)`,
}} }}
> >
<svg <PlusIcon className="h-4 w-4" />
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{t('subscription.buyTraffic')} {t('subscription.buyTraffic')}
</Link> </Link>
) : ( ) : (
@@ -311,19 +274,7 @@ export default function SubscriptionCardExpired({
aria-hidden="true" aria-hidden="true"
/> />
) : ( ) : (
<svg <SubscriptionIcon className="h-4 w-4" />
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z" />
</svg>
)} )}
{isRenewing {isRenewing
? t('common.loading') ? t('common.loading')
@@ -341,19 +292,7 @@ export default function SubscriptionCardExpired({
boxShadow: `0 4px 20px rgba(${accent.r},${accent.g},${accent.b},0.2)`, boxShadow: `0 4px 20px rgba(${accent.r},${accent.g},${accent.b},0.2)`,
}} }}
> >
<svg <PlusIcon className="h-4 w-4" />
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 4.5v15m7.5-7.5h-15" />
</svg>
{t('dashboard.expired.topUp')} {t('dashboard.expired.topUp')}
</button> </button>
)} )}

View File

@@ -5,6 +5,7 @@ import type { TrialInfo } from '../../types';
import { useCurrency } from '../../hooks/useCurrency'; import { useCurrency } from '../../hooks/useCurrency';
import { useTheme } from '../../hooks/useTheme'; import { useTheme } from '../../hooks/useTheme';
import { getGlassColors } from '../../utils/glassTheme'; import { getGlassColors } from '../../utils/glassTheme';
import { BoltIcon, SparklesIcon } from '@/components/icons';
interface TrialOfferCardProps { interface TrialOfferCardProps {
trialInfo: TrialInfo; trialInfo: TrialInfo;
@@ -94,37 +95,21 @@ export default function TrialOfferCard({
}} }}
> >
{isFree ? ( {isFree ? (
<svg <span
width="26" className="flex"
height="26" style={{ color: 'rgb(var(--color-accent-400))' }}
viewBox="0 0 24 24"
fill="none"
stroke="rgb(var(--color-accent-400))"
strokeWidth="1.5"
aria-hidden="true" aria-hidden="true"
> >
<path <SparklesIcon className="h-[26px] w-[26px]" />
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.455 2.456L21.75 6l-1.036.259a3.375 3.375 0 00-2.455 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" </span>
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : ( ) : (
<svg <span
width="26" className="flex"
height="26" style={{ color: 'rgb(var(--color-urgent-400))' }}
viewBox="0 0 24 24"
fill="none"
stroke="rgb(var(--color-urgent-400))"
strokeWidth="1.5"
aria-hidden="true" aria-hidden="true"
> >
<path <BoltIcon className="h-[26px] w-[26px]" />
d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" </span>
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)} )}
{/* Glow effect */} {/* Glow effect */}
<div <div

View File

@@ -0,0 +1,85 @@
import {
PiTextB,
PiTextItalic,
PiTextUnderline,
PiTextStrikethrough,
PiTextHOne,
PiTextHTwo,
PiTextHThree,
PiListBullets,
PiListNumbers,
PiQuotes,
PiCodeBlock,
PiTextAlignLeft,
PiTextAlignCenter,
PiHighlighter,
} from 'react-icons/pi';
import { cn } from '@/lib/utils';
interface IconProps {
className?: string;
}
/**
* Rich-text editor toolbar icons — Phosphor (react-icons/pi), the panel's
* icon family (regular weight). Shared by the TipTap toolbars in AdminNewsCreate and
* AdminInfoPageEditor. Names match the historical local definitions so the
* toolbars import instead of hand-rolling SVGs.
*/
export const BoldIcon = ({ className }: IconProps) => (
<PiTextB className={cn('h-5 w-5', className)} />
);
export const ItalicIcon = ({ className }: IconProps) => (
<PiTextItalic className={cn('h-5 w-5', className)} />
);
export const UnderlineIcon = ({ className }: IconProps) => (
<PiTextUnderline className={cn('h-5 w-5', className)} />
);
export const StrikeIcon = ({ className }: IconProps) => (
<PiTextStrikethrough className={cn('h-5 w-5', className)} />
);
export const H1Icon = ({ className }: IconProps) => (
<PiTextHOne className={cn('h-5 w-5', className)} />
);
export const H2Icon = ({ className }: IconProps) => (
<PiTextHTwo className={cn('h-5 w-5', className)} />
);
export const H3Icon = ({ className }: IconProps) => (
<PiTextHThree className={cn('h-5 w-5', className)} />
);
export const ListBulletIcon = ({ className }: IconProps) => (
<PiListBullets className={cn('h-5 w-5', className)} />
);
export const ListOrderedIcon = ({ className }: IconProps) => (
<PiListNumbers className={cn('h-5 w-5', className)} />
);
export const QuoteIcon = ({ className }: IconProps) => (
<PiQuotes className={cn('h-5 w-5', className)} />
);
export const CodeBlockIcon = ({ className }: IconProps) => (
<PiCodeBlock className={cn('h-5 w-5', className)} />
);
export const AlignLeftIcon = ({ className }: IconProps) => (
<PiTextAlignLeft className={cn('h-5 w-5', className)} />
);
export const AlignCenterIcon = ({ className }: IconProps) => (
<PiTextAlignCenter className={cn('h-5 w-5', className)} />
);
export const HighlightIcon = ({ className }: IconProps) => (
<PiHighlighter className={cn('h-5 w-5', className)} />
);

View File

@@ -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) => (
<PiSlidersHorizontal className={cn('h-5 w-5', className)} />
);
export const WrenchIcon = ({ className }: IconProps) => (
<PiWrench className={cn('h-5 w-5', className)} />
);
export const BookOpenIcon = ({ className }: IconProps) => (
<PiBookOpen className={cn('h-5 w-5', className)} />
);
export const AgentIcon = ({ className }: IconProps) => (
<PiHeadset className={cn('h-5 w-5', className)} />
);
export const ArrowDownIcon = ({ className }: IconProps) => (
<PiArrowDown className={cn('h-5 w-5', className)} />
);
export const ArrowIcon = ({ className }: IconProps) => (
<PiArrowRight className={cn('h-5 w-5', className)} />
);
export const ArrowUpIcon = ({ className }: IconProps) => (
<PiArrowUp className={cn('h-5 w-5', className)} />
);
export const BanIcon = ({ className }: IconProps) => (
<PiProhibit className={cn('h-5 w-5', className)} />
);
export const KeyIcon = ({ className }: IconProps) => <PiKey className={cn('h-5 w-5', className)} />;
export const InboxIcon = ({ className }: IconProps) => (
<PiTray className={cn('h-5 w-5', className)} />
);
export const ExportIcon = ({ className }: IconProps) => (
<PiExport className={cn('h-5 w-5', className)} />
);
export const WarningCircleIcon = ({ className }: IconProps) => (
<PiWarningCircle className={cn('h-5 w-5', className)} />
);
export const HeartbeatIcon = ({ className }: IconProps) => (
<PiHeartbeat className={cn('h-5 w-5', className)} />
);
export const CalendarBlankIcon = ({ className }: IconProps) => (
<PiCalendarBlank className={cn('h-5 w-5', className)} />
);
export const CalendarStarIcon = ({ className }: IconProps) => (
<PiCalendarStar className={cn('h-5 w-5', className)} />
);
export const ChartPieIcon = ({ className }: IconProps) => (
<PiChartPie className={cn('h-5 w-5', className)} />
);
export const ChartDonutIcon = ({ className }: IconProps) => (
<PiChartDonut className={cn('h-5 w-5', className)} />
);
export const CpuIcon = ({ className }: IconProps) => <PiCpu className={cn('h-5 w-5', className)} />;
export const MemoryIcon = ({ className }: IconProps) => (
<PiMemory className={cn('h-5 w-5', className)} />
);
export const PulseIcon = ({ className }: IconProps) => (
<PiPulse className={cn('h-5 w-5', className)} />
);
export const BanknotesIcon = ({ className }: IconProps) => (
<PiMoney className={cn('h-5 w-5', className)} />
);
export const BellIcon = ({ className }: IconProps) => (
<PiBell className={cn('h-5 w-5', className)} />
);
export const BoltIcon = ({ className }: IconProps) => (
<PiLightning className={cn('h-5 w-5', className)} />
);
export const BotIcon = ({ className }: IconProps) => (
<PiRobot className={cn('h-5 w-5', className)} />
);
export const BroadcastIcon = ({ className }: IconProps) => (
<PiBroadcast className={cn('h-5 w-5', className)} />
);
export const CabinetIcon = ({ className }: IconProps) => (
<PiAppWindow className={cn('h-5 w-5', className)} />
);
export const CalendarIcon = ({ className }: IconProps) => (
<PiCalendarDots className={cn('h-5 w-5', className)} />
);
export const CardIcon = ({ className }: IconProps) => (
<PiCreditCard className={cn('h-5 w-5', className)} />
);
export const ChannelIcon = ({ className }: IconProps) => (
<PiBroadcast className={cn('h-5 w-5', className)} />
);
export const ChartBarIcon = ({ className }: IconProps) => (
<PiChartBar className={cn('h-5 w-5', className)} />
);
export const CheckCircleIcon = ({ className }: IconProps) => (
<PiCheckCircle className={cn('h-5 w-5', className)} />
);
export const ChevronExpandIcon = ({ className }: IconProps) => (
<PiCaretUpDown className={cn('h-5 w-5', className)} />
);
export const ChevronIcon = ({ className }: IconProps) => (
<PiCaretDown className={cn('h-5 w-5', className)} />
);
export const ChevronLeftIcon = ({ className }: IconProps) => (
<PiCaretLeft className={cn('h-5 w-5', className)} />
);
export const ChevronUpIcon = ({ className }: IconProps) => (
<PiCaretUp className={cn('h-5 w-5', className)} />
);
export const CryptoIcon = ({ className }: IconProps) => (
<PiCurrencyBtc className={cn('h-5 w-5', className)} />
);
export const DevicesIcon = ({ className }: IconProps) => (
<PiDevices className={cn('h-5 w-5', className)} />
);
export const DocumentIcon = ({ className }: IconProps) => (
<PiFileText className={cn('h-5 w-5', className)} />
);
export const DotIcon = ({ className }: IconProps) => (
<PiCircleFill className={cn('h-2 w-2', className)} />
);
export const EmailIcon = ({ className }: IconProps) => (
<PiEnvelope className={cn('h-5 w-5', className)} />
);
export const ExclamationIcon = ({ className }: IconProps) => (
<PiWarning className={cn('h-5 w-5', className)} />
);
export const ExternalLinkIcon = ({ className }: IconProps) => (
<PiArrowSquareOut className={cn('h-5 w-5', className)} />
);
export const EyeIcon = ({ className }: IconProps) => <PiEye className={cn('h-5 w-5', className)} />;
export const FileTextIcon = ({ className }: IconProps) => (
<PiFileText className={cn('h-5 w-5', className)} />
);
export const FilterIcon = ({ className }: IconProps) => (
<PiFunnel className={cn('h-5 w-5', className)} />
);
export const GripIcon = ({ className }: IconProps) => (
<PiDotsSix className={cn('h-5 w-5', className)} />
);
export const GripVerticalIcon = ({ className }: IconProps) => (
<PiDotsSixVertical className={cn('h-5 w-5', className)} />
);
export const HealthIcon = ({ className }: IconProps) => (
<PiHeartbeat className={cn('h-5 w-5', className)} />
);
export const HistoryIcon = ({ className }: IconProps) => (
<PiClockCounterClockwise className={cn('h-5 w-5', className)} />
);
export const ImageIcon = ({ className }: IconProps) => (
<PiImage className={cn('h-5 w-5', className)} />
);
export const InfinityIcon = ({ className }: IconProps) => (
<PiInfinity className={cn('h-5 w-5', className)} />
);
export const LinkIcon = ({ className }: IconProps) => (
<PiLink className={cn('h-5 w-5', className)} />
);
export const MailIcon = ({ className }: IconProps) => (
<PiEnvelope className={cn('h-5 w-5', className)} />
);
export const MegaphoneIcon = ({ className }: IconProps) => (
<PiMegaphone className={cn('h-5 w-5', className)} />
);
export const MinusIcon = ({ className }: IconProps) => (
<PiMinus className={cn('h-5 w-5', className)} />
);
export const NewsIcon = ({ className }: IconProps) => (
<PiNewspaper className={cn('h-5 w-5', className)} />
);
export const PartnerIcon = ({ className }: IconProps) => (
<PiHandshake className={cn('h-5 w-5', className)} />
);
export const PhotoIcon = ({ className }: IconProps) => (
<PiImage className={cn('h-5 w-5', className)} />
);
export const PercentIcon = ({ className }: IconProps) => (
<PiPercent className={cn('h-5 w-5', className)} />
);
export const PinIcon = ({ className }: IconProps) => (
<PiPushPin className={cn('h-5 w-5', className)} />
);
export const PlusSmallIcon = ({ className }: IconProps) => (
<PiPlus className={cn('h-4 w-4', className)} />
);
export const PowerIcon = ({ className }: IconProps) => (
<PiPower className={cn('h-5 w-5', className)} />
);
export const QuestionIcon = ({ className }: IconProps) => (
<PiQuestion className={cn('h-5 w-5', className)} />
);
export const RepeatIcon = ({ className }: IconProps) => (
<PiRepeat className={cn('h-5 w-5', className)} />
);
export const ReportIcon = ({ className }: IconProps) => (
<PiFlag className={cn('h-5 w-5', className)} />
);
export const ResetIcon = ({ className }: IconProps) => (
<PiArrowCounterClockwise className={cn('h-5 w-5', className)} />
);
export const RestartIcon = ({ className }: IconProps) => (
<PiArrowClockwise className={cn('h-5 w-5', className)} />
);
export const RocketIcon = ({ className }: IconProps) => (
<PiRocket className={cn('h-5 w-5', className)} />
);
export const SaveIcon = ({ className }: IconProps) => (
<PiFloppyDisk className={cn('h-5 w-5', className)} />
);
export const SendIcon = ({ className }: IconProps) => (
<PiPaperPlaneTilt className={cn('h-5 w-5', className)} />
);
export const ServerSmallIcon = ({ className }: IconProps) => (
<PiHardDrives className={cn('h-4 w-4', className)} />
);
export const SettingsIcon = ({ className }: IconProps) => (
<PiGearSix className={cn('h-5 w-5', className)} />
);
export const ShareIcon = ({ className }: IconProps) => (
<PiShareNetwork className={cn('h-5 w-5', className)} />
);
export const SparklesIcon = ({ className }: IconProps) => (
<PiSparkle className={cn('h-5 w-5', className)} />
);
export const StatBotIcon = ({ className }: IconProps) => (
<PiRobot className={cn('h-5 w-5', className)} />
);
export const StatCabinetIcon = ({ className }: IconProps) => (
<PiAppWindow className={cn('h-5 w-5', className)} />
);
export const StatPaidIcon = ({ className }: IconProps) => (
<PiMoney className={cn('h-5 w-5', className)} />
);
export const StatsChartIcon = ({ className }: IconProps) => (
<PiChartLine className={cn('h-5 w-5', className)} />
);
export const StatTrialIcon = ({ className }: IconProps) => (
<PiGift className={cn('h-5 w-5', className)} />
);
export const StatUptimeIcon = ({ className }: IconProps) => (
<PiTimer className={cn('h-5 w-5', className)} />
);
export const StatusIcon = ({ className }: IconProps) => (
<PiCircle className={cn('h-5 w-5', className)} />
);
export const TagIcon = ({ className }: IconProps) => <PiTag className={cn('h-5 w-5', className)} />;
export const TelegramIcon = ({ className }: IconProps) => (
<PiTelegramLogo className={cn('h-5 w-5', className)} />
);
export const TelegramSmallIcon = ({ className }: IconProps) => (
<PiTelegramLogo className={cn('h-4 w-4', className)} />
);
export const TicketIcon = ({ className }: IconProps) => (
<PiTicket className={cn('h-5 w-5', className)} />
);
export const TrafficIcon = ({ className }: IconProps) => (
<PiGauge className={cn('h-5 w-5', className)} />
);
export const TrashSmallIcon = ({ className }: IconProps) => (
<PiTrash className={cn('h-4 w-4', className)} />
);
export const TrophyIcon = ({ className }: IconProps) => (
<PiTrophy className={cn('h-5 w-5', className)} />
);
export const UnpinIcon = ({ className }: IconProps) => (
<PiPushPinSlash className={cn('h-5 w-5', className)} />
);
export const UserPlusIcon = ({ className }: IconProps) => (
<PiUserPlus className={cn('h-5 w-5', className)} />
);
export const UsersOnlineIcon = ({ className }: IconProps) => (
<PiUsersThree className={cn('h-5 w-5', className)} />
);
export const VideoIcon = ({ className }: IconProps) => (
<PiVideoCamera className={cn('h-5 w-5', className)} />
);
export const WarningIcon = ({ className }: IconProps) => (
<PiWarning className={cn('h-5 w-5', className)} />
);
export const XCircleIcon = ({ className }: IconProps) => (
<PiXCircle className={cn('h-5 w-5', className)} />
);
export const XCloseIcon = ({ className }: IconProps) => (
<PiX className={cn('h-5 w-5', className)} />
);
export const XMarkIcon = ({ className }: IconProps) => <PiX className={cn('h-5 w-5', className)} />;

View File

@@ -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'; 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 { interface IconProps {
className?: string; 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 // Navigation & Layout
export const HomeIcon = ({ className }: IconProps) => ( export const HomeIcon = ({ className }: IconProps) => (
<svg <PiHouse className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
/>
</svg>
); );
export const BackIcon = ({ className }: IconProps) => ( export const BackIcon = ({ className }: IconProps) => (
<svg <PiArrowLeft className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
); );
export const ChevronRightIcon = ({ className }: IconProps) => ( export const ChevronRightIcon = ({ className }: IconProps) => (
<svg <PiCaretRight className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
); );
export const MenuIcon = ({ className }: IconProps) => ( export const MenuIcon = ({ className }: IconProps) => (
<svg <PiList className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
); );
export const CloseIcon = ({ className }: IconProps) => ( export const CloseIcon = ({ className }: IconProps) => <PiX className={cn('h-5 w-5', className)} />;
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
export const ChevronDownIcon = ({ className }: IconProps) => ( export const ChevronDownIcon = ({ className }: IconProps) => (
<svg <PiCaretDown className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
); );
export const ArrowRightIcon = ({ className }: IconProps) => ( export const ArrowRightIcon = ({ className }: IconProps) => (
<svg <PiArrowRight className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
); );
// Actions // Actions
export const SearchIcon = ({ className }: IconProps) => ( export const SearchIcon = ({ className }: IconProps) => (
<svg <PiMagnifyingGlass className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
); );
export const PlusIcon = ({ className }: IconProps) => ( export const PlusIcon = ({ className }: IconProps) => (
<svg <PiPlus className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
); );
export const EditIcon = ({ className }: IconProps) => ( export const EditIcon = ({ className }: IconProps) => (
<svg <PiPencilSimple className={cn('h-4 w-4', className)} />
className={cn('h-4 w-4', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125"
/>
</svg>
); );
export const PencilIcon = ({ className }: IconProps) => ( export const PencilIcon = ({ className }: IconProps) => (
<svg <PiPencil className={cn('h-4 w-4', className)} />
className={cn('h-4 w-4', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
); );
export const TrashIcon = ({ className }: IconProps) => ( export const TrashIcon = ({ className }: IconProps) => (
<svg <PiTrash className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
); );
export const UploadIcon = ({ className }: IconProps) => ( export const UploadIcon = ({ className }: IconProps) => (
<svg <PiUploadSimple className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/>
</svg>
); );
export const DownloadIcon = ({ className }: IconProps) => ( export const DownloadIcon = ({ className }: IconProps) => (
<svg <PiDownloadSimple className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
); );
export const RefreshIcon = ({ export const RefreshIcon = ({
className, className,
spinning = false, spinning = false,
}: IconProps & { spinning?: boolean }) => ( }: IconProps & { spinning?: boolean }) => (
<svg <PiArrowsClockwise className={cn('h-4 w-4', spinning && 'animate-spin', className)} />
className={cn('h-4 w-4', spinning && 'animate-spin', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
); );
export const SyncIcon = ({ className }: IconProps) => ( export const SyncIcon = ({ className }: IconProps) => (
<svg <PiArrowsClockwise className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
); );
// Status // Status
export const CheckIcon = ({ className }: IconProps) => ( export const CheckIcon = ({ className }: IconProps) => (
<svg <PiCheck className={cn('h-4 w-4', className)} />
className={cn('h-4 w-4', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
); );
export const CopyIcon = ({ className }: IconProps) => ( export const CopyIcon = ({ className }: IconProps) => (
<svg <PiCopy className={cn('h-4 w-4', className)} />
className={cn('h-4 w-4', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
); );
export const XIcon = ({ className }: IconProps) => ( export const XIcon = ({ className }: IconProps) => <PiX className={cn('h-4 w-4', className)} />;
<svg
className={cn('h-4 w-4', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
export const LockIcon = ({ className }: IconProps) => ( export const LockIcon = ({ className }: IconProps) => (
<svg <PiLock className={cn('h-4 w-4', className)} />
className={cn('h-4 w-4', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/>
</svg>
); );
export const InfoIcon = ({ className }: IconProps) => ( export const InfoIcon = ({ className }: IconProps) => (
<svg <PiInfo className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
/>
</svg>
); );
// User & People // User & People
export const UserIcon = ({ className }: IconProps) => ( export const UserIcon = ({ className }: IconProps) => (
<svg <PiUser className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
); );
export const UsersIcon = ({ className }: IconProps) => ( export const UsersIcon = ({ className }: IconProps) => (
<svg <PiUsers className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
); );
export const LogoutIcon = ({ className }: IconProps) => ( export const LogoutIcon = ({ className }: IconProps) => (
<svg <PiSignOut className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
/>
</svg>
); );
// Theme // Theme
export const SunIcon = ({ className }: IconProps) => ( export const SunIcon = ({ className }: IconProps) => <PiSun className={cn('h-5 w-5', className)} />;
<svg
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg>
);
export const MoonIcon = ({ className }: IconProps) => ( export const MoonIcon = ({ className }: IconProps) => (
<svg <PiMoon className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</svg>
); );
export const PaletteIcon = ({ className }: IconProps) => ( export const PaletteIcon = ({ className }: IconProps) => (
<svg <PiPalette className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z"
/>
</svg>
); );
// Features & Content // Features & Content
export const SubscriptionIcon = ({ className }: IconProps) => ( export const SubscriptionIcon = ({ className }: IconProps) => (
<svg <PiSparkle className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
/>
</svg>
); );
export const WalletIcon = ({ className }: IconProps) => ( export const WalletIcon = ({ className }: IconProps) => (
<svg <PiWallet className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
/>
</svg>
); );
export const ChatIcon = ({ className }: IconProps) => ( export const ChatIcon = ({ className }: IconProps) => (
<svg <PiChatCircle className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
/>
</svg>
); );
export const GiftIcon = ({ className }: IconProps) => ( export const GiftIcon = ({ className }: IconProps) => (
<svg <PiGift className={cn('h-4 w-4', className)} />
className={cn('h-4 w-4', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
); );
export const ClockIcon = ({ className }: IconProps) => ( export const ClockIcon = ({ className }: IconProps) => (
<svg <PiClock className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
); );
export const StarIcon = ({ className, filled }: IconProps & { filled?: boolean }) => ( export const StarIcon = ({ className, filled }: IconProps & { filled?: boolean }) =>
<svg filled ? (
className={cn('h-5 w-5', className)} <PiStarFill className={cn('h-5 w-5', className)} />
fill={filled ? 'currentColor' : 'none'} ) : (
viewBox="0 0 24 24" <PiStar className={cn('h-5 w-5', className)} />
stroke="currentColor" );
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
/>
</svg>
);
export const GamepadIcon = ({ className }: IconProps) => ( export const GamepadIcon = ({ className }: IconProps) => (
<svg <PiGameController className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.959.401v0a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.78.78 0 01.79-.869"
/>
</svg>
); );
export const ClipboardIcon = ({ className }: IconProps) => ( export const ClipboardIcon = ({ className }: IconProps) => (
<svg <PiClipboardText className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z"
/>
</svg>
); );
export const CogIcon = ({ className }: IconProps) => ( export const CogIcon = ({ className }: IconProps) => (
<svg <PiGearSix className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
); );
export const WheelIcon = ({ className }: IconProps) => ( export const WheelIcon = ({ className }: IconProps) => (
<svg <PiDiceFive className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)} );
fill="none"
viewBox="0 0 24 24" export const ShieldIcon = ({ className }: IconProps) => (
stroke="currentColor" <PiShield className={cn('h-5 w-5', className)} />
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg>
); );
export const ServerIcon = ({ className }: IconProps) => ( export const ServerIcon = ({ className }: IconProps) => (
<svg <PiHardDrives className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 17.25v-.228a4.5 4.5 0 00-.12-1.03l-2.268-9.64a3.375 3.375 0 00-3.285-2.602H7.923a3.375 3.375 0 00-3.285 2.602l-2.268 9.64a4.5 4.5 0 00-.12 1.03v.228m19.5 0a3 3 0 01-3 3H5.25a3 3 0 01-3-3m19.5 0a3 3 0 00-3-3H5.25a3 3 0 00-3 3m16.5 0h.008v.008h-.008v-.008zm-3 0h.008v.008h-.008v-.008z"
/>
</svg>
); );
export const CampaignIcon = ({ className }: IconProps) => ( export const CampaignIcon = ({ className }: IconProps) => (
<svg <PiMegaphone className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
/>
</svg>
); );
// 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 RemnawaveIcon = ({ className }: IconProps) => (
<svg <svg
className={cn('h-5 w-5', className)} className={cn('h-5 w-5', className)}
@@ -623,114 +254,45 @@ export const RemnawaveIcon = ({ className }: IconProps) => (
); );
export const ChartIcon = ({ className }: IconProps) => ( export const ChartIcon = ({ className }: IconProps) => (
<PiChartBar className={cn('h-4 w-4', className)} />
);
// 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) => (
<svg <svg
className={cn('h-4 w-4', className)} className={cn('h-5 w-5', className)}
fill="none" fill="currentColor"
viewBox="0 0 24 24" viewBox="0 0 35 35"
stroke="currentColor" xmlns="http://www.w3.org/2000/svg"
strokeWidth={2}
> >
<path <path d="M16.6961 15.2606L16.5825 3.49701C16.5718 2.38439 15.025 2.11843 14.6433 3.16356L11.7279 11.1447C11.6384 11.3898 11.4566 11.5902 11.2213 11.7031L5.66765 14.3687C4.70841 14.8291 5.03635 16.2703 6.10036 16.2703H15.6962C16.2522 16.2703 16.7015 15.8166 16.6961 15.2606Z" />
strokeLinecap="round" <path d="M18.6471 15.2703V5.88936C18.6471 4.84679 20.0428 4.49998 20.5308 5.4213L23.5833 11.1845C23.7 11.4049 23.8948 11.5737 24.1296 11.6578L31.5829 14.3289C32.6388 14.7073 32.3671 16.2703 31.2455 16.2703H19.6471C19.0948 16.2703 18.6471 15.8226 18.6471 15.2703Z" />
strokeLinejoin="round" <path d="M18.6471 31.4643V19.3784C18.6471 18.8261 19.0948 18.3784 19.6471 18.3784H29.2853C30.3376 18.3784 30.676 19.7947 29.7374 20.2704L24.1129 23.1208C23.889 23.2343 23.716 23.4278 23.6281 23.663L20.5839 31.8141C20.1941 32.8578 18.6471 32.5783 18.6471 31.4643Z" />
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" <path d="M16.7059 28.9873V19.3784C16.7059 18.8261 16.2582 18.3784 15.7059 18.3784H3.83963C2.71522 18.3784 2.44656 19.9473 3.50691 20.3214L11.5457 23.1578C11.7987 23.247 12.0052 23.4342 12.1188 23.6772L14.8 29.4109C15.2531 30.3797 16.7059 30.0568 16.7059 28.9873Z" />
/>
</svg> </svg>
); );
export const GlobeIcon = ({ className }: IconProps) => ( export const GlobeIcon = ({ className }: IconProps) => (
<svg <PiGlobe className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"
/>
</svg>
); );
export const PlayIcon = ({ className }: IconProps) => ( export const PlayIcon = ({ className }: IconProps) => (
<svg <PiPlay className={cn('h-4 w-4', className)} />
className={cn('h-4 w-4', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
/>
</svg>
); );
export const StopIcon = ({ className }: IconProps) => ( export const StopIcon = ({ className }: IconProps) => (
<svg <PiStop className={cn('h-4 w-4', className)} />
className={cn('h-4 w-4', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z"
/>
</svg>
); );
export const ArrowPathIcon = ({ className }: IconProps) => ( export const ArrowPathIcon = ({ className }: IconProps) => (
<svg <PiArrowsClockwise className={cn('h-4 w-4', className)} />
className={cn('h-4 w-4', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
); );
export const PauseIcon = ({ className, style }: IconProps & { style?: React.CSSProperties }) => ( export const PauseIcon = ({ className, style }: IconProps & { style?: CSSProperties }) => (
<svg <PiPauseCircle className={cn('h-5 w-5', className)} style={style} />
className={cn('h-5 w-5', className)}
style={style}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.25 9v6m-4.5 0V9M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
); );
export const CreditCardIcon = ({ className }: IconProps) => ( export const CreditCardIcon = ({ className }: IconProps) => (
<svg <PiCreditCard className={cn('h-5 w-5', className)} />
className={cn('h-5 w-5', className)}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
/>
</svg>
); );

View File

@@ -21,173 +21,25 @@ import SuccessNotificationModal from '@/components/SuccessNotificationModal';
import { PromptDialogHost } from '@/components/PromptDialogHost'; import { PromptDialogHost } from '@/components/PromptDialogHost';
import LanguageSwitcher from '@/components/LanguageSwitcher'; import LanguageSwitcher from '@/components/LanguageSwitcher';
import TicketNotificationBell from '@/components/TicketNotificationBell'; 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 { MobileBottomNav } from './MobileBottomNav';
import { AppHeader } from './AppHeader'; import { AppHeader } from './AppHeader';
import { BackgroundRenderer } from '@/components/backgrounds/BackgroundRenderer'; import { BackgroundRenderer } from '@/components/backgrounds/BackgroundRenderer';
// Desktop nav icons
const HomeIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
/>
</svg>
);
const CreditCardIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
/>
</svg>
);
const ChatIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
/>
</svg>
);
const UserIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg>
);
const UsersIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
);
const ShieldIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z"
/>
</svg>
);
const InfoIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
/>
</svg>
);
const LogoutIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15M12 9l-3 3m0 0l3 3m-3-3h12.75"
/>
</svg>
);
const SunIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg>
);
const MoonIcon = ({ className }: { className?: string }) => (
<svg
className={className}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</svg>
);
interface AppShellProps { interface AppShellProps {
children: React.ReactNode; children: React.ReactNode;
} }

View File

@@ -6,6 +6,7 @@ import { motion } from 'framer-motion';
import { newsApi } from '../../api/news'; import { newsApi } from '../../api/news';
import { useHapticFeedback } from '../../platform/hooks/useHaptic'; import { useHapticFeedback } from '../../platform/hooks/useHaptic';
import { cn } from '../../lib/utils'; import { cn } from '../../lib/utils';
import { ArrowIcon, NewsIcon } from '@/components/icons';
import type { NewsListItem } from '../../types/news'; import type { NewsListItem } from '../../types/news';
// --- Security: hex color validation to prevent CSS injection --- // --- Security: hex color validation to prevent CSS injection ---
@@ -31,19 +32,6 @@ const fadeSlideUp = {
}), }),
}; };
// --- Icons ---
const ArrowIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M3 8h10M9 4l4 4-4 4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
// --- Sub-components --- // --- Sub-components ---
interface CategoryBadgeProps { interface CategoryBadgeProps {
@@ -427,35 +415,7 @@ export default function NewsSection() {
> >
<div className="mb-2 flex items-center gap-2.5"> <div className="mb-2 flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-accent-400 to-accent-600"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-gradient-to-br from-accent-400 to-accent-600">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <NewsIcon className="h-[18px] w-[18px] text-dark-950" />
<path
d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z"
fill="currentColor"
className="text-dark-950/20"
/>
<path
d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2Z"
stroke="currentColor"
strokeWidth="1.5"
className="text-dark-950"
/>
<path
d="M7 8h4M7 11h10M7 14h10M7 17h6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
className="text-dark-950"
/>
<rect
x="14"
y="7"
width="4"
height="4"
rx="0.5"
fill="currentColor"
className="text-dark-950"
/>
</svg>
</div> </div>
<span className="font-mono text-[11px] font-bold uppercase tracking-[0.18em] text-dark-500"> <span className="font-mono text-[11px] font-bold uppercase tracking-[0.18em] text-dark-500">
{t('news.title')} {t('news.title')}
@@ -469,11 +429,20 @@ export default function NewsSection() {
{/* Grid */} {/* Grid */}
{items.length > 0 && ( {items.length > 0 && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="space-y-4">
{featured && <FeaturedCard item={featured} onClick={handleFeaturedClick} />} {featured && <FeaturedCard item={featured} onClick={handleFeaturedClick} />}
{regular.map((item, i) => ( {regular.length > 0 && (
<NewsCardWrapper key={item.id} item={item} index={i} onCardClick={handleCardClick} /> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 sm:[&>*:last-child:nth-child(odd)]:col-span-2">
))} {regular.map((item, i) => (
<NewsCardWrapper
key={item.id}
item={item}
index={i}
onCardClick={handleCardClick}
/>
))}
</div>
)}
</div> </div>
)} )}

View File

@@ -1,6 +1,8 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PiCaretDown } from 'react-icons/pi';
import { CheckIcon, CopyIcon } from '@/components/icons';
import type { PartnerCampaignInfo } from '../../api/partners'; import type { PartnerCampaignInfo } from '../../api/partners';
import { PARTNER_STATS } from '../../constants/partner'; import { PARTNER_STATS } from '../../constants/partner';
import { useCurrency } from '../../hooks/useCurrency'; import { useCurrency } from '../../hooks/useCurrency';
@@ -9,32 +11,8 @@ import { copyToClipboard } from '../../utils/clipboard';
import { CampaignDetailStats } from './CampaignDetailStats'; import { CampaignDetailStats } from './CampaignDetailStats';
import { StatCard } from '../stats/StatCard'; import { StatCard } from '../stats/StatCard';
const CopyIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
);
const CheckIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
const ChevronIcon = ({ expanded }: { expanded: boolean }) => ( const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
<svg <PiCaretDown className={`h-5 w-5 transition-transform ${expanded ? 'rotate-180' : ''}`} />
className={`h-5 w-5 transition-transform ${expanded ? 'rotate-180' : ''}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
); );
interface CampaignCardProps { interface CampaignCardProps {

View File

@@ -1,20 +1,8 @@
import { Command as CommandPrimitive } from 'cmdk'; import { Command as CommandPrimitive } from 'cmdk';
import { forwardRef, type ComponentPropsWithoutRef, type HTMLAttributes } from 'react'; import { forwardRef, type ComponentPropsWithoutRef, type HTMLAttributes } from 'react';
import { SearchIcon } from '@/components/icons';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
// Search icon
const SearchIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-dark-400">
<path
d="M7.333 12.667A5.333 5.333 0 1 0 7.333 2a5.333 5.333 0 0 0 0 10.667ZM14 14l-2.9-2.9"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
// Root Command // Root Command
export type CommandProps = ComponentPropsWithoutRef<typeof CommandPrimitive>; export type CommandProps = ComponentPropsWithoutRef<typeof CommandPrimitive>;

View File

@@ -8,6 +8,7 @@ import {
useState, useState,
} from 'react'; } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { CloseIcon } from '@/components/icons';
import { backdrop, backdropTransition, scale, scaleTransition } from '../../motion/transitions'; import { backdrop, backdropTransition, scale, scaleTransition } from '../../motion/transitions';
export { export {
@@ -16,19 +17,6 @@ export {
Close as DialogClose, Close as DialogClose,
} from '@radix-ui/react-dialog'; } from '@radix-ui/react-dialog';
// Close icon
const CloseIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M12 4L4 12M4 4l8 8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
// Context for AnimatePresence // Context for AnimatePresence
const DialogContext = createContext<{ open: boolean }>({ open: false }); const DialogContext = createContext<{ open: boolean }>({ open: false });

View File

@@ -1,6 +1,7 @@
import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { forwardRef, type ComponentPropsWithoutRef } from 'react'; import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import { CheckIcon, ChevronRightIcon, DotIcon } from '@/components/icons';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform'; import { usePlatform } from '@/platform';
import { dropdown, dropdownTransition } from '../../motion/transitions'; import { dropdown, dropdownTransition } from '../../motion/transitions';
@@ -14,37 +15,6 @@ export {
RadioGroup as DropdownMenuRadioGroup, RadioGroup as DropdownMenuRadioGroup,
} from '@radix-ui/react-dropdown-menu'; } from '@radix-ui/react-dropdown-menu';
// Icons
const CheckIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M3.5 8.5L6.5 11.5L12.5 4.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
const ChevronRightIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M6 4l4 4-4 4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
const DotIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="8" cy="8" r="3" fill="currentColor" />
</svg>
);
// SubTrigger // SubTrigger
export interface DropdownMenuSubTriggerProps extends ComponentPropsWithoutRef< export interface DropdownMenuSubTriggerProps extends ComponentPropsWithoutRef<
typeof DropdownMenuPrimitive.SubTrigger typeof DropdownMenuPrimitive.SubTrigger

View File

@@ -1,6 +1,7 @@
import * as PopoverPrimitive from '@radix-ui/react-popover'; import * as PopoverPrimitive from '@radix-ui/react-popover';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { forwardRef, type ComponentPropsWithoutRef } from 'react'; import { forwardRef, type ComponentPropsWithoutRef } from 'react';
import { CloseIcon } from '@/components/icons';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { dropdown, dropdownTransition } from '../../motion/transitions'; import { dropdown, dropdownTransition } from '../../motion/transitions';
@@ -11,19 +12,6 @@ export {
Close as PopoverClose, Close as PopoverClose,
} from '@radix-ui/react-popover'; } from '@radix-ui/react-popover';
// Close icon
const CloseIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M12 4L4 12M4 4l8 8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
// Content // Content
export interface PopoverContentProps extends ComponentPropsWithoutRef< export interface PopoverContentProps extends ComponentPropsWithoutRef<
typeof PopoverPrimitive.Content typeof PopoverPrimitive.Content

View File

@@ -2,36 +2,12 @@ import * as SelectPrimitive from '@radix-ui/react-select';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react'; import { forwardRef, type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { CheckIcon, ChevronDownIcon } from '@/components/icons';
import { usePlatform } from '@/platform'; import { usePlatform } from '@/platform';
import { dropdown, dropdownTransition } from '../../motion/transitions'; import { dropdown, dropdownTransition } from '../../motion/transitions';
export { Root as Select, Group as SelectGroup } from '@radix-ui/react-select'; export { Root as Select, Group as SelectGroup } from '@radix-ui/react-select';
// Icons
const ChevronDownIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-dark-400">
<path
d="M4 6L8 10L12 6"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
const CheckIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="text-accent-400">
<path
d="M3.5 8.5L6.5 11.5L12.5 4.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
// Trigger // Trigger
export interface SelectTriggerProps extends ComponentPropsWithoutRef< export interface SelectTriggerProps extends ComponentPropsWithoutRef<
typeof SelectPrimitive.Trigger typeof SelectPrimitive.Trigger

View File

@@ -8,6 +8,7 @@ import {
useState, useState,
useCallback, useCallback,
} from 'react'; } from 'react';
import { CloseIcon } from '@/components/icons';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { usePlatform } from '@/platform'; import { usePlatform } from '@/platform';
import { import {
@@ -23,19 +24,6 @@ export {
Close as SheetClose, Close as SheetClose,
} from '@radix-ui/react-dialog'; } from '@radix-ui/react-dialog';
// Close icon
const CloseIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M12 4L4 12M4 4l8 8"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
// Context for AnimatePresence and control // Context for AnimatePresence and control
interface SheetContextValue { interface SheetContextValue {
open: boolean; open: boolean;

View File

@@ -1,6 +1,7 @@
import { Link } from 'react-router'; import { Link } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { HoverBorderGradient } from '../ui/hover-border-gradient'; import { HoverBorderGradient } from '../ui/hover-border-gradient';
import { ChevronRightIcon, SubscriptionIcon } from '@/components/icons';
import type { Subscription } from '../../types'; import type { Subscription } from '../../types';
interface PurchaseCTAButtonProps { interface PurchaseCTAButtonProps {
@@ -73,21 +74,10 @@ export default function PurchaseCTAButton({
background: isExpired background: isExpired
? 'rgba(255,59,92,0.12)' ? 'rgba(255,59,92,0.12)'
: 'rgba(var(--color-accent-400), 0.12)', : 'rgba(var(--color-accent-400), 0.12)',
color: accentColor,
}} }}
> >
<svg <SubscriptionIcon className="h-[18px] w-[18px]" />
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke={accentColor}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
</svg>
</div> </div>
<div> <div>
<div className="text-[15px] font-semibold text-dark-50">{buttonText}</div> <div className="text-[15px] font-semibold text-dark-50">{buttonText}</div>
@@ -96,20 +86,7 @@ export default function PurchaseCTAButton({
</div> </div>
{/* Right: chevron */} {/* Right: chevron */}
<svg <ChevronRightIcon className="h-5 w-5 flex-shrink-0 text-dark-50/30 transition-transform duration-300 group-hover:translate-x-1" />
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className="flex-shrink-0 text-dark-50/30 transition-transform duration-300 group-hover:translate-x-1"
>
<path d="M9 18l6-6-6-6" />
</svg>
</div> </div>
</HoverBorderGradient> </HoverBorderGradient>
</Link> </Link>

View File

@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { useTheme } from '../../hooks/useTheme'; import { useTheme } from '../../hooks/useTheme';
import { getGlassColors } from '../../utils/glassTheme'; import { getGlassColors } from '../../utils/glassTheme';
import { useHaptic } from '../../platform'; import { useHaptic } from '../../platform';
import { CalendarIcon, CheckIcon, ChevronRightIcon, DevicesIcon } from '@/components/icons';
import type { SubscriptionListItem } from '../../types'; import type { SubscriptionListItem } from '../../types';
function formatDate(iso: string | null, locale?: string): string { function formatDate(iso: string | null, locale?: string): string {
@@ -136,15 +137,7 @@ export default function SubscriptionListCard({
</span> </span>
<StatusBadge status={subscription.status} isTrial={isTrial} t={t} /> <StatusBadge status={subscription.status} isTrial={isTrial} t={t} />
</div> </div>
<svg <ChevronRightIcon className="h-4 w-4 shrink-0 opacity-30" />
className="h-4 w-4 shrink-0 opacity-30"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</div> </div>
{/* Traffic mini progress bar */} {/* Traffic mini progress bar */}
@@ -177,29 +170,11 @@ export default function SubscriptionListCard({
style={{ color: g.textSecondary }} style={{ color: g.textSecondary }}
> >
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<svg <DevicesIcon className="h-3.5 w-3.5 opacity-50" />
className="h-3.5 w-3.5 opacity-50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<rect x="5" y="2" width="14" height="20" rx="2" />
<path d="M12 18h.01" />
</svg>
{subscription.device_limit} {subscription.device_limit}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<svg <CalendarIcon className="h-3.5 w-3.5 opacity-50" />
className="h-3.5 w-3.5 opacity-50"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</svg>
{formatDate(subscription.end_date, i18n.language)} {formatDate(subscription.end_date, i18n.language)}
</span> </span>
{!isTrial && {!isTrial &&
@@ -213,19 +188,19 @@ export default function SubscriptionListCard({
<span <span
className={`flex items-center gap-1 ${enabled ? 'text-success-400/70' : 'text-error-400/50'}`} className={`flex items-center gap-1 ${enabled ? 'text-success-400/70' : 'text-error-400/50'}`}
> >
<svg {enabled ? (
className="h-3 w-3" <CheckIcon className="h-3 w-3" />
viewBox="0 0 24 24" ) : (
fill="none" <svg
stroke="currentColor" className="h-3 w-3"
strokeWidth={2.5} viewBox="0 0 24 24"
> fill="none"
{enabled ? ( stroke="currentColor"
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" /> strokeWidth={2.5}
) : ( >
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
)} </svg>
</svg> )}
{label} {label}
</span> </span>
); );

View File

@@ -4,6 +4,7 @@ import { useTheme } from '../../../hooks/useTheme';
import { useCurrency } from '../../../hooks/useCurrency'; import { useCurrency } from '../../../hooks/useCurrency';
import { usePromoDiscount } from '../../../hooks/usePromoDiscount'; import { usePromoDiscount } from '../../../hooks/usePromoDiscount';
import { getGlassColors } from '../../../utils/glassTheme'; import { getGlassColors } from '../../../utils/glassTheme';
import { ArrowDownIcon, DevicesIcon, RestartIcon } from '@/components/icons';
import type { Tariff, Subscription, PurchaseOptions } from '../../../types'; import type { Tariff, Subscription, PurchaseOptions } from '../../../types';
// ────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────
@@ -169,35 +170,11 @@ export function TariffPickerGrid({
</div> </div>
<div className="flex flex-wrap gap-4 text-sm"> <div className="flex flex-wrap gap-4 text-sm">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<svg <ArrowDownIcon className="h-4 w-4 text-accent-400" />
className="h-4 w-4 text-accent-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/>
</svg>
<span className="font-medium text-dark-200">{tariff.traffic_limit_label}</span> <span className="font-medium text-dark-200">{tariff.traffic_limit_label}</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<svg <DevicesIcon className="h-4 w-4 text-dark-400" />
className="h-4 w-4 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
/>
</svg>
<span className="text-dark-300"> <span className="text-dark-300">
{tariff.device_limit === 0 {tariff.device_limit === 0
? '∞' ? '∞'
@@ -206,19 +183,7 @@ export function TariffPickerGrid({
</div> </div>
{tariff.traffic_reset_mode && tariff.traffic_reset_mode !== 'NO_RESET' && ( {tariff.traffic_reset_mode && tariff.traffic_reset_mode !== 'NO_RESET' && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<svg <RestartIcon className="h-4 w-4 text-dark-400" />
className="h-4 w-4 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182M2.985 19.644l3.181-3.182"
/>
</svg>
<span className="text-dark-300"> <span className="text-dark-300">
{t(`subscription.trafficReset.${tariff.traffic_reset_mode}`)} {t(`subscription.trafficReset.${tariff.traffic_reset_mode}`)}
</span> </span>

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { TrashIcon } from '@/components/icons';
import { subscriptionApi } from '../../../api/subscription'; import { subscriptionApi } from '../../../api/subscription';
import { usePlatform } from '../../../platform'; import { usePlatform } from '../../../platform';
import { useDestructiveConfirm } from '../../../platform/hooks/useNativeDialog'; import { useDestructiveConfirm } from '../../../platform/hooks/useNativeDialog';
@@ -72,19 +73,7 @@ export function DeleteSubscriptionSheet({
disabled={deleteLoading} 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" 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"
> >
<svg <TrashIcon className="h-4 w-4" />
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
{t('subscription.delete', 'Удалить подписку')} {t('subscription.delete', 'Удалить подписку')}
</button> </button>
); );

View File

@@ -1,5 +1,8 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { ChevronLeftIcon, ChevronRightIcon, DocumentIcon, XIcon } from '@/components/icons';
import { ticketsApi } from '../../api/tickets'; import { ticketsApi } from '../../api/tickets';
export interface MediaItem { export interface MediaItem {
@@ -150,19 +153,7 @@ export function MessageMediaGrid({
rel="noopener noreferrer" 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" 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"
> >
<svg <DocumentIcon className="h-4 w-4" />
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
</svg>
{item.caption || `Download ${item.type}`} {item.caption || `Download ${item.type}`}
</a> </a>
); );
@@ -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" 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} onClick={closeFullscreen}
> >
<svg <XIcon className="h-5 w-5" />
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
{photoItems.length > 1 && ( {photoItems.length > 1 && (
@@ -199,15 +182,7 @@ export function MessageMediaGrid({
onClick={() => setFullscreenIndex(fullscreenIndex - 1)} 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" 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"
> >
<svg <ChevronLeftIcon className="h-5 w-5" />
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button> </button>
<button <button
type="button" type="button"
@@ -215,15 +190,7 @@ export function MessageMediaGrid({
onClick={() => setFullscreenIndex(fullscreenIndex + 1)} onClick={() => setFullscreenIndex(fullscreenIndex + 1)}
className="absolute right-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" className="absolute right-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"
> >
<svg <ChevronRightIcon className="h-5 w-5" />
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button> </button>
<div className="absolute bottom-6 left-1/2 z-10 -translate-x-1/2 rounded-full bg-dark-950/70 px-3 py-1 text-sm text-white"> <div className="absolute bottom-6 left-1/2 z-10 -translate-x-1/2 rounded-full bg-dark-950/70 px-3 py-1 text-sm text-white">
{fullscreenIndex + 1} / {photoItems.length} {fullscreenIndex + 1} / {photoItems.length}

View File

@@ -8,13 +8,6 @@ interface FortuneWheelProps {
onSpinComplete: () => void; 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({ const FortuneWheel = memo(function FortuneWheel({
prizes, prizes,
isSpinning, isSpinning,
@@ -206,6 +199,21 @@ const FortuneWheel = memo(function FortuneWheel({
<filter id="textShadow" x="-20%" y="-20%" width="140%" height="140%"> <filter id="textShadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="1" stdDeviation="1" floodColor="#000" floodOpacity="0.7" /> <feDropShadow dx="0" dy="1" stdDeviation="1" floodColor="#000" floodOpacity="0.7" />
</filter> </filter>
{/* 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. */}
<radialGradient id="ledGlowGrad">
<stop offset="0%" stopColor="#FEF08A" stopOpacity="0.95" />
<stop offset="45%" stopColor="#FDE047" stopOpacity="0.55" />
<stop offset="100%" stopColor="#FDE047" stopOpacity="0" />
</radialGradient>
{/* SVG-native emoji shadow (not a CSS filter) for the same reason */}
<filter id="emojiShadow" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="2" stdDeviation="2" floodColor="#000" floodOpacity="0.5" />
</filter>
</defs> </defs>
{/* Background shadow */} {/* Background shadow */}
@@ -262,9 +270,9 @@ const FortuneWheel = memo(function FortuneWheel({
className="led-glow" className="led-glow"
cx={dotX} cx={dotX}
cy={dotY} cy={dotY}
r={5} r={9}
fill="#FEF08A" fill="url(#ledGlowGrad)"
style={{ filter: 'blur(2px)', animationDelay: delay }} style={{ animationDelay: delay }}
/> />
<circle <circle
className="led-dot" className="led-dot"
@@ -331,7 +339,7 @@ const FortuneWheel = memo(function FortuneWheel({
dominantBaseline="middle" dominantBaseline="middle"
fontSize={prizes.length <= 6 ? '32' : '26'} fontSize={prizes.length <= 6 ? '32' : '26'}
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`} transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
style={{ filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.5))' }} filter="url(#emojiShadow)"
> >
{prize.emoji} {prize.emoji}
</text> </text>
@@ -400,25 +408,6 @@ const FortuneWheel = memo(function FortuneWheel({
/> />
)} )}
</div> </div>
{/* Sparkle effects when spinning - optimized with pre-calculated positions */}
{isSpinning && (
<div className="pointer-events-none absolute inset-0 overflow-hidden">
{SPARKLE_POSITIONS.map((pos, i) => (
<div
key={`sparkle-${i}`}
className="absolute h-2 w-2 animate-ping rounded-full bg-yellow-300"
style={{
top: pos.top,
left: pos.left,
animationDelay: pos.delay,
animationDuration: '1.5s',
opacity: 0.7,
}}
/>
))}
</div>
)}
</div> </div>
); );
}); });

View File

@@ -1207,7 +1207,7 @@
"broadcasts": "Broadcasts", "broadcasts": "Broadcasts",
"users": "Users", "users": "Users",
"payments": "Payments", "payments": "Payments",
"remnawave": "RemnaWave", "remnawave": "Remnawave",
"emailTemplates": "Email Templates", "emailTemplates": "Email Templates",
"paymentMethods": "Payment Methods", "paymentMethods": "Payment Methods",
"campaigns": "Campaigns", "campaigns": "Campaigns",
@@ -1605,7 +1605,7 @@
"notFound": "Payment method not found" "notFound": "Payment method not found"
}, },
"remnawave": { "remnawave": {
"title": "RemnaWave", "title": "Remnawave",
"subtitle": "Panel management and statistics", "subtitle": "Panel management and statistics",
"connected": "Connected", "connected": "Connected",
"disconnected": "Not configured", "disconnected": "Not configured",
@@ -1719,9 +1719,9 @@
"success": "Success", "success": "Success",
"runAutoSyncNow": "Run Auto Sync Now", "runAutoSyncNow": "Run Auto Sync Now",
"fromPanel": "From Panel", "fromPanel": "From Panel",
"fromPanelDesc": "Import users from RemnaWave to bot", "fromPanelDesc": "Import users from Remnawave to bot",
"toPanel": "To Panel", "toPanel": "To Panel",
"toPanelDesc": "Export users from bot to RemnaWave" "toPanelDesc": "Export users from bot to Remnawave"
} }
}, },
"wheel": { "wheel": {
@@ -2128,7 +2128,7 @@
"db_sqlite": "SQLite", "db_sqlite": "SQLite",
"db_redis": "Redis", "db_redis": "Redis",
"sys_core": "Core & Debug", "sys_core": "Core & Debug",
"sys_remnawave": "RemnaWave", "sys_remnawave": "Remnawave",
"sys_webapi": "Web API", "sys_webapi": "Web API",
"sys_webhook": "Webhook", "sys_webhook": "Webhook",
"sys_server": "Server status", "sys_server": "Server status",
@@ -2178,7 +2178,7 @@
"deleteUser": "Delete users" "deleteUser": "Delete users"
}, },
"deleteSubscription": { "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.", "hint": "Users will lose VPN access for deleted subscriptions. This action cannot be undone.",
"activePaidWarning": "{{count}} of {{total}} selected subscriptions are active paid", "activePaidWarning": "{{count}} of {{total}} selected subscriptions are active paid",
"forceDeleteConfirm": "I confirm deletion of active paid subscriptions" "forceDeleteConfirm": "I confirm deletion of active paid subscriptions"
@@ -2186,7 +2186,7 @@
"deleteUser": { "deleteUser": {
"warning": "Selected users will be permanently deleted from the bot! All their subscriptions, balance, and data will be lost!", "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.", "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": { "params": {
"days": "Number of days", "days": "Number of days",
@@ -2370,16 +2370,16 @@
"importError": "Invalid JSON file format", "importError": "Invalid JSON file format",
"importCreateError": "Failed to create some apps", "importCreateError": "Failed to create some apps",
"noAppsToImport": "No apps to import", "noAppsToImport": "No apps to import",
"remnaWaveToggle": "Toggle RemnaWave source", "remnaWaveToggle": "Toggle Remnawave source",
"refreshRemnaWave": "Refresh from RemnaWave", "refreshRemnaWave": "Refresh from Remnawave",
"remnaWaveMode": "Showing apps from RemnaWave panel. Read-only mode.", "remnaWaveMode": "Showing apps from Remnawave panel. Read-only mode.",
"remnaWaveSettings": "RemnaWave Settings", "remnaWaveSettings": "Remnawave Settings",
"remnaWaveConfigUuid": "Config UUID", "remnaWaveConfigUuid": "Config UUID",
"remnaWaveConfigUuidHint": "UUID of subscription page config from RemnaWave panel", "remnaWaveConfigUuidHint": "UUID of subscription page config from Remnawave panel",
"availableConfigs": "Available configs", "availableConfigs": "Available configs",
"noConfigs": "No configs", "noConfigs": "No configs",
"remnaWaveConnected": "RemnaWave connected", "remnaWaveConnected": "Remnawave connected",
"remnaWaveDisconnected": "RemnaWave disconnected" "remnaWaveDisconnected": "Remnawave disconnected"
}, },
"tickets": { "tickets": {
"title": "Ticket Management", "title": "Ticket Management",
@@ -2528,7 +2528,7 @@
"daysShort": "d.", "daysShort": "d.",
"serversTitle": "Servers", "serversTitle": "Servers",
"externalSquadTitle": "External Squad", "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", "noExternalSquad": "No external squad",
"externalSquadUsers": "users", "externalSquadUsers": "users",
"serversTabHint": "Select servers available on this tariff.", "serversTabHint": "Select servers available on this tariff.",
@@ -2607,7 +2607,7 @@
"sync": "Sync", "sync": "Sync",
"syncing": "Syncing...", "syncing": "Syncing...",
"syncNow": "Sync now", "syncNow": "Sync now",
"noServers": "No servers. Sync with RemnaWave.", "noServers": "No servers. Sync with Remnawave.",
"edit": "Edit Server", "edit": "Edit Server",
"originalName": "Original Name", "originalName": "Original Name",
"displayName": "Display Name", "displayName": "Display Name",

View File

@@ -1086,7 +1086,7 @@
"promoOffers": "پیشنهادات تبلیغاتی", "promoOffers": "پیشنهادات تبلیغاتی",
"promocodes": "کدهای تخفیف", "promocodes": "کدهای تخفیف",
"promoGroups": "گروه‌های تخفیف", "promoGroups": "گروه‌های تخفیف",
"remnawave": "RemnaWave", "remnawave": "Remnawave",
"users": "کاربران", "users": "کاربران",
"trafficUsage": "مصرف ترافیک", "trafficUsage": "مصرف ترافیک",
"updates": "به‌روزرسانی‌ها", "updates": "به‌روزرسانی‌ها",
@@ -1832,7 +1832,7 @@
"db_sqlite": "SQLite", "db_sqlite": "SQLite",
"db_redis": "Redis", "db_redis": "Redis",
"sys_core": "هسته و اشکال‌زدایی", "sys_core": "هسته و اشکال‌زدایی",
"sys_remnawave": "RemnaWave", "sys_remnawave": "Remnawave",
"sys_webapi": "Web API", "sys_webapi": "Web API",
"sys_webhook": "Webhook", "sys_webhook": "Webhook",
"sys_server": "وضعیت سرور", "sys_server": "وضعیت سرور",
@@ -1957,15 +1957,15 @@
"importError": "فرمت فایل JSON نامعتبر", "importError": "فرمت فایل JSON نامعتبر",
"importPreview": "پیش‌نمایش وارد کردن", "importPreview": "پیش‌نمایش وارد کردن",
"noAppsToImport": "هیچ برنامه‌ای برای وارد کردن نیست", "noAppsToImport": "هیچ برنامه‌ای برای وارد کردن نیست",
"refreshRemnaWave": "بازخوانی از RemnaWave", "refreshRemnaWave": "بازخوانی از Remnawave",
"remnaWaveConfigUuid": "UUID پیکربندی", "remnaWaveConfigUuid": "UUID پیکربندی",
"remnaWaveConfigUuidHint": "UUID پیکربندی صفحه اشتراک از پنل RemnaWave", "remnaWaveConfigUuidHint": "UUID پیکربندی صفحه اشتراک از پنل Remnawave",
"remnaWaveMode": "نمایش برنامه‌ها از پنل RemnaWave. حالت فقط خواندنی.", "remnaWaveMode": "نمایش برنامه‌ها از پنل Remnawave. حالت فقط خواندنی.",
"remnaWaveSettings": "تنظیمات RemnaWave", "remnaWaveSettings": "تنظیمات Remnawave",
"remnaWaveToggle": "تغییر منبع RemnaWave", "remnaWaveToggle": "تغییر منبع Remnawave",
"noConfigs": "هیچ تنظیماتی وجود ندارد", "noConfigs": "هیچ تنظیماتی وجود ندارد",
"remnaWaveConnected": "RemnaWave متصل است", "remnaWaveConnected": "Remnawave متصل است",
"remnaWaveDisconnected": "RemnaWave قطع است" "remnaWaveDisconnected": "Remnawave قطع است"
}, },
"tickets": { "tickets": {
"title": "مدیریت تیکت‌ها", "title": "مدیریت تیکت‌ها",
@@ -2107,7 +2107,7 @@
"daysShort": "روز", "daysShort": "روز",
"serversTitle": "سرورها", "serversTitle": "سرورها",
"externalSquadTitle": "گروه خارجی", "externalSquadTitle": "گروه خارجی",
"externalSquadHint": "یک گروه خارجی RemnaWave برای کاربران این تعرفه تعیین کنید. هنگام ایجاد اشتراک، کاربر به طور خودکار به گروه انتخاب شده اضافه می‌شود.", "externalSquadHint": "یک گروه خارجی Remnawave برای کاربران این تعرفه تعیین کنید. هنگام ایجاد اشتراک، کاربر به طور خودکار به گروه انتخاب شده اضافه می‌شود.",
"noExternalSquad": "بدون گروه خارجی", "noExternalSquad": "بدون گروه خارجی",
"externalSquadUsers": "کاربران", "externalSquadUsers": "کاربران",
"serversTabHint": "سرورهای موجود در این تعرفه را انتخاب کنید.", "serversTabHint": "سرورهای موجود در این تعرفه را انتخاب کنید.",
@@ -2186,7 +2186,7 @@
"sync": "همگام‌سازی", "sync": "همگام‌سازی",
"syncing": "در حال همگام‌سازی...", "syncing": "در حال همگام‌سازی...",
"syncNow": "همگام‌سازی الان", "syncNow": "همگام‌سازی الان",
"noServers": "سروری نیست. با RemnaWave همگام‌سازی کنید.", "noServers": "سروری نیست. با Remnawave همگام‌سازی کنید.",
"edit": "ویرایش سرور", "edit": "ویرایش سرور",
"originalName": "نام اصلی", "originalName": "نام اصلی",
"displayName": "نام نمایشی", "displayName": "نام نمایشی",
@@ -3138,7 +3138,7 @@
"connected": "متصل", "connected": "متصل",
"disconnected": "تنظیم نشده", "disconnected": "تنظیم نشده",
"noData": "بارگذاری داده‌ها ناموفق بود", "noData": "بارگذاری داده‌ها ناموفق بود",
"title": "RemnaWave", "title": "Remnawave",
"subtitle": "مدیریت پنل و آمار", "subtitle": "مدیریت پنل و آمار",
"tabs": { "tabs": {
"overview": "نمای کلی", "overview": "نمای کلی",
@@ -3246,9 +3246,9 @@
"success": "موفق", "success": "موفق",
"runAutoSyncNow": "اجرای همگام‌سازی خودکار", "runAutoSyncNow": "اجرای همگام‌سازی خودکار",
"fromPanel": "از پنل", "fromPanel": "از پنل",
"fromPanelDesc": "وارد کردن کاربران از RemnaWave به ربات", "fromPanelDesc": "وارد کردن کاربران از Remnawave به ربات",
"toPanel": "به پنل", "toPanel": "به پنل",
"toPanelDesc": "صادر کردن کاربران از ربات به RemnaWave" "toPanelDesc": "صادر کردن کاربران از ربات به Remnawave"
} }
}, },
"theme": { "theme": {
@@ -3824,7 +3824,7 @@
"deleteUser": "حذف کاربران" "deleteUser": "حذف کاربران"
}, },
"deleteSubscription": { "deleteSubscription": {
"warning": "اشتراک‌های انتخاب شده برای همیشه از ربات و پنل RemnaWave حذف خواهند شد!", "warning": "اشتراک‌های انتخاب شده برای همیشه از ربات و پنل Remnawave حذف خواهند شد!",
"hint": "کاربران دسترسی VPN اشتراک‌های حذف شده را از دست خواهند داد. این عملیات قابل بازگشت نیست.", "hint": "کاربران دسترسی VPN اشتراک‌های حذف شده را از دست خواهند داد. این عملیات قابل بازگشت نیست.",
"activePaidWarning": "{{count}} از {{total}} اشتراک انتخاب‌شده اشتراک‌های فعال پرداختی هستند", "activePaidWarning": "{{count}} از {{total}} اشتراک انتخاب‌شده اشتراک‌های فعال پرداختی هستند",
"forceDeleteConfirm": "حذف اشتراک‌های فعال پرداختی را تأیید می‌کنم" "forceDeleteConfirm": "حذف اشتراک‌های فعال پرداختی را تأیید می‌کنم"
@@ -3832,7 +3832,7 @@
"deleteUser": { "deleteUser": {
"warning": "کاربران انتخاب شده برای همیشه از ربات حذف خواهند شد! تمام اشتراک‌ها، موجودی و داده‌های آن‌ها از بین خواهد رفت!", "warning": "کاربران انتخاب شده برای همیشه از ربات حذف خواهند شد! تمام اشتراک‌ها، موجودی و داده‌های آن‌ها از بین خواهد رفت!",
"hint": "این عملیات قابل بازگشت نیست. کاربران باید ربات را دوباره راه‌اندازی کنند تا حساب جدید بسازند.", "hint": "این عملیات قابل بازگشت نیست. کاربران باید ربات را دوباره راه‌اندازی کنند تا حساب جدید بسازند.",
"deleteFromPanel": "همچنین از پنل RemnaWave حذف شود" "deleteFromPanel": "همچنین از پنل Remnawave حذف شود"
}, },
"params": { "params": {
"days": "تعداد روز", "days": "تعداد روز",

View File

@@ -1229,7 +1229,7 @@
"broadcasts": "Рассылки", "broadcasts": "Рассылки",
"users": "Пользователи", "users": "Пользователи",
"payments": "Платежи", "payments": "Платежи",
"remnawave": "RemnaWave", "remnawave": "Remnawave",
"emailTemplates": "Email-шаблоны", "emailTemplates": "Email-шаблоны",
"paymentMethods": "Платёжные методы", "paymentMethods": "Платёжные методы",
"campaigns": "Кампании", "campaigns": "Кампании",
@@ -1627,7 +1627,7 @@
"notFound": "Метод оплаты не найден" "notFound": "Метод оплаты не найден"
}, },
"remnawave": { "remnawave": {
"title": "RemnaWave", "title": "Remnawave",
"subtitle": "Управление панелью и статистика", "subtitle": "Управление панелью и статистика",
"connected": "Подключено", "connected": "Подключено",
"disconnected": "Не настроено", "disconnected": "Не настроено",
@@ -1645,6 +1645,7 @@
"totalUpload": "Отдача", "totalUpload": "Отдача",
"totalTraffic": "Всего", "totalTraffic": "Всего",
"online": "онлайн", "online": "онлайн",
"realtimeTitle": "Текущий трафик",
"inbounds": "Inbounds", "inbounds": "Inbounds",
"outbounds": "Outbounds" "outbounds": "Outbounds"
}, },
@@ -1744,9 +1745,9 @@
"success": "Успешно", "success": "Успешно",
"runAutoSyncNow": "Запустить автосинхронизацию", "runAutoSyncNow": "Запустить автосинхронизацию",
"fromPanel": "Из панели", "fromPanel": "Из панели",
"fromPanelDesc": "Импорт пользователей из RemnaWave в бота", "fromPanelDesc": "Импорт пользователей из Remnawave в бота",
"toPanel": "В панель", "toPanel": "В панель",
"toPanelDesc": "Экспорт пользователей из бота в RemnaWave" "toPanelDesc": "Экспорт пользователей из бота в Remnawave"
} }
}, },
"wheel": { "wheel": {
@@ -2145,7 +2146,7 @@
"db_sqlite": "SQLite", "db_sqlite": "SQLite",
"db_redis": "Redis", "db_redis": "Redis",
"sys_core": "Ядро и отладка", "sys_core": "Ядро и отладка",
"sys_remnawave": "RemnaWave", "sys_remnawave": "Remnawave",
"sys_webapi": "Web API", "sys_webapi": "Web API",
"sys_webhook": "Webhook", "sys_webhook": "Webhook",
"sys_server": "Статус сервера", "sys_server": "Статус сервера",
@@ -2355,8 +2356,8 @@
"Api Key": "API ключ", "Api Key": "API ключ",
"Api Token": "API токен", "Api Token": "API токен",
"Webhook Secret": "Секрет вебхука", "Webhook Secret": "Секрет вебхука",
"Remnawave Url": "URL RemnaWave", "Remnawave Url": "URL Remnawave",
"Remnawave Token": "Токен RemnaWave", "Remnawave Token": "Токен Remnawave",
"Server Status Enabled": "Статус сервера", "Server Status Enabled": "Статус сервера",
"Monitoring Enabled": "Мониторинг", "Monitoring Enabled": "Мониторинг",
"Backup Enabled": "Бэкап", "Backup Enabled": "Бэкап",
@@ -2646,7 +2647,7 @@
"SQLITE": "SQLite", "SQLITE": "SQLite",
"REDIS": "Redis", "REDIS": "Redis",
"CORE": "Бот", "CORE": "Бот",
"REMNAWAVE": "RemnaWave", "REMNAWAVE": "Remnawave",
"SERVER_STATUS": "Статус сервера", "SERVER_STATUS": "Статус сервера",
"MONITORING": "Мониторинг", "MONITORING": "Мониторинг",
"MAINTENANCE": "Обслуживание", "MAINTENANCE": "Обслуживание",
@@ -2765,16 +2766,16 @@
"importError": "Неверный формат JSON файла", "importError": "Неверный формат JSON файла",
"importCreateError": "Ошибка при создании приложений", "importCreateError": "Ошибка при создании приложений",
"noAppsToImport": "Нет приложений для импорта", "noAppsToImport": "Нет приложений для импорта",
"remnaWaveToggle": "Переключить источник RemnaWave", "remnaWaveToggle": "Переключить источник Remnawave",
"refreshRemnaWave": "Обновить данные из RemnaWave", "refreshRemnaWave": "Обновить данные из Remnawave",
"remnaWaveMode": "Отображаются приложения из панели RemnaWave. Режим только для чтения.", "remnaWaveMode": "Отображаются приложения из панели Remnawave. Режим только для чтения.",
"remnaWaveSettings": "Настройки RemnaWave", "remnaWaveSettings": "Настройки Remnawave",
"remnaWaveConfigUuid": "UUID конфигурации", "remnaWaveConfigUuid": "UUID конфигурации",
"remnaWaveConfigUuidHint": "UUID конфигурации страницы подписки из панели RemnaWave", "remnaWaveConfigUuidHint": "UUID конфигурации страницы подписки из панели Remnawave",
"availableConfigs": "Доступные конфигурации", "availableConfigs": "Доступные конфигурации",
"noConfigs": "Нет настроенных конфигов", "noConfigs": "Нет настроенных конфигов",
"remnaWaveConnected": "RemnaWave подключён", "remnaWaveConnected": "Remnawave подключён",
"remnaWaveDisconnected": "RemnaWave отключён" "remnaWaveDisconnected": "Remnawave отключён"
}, },
"tickets": { "tickets": {
"title": "Управление тикетами", "title": "Управление тикетами",
@@ -2926,7 +2927,7 @@
"daysShort": "дн.", "daysShort": "дн.",
"serversTitle": "Серверы", "serversTitle": "Серверы",
"externalSquadTitle": "Внешний сквад", "externalSquadTitle": "Внешний сквад",
"externalSquadHint": "Назначьте внешний сквад RemnaWave для пользователей этого тарифа. При создании подписки пользователь будет автоматически добавлен в выбранный сквад.", "externalSquadHint": "Назначьте внешний сквад Remnawave для пользователей этого тарифа. При создании подписки пользователь будет автоматически добавлен в выбранный сквад.",
"noExternalSquad": "Без внешнего сквада", "noExternalSquad": "Без внешнего сквада",
"externalSquadUsers": "пользователей", "externalSquadUsers": "пользователей",
"serversTabHint": "Выберите серверы, доступные на этом тарифе.", "serversTabHint": "Выберите серверы, доступные на этом тарифе.",
@@ -3005,7 +3006,7 @@
"sync": "Синхронизировать", "sync": "Синхронизировать",
"syncing": "Синхронизация...", "syncing": "Синхронизация...",
"syncNow": "Синхронизировать сейчас", "syncNow": "Синхронизировать сейчас",
"noServers": "Нет серверов. Синхронизируйте с RemnaWave.", "noServers": "Нет серверов. Синхронизируйте с Remnawave.",
"edit": "Редактировать сервер", "edit": "Редактировать сервер",
"originalName": "Оригинальное название", "originalName": "Оригинальное название",
"displayName": "Отображаемое название", "displayName": "Отображаемое название",
@@ -3976,7 +3977,7 @@
"deleteUser": "Удалить пользователей" "deleteUser": "Удалить пользователей"
}, },
"deleteSubscription": { "deleteSubscription": {
"warning": "Выбранные подписки будут безвозвратно удалены из бота и панели RemnaWave!", "warning": "Выбранные подписки будут безвозвратно удалены из бота и панели Remnawave!",
"hint": "Пользователи потеряют доступ к VPN по удалённым подпискам. Это действие нельзя отменить.", "hint": "Пользователи потеряют доступ к VPN по удалённым подпискам. Это действие нельзя отменить.",
"activePaidWarning": "{{count}} из {{total}} выбранных подписок: активные платные", "activePaidWarning": "{{count}} из {{total}} выбранных подписок: активные платные",
"forceDeleteConfirm": "Подтверждаю удаление активных платных подписок" "forceDeleteConfirm": "Подтверждаю удаление активных платных подписок"
@@ -3984,7 +3985,7 @@
"deleteUser": { "deleteUser": {
"warning": "Выбранные пользователи будут безвозвратно удалены из бота! Все их подписки, баланс и данные будут потеряны!", "warning": "Выбранные пользователи будут безвозвратно удалены из бота! Все их подписки, баланс и данные будут потеряны!",
"hint": "Это действие нельзя отменить. Пользователям придётся заново запустить бота для создания нового аккаунта.", "hint": "Это действие нельзя отменить. Пользователям придётся заново запустить бота для создания нового аккаунта.",
"deleteFromPanel": "Также удалить из панели RemnaWave" "deleteFromPanel": "Также удалить из панели Remnawave"
}, },
"params": { "params": {
"days": "Количество дней", "days": "Количество дней",

View File

@@ -1086,7 +1086,7 @@
"promoOffers": "促销优惠", "promoOffers": "促销优惠",
"promocodes": "促销码", "promocodes": "促销码",
"promoGroups": "折扣组", "promoGroups": "折扣组",
"remnawave": "RemnaWave", "remnawave": "Remnawave",
"users": "用户", "users": "用户",
"trafficUsage": "流量使用", "trafficUsage": "流量使用",
"updates": "更新", "updates": "更新",
@@ -1873,7 +1873,7 @@
"db_sqlite": "SQLite", "db_sqlite": "SQLite",
"db_redis": "Redis", "db_redis": "Redis",
"sys_core": "核心与调试", "sys_core": "核心与调试",
"sys_remnawave": "RemnaWave", "sys_remnawave": "Remnawave",
"sys_webapi": "Web API", "sys_webapi": "Web API",
"sys_webhook": "Webhook", "sys_webhook": "Webhook",
"sys_server": "服务器状态", "sys_server": "服务器状态",
@@ -1991,12 +1991,12 @@
"importError": "无效的JSON文件格式", "importError": "无效的JSON文件格式",
"importPreview": "导入预览", "importPreview": "导入预览",
"noAppsToImport": "没有可导入的应用", "noAppsToImport": "没有可导入的应用",
"refreshRemnaWave": "从RemnaWave刷新", "refreshRemnaWave": "从Remnawave刷新",
"remnaWaveConfigUuid": "配置UUID", "remnaWaveConfigUuid": "配置UUID",
"remnaWaveConfigUuidHint": "来自RemnaWave面板的订阅页面配置UUID", "remnaWaveConfigUuidHint": "来自Remnawave面板的订阅页面配置UUID",
"remnaWaveMode": "显示来自RemnaWave面板的应用。只读模式。", "remnaWaveMode": "显示来自Remnawave面板的应用。只读模式。",
"remnaWaveSettings": "RemnaWave设置", "remnaWaveSettings": "Remnawave设置",
"remnaWaveToggle": "切换RemnaWave源", "remnaWaveToggle": "切换Remnawave源",
"tabs": { "tabs": {
"basic": "基本", "basic": "基本",
"installation": "安装", "installation": "安装",
@@ -2005,8 +2005,8 @@
"additional": "附加" "additional": "附加"
}, },
"noConfigs": "未配置任何配置", "noConfigs": "未配置任何配置",
"remnaWaveConnected": "RemnaWave 已连接", "remnaWaveConnected": "Remnawave 已连接",
"remnaWaveDisconnected": "RemnaWave 未连接" "remnaWaveDisconnected": "Remnawave 未连接"
}, },
"tickets": { "tickets": {
"title": "工单管理", "title": "工单管理",
@@ -2147,7 +2147,7 @@
"daysShort": "天", "daysShort": "天",
"serversTitle": "服务器", "serversTitle": "服务器",
"externalSquadTitle": "外部小组", "externalSquadTitle": "外部小组",
"externalSquadHint": "为此套餐的用户分配RemnaWave外部小组。创建订阅时用户将自动添加到所选小组。", "externalSquadHint": "为此套餐的用户分配Remnawave外部小组。创建订阅时用户将自动添加到所选小组。",
"noExternalSquad": "无外部小组", "noExternalSquad": "无外部小组",
"externalSquadUsers": "用户", "externalSquadUsers": "用户",
"serversTabHint": "选择此套餐上可用的服务器。", "serversTabHint": "选择此套餐上可用的服务器。",
@@ -2226,7 +2226,7 @@
"sync": "同步", "sync": "同步",
"syncing": "同步中...", "syncing": "同步中...",
"syncNow": "立即同步", "syncNow": "立即同步",
"noServers": "暂无服务器。请与RemnaWave同步。", "noServers": "暂无服务器。请与Remnawave同步。",
"edit": "编辑服务器", "edit": "编辑服务器",
"originalName": "原始名称", "originalName": "原始名称",
"displayName": "显示名称", "displayName": "显示名称",
@@ -3146,7 +3146,7 @@
"connected": "已连接", "connected": "已连接",
"disconnected": "未配置", "disconnected": "未配置",
"noData": "加载数据失败", "noData": "加载数据失败",
"title": "RemnaWave", "title": "Remnawave",
"subtitle": "面板管理和统计", "subtitle": "面板管理和统计",
"tabs": { "tabs": {
"overview": "概览", "overview": "概览",
@@ -3254,9 +3254,9 @@
"success": "成功", "success": "成功",
"runAutoSyncNow": "立即运行自动同步", "runAutoSyncNow": "立即运行自动同步",
"fromPanel": "从面板", "fromPanel": "从面板",
"fromPanelDesc": "从RemnaWave导入用户到机器人", "fromPanelDesc": "从Remnawave导入用户到机器人",
"toPanel": "到面板", "toPanel": "到面板",
"toPanelDesc": "从机器人导出用户到RemnaWave" "toPanelDesc": "从机器人导出用户到Remnawave"
} }
}, },
"roles": { "roles": {
@@ -3823,7 +3823,7 @@
"deleteUser": "删除用户" "deleteUser": "删除用户"
}, },
"deleteSubscription": { "deleteSubscription": {
"warning": "选中的订阅将从机器人和RemnaWave面板中永久删除", "warning": "选中的订阅将从机器人和Remnawave面板中永久删除",
"hint": "用户将失去已删除订阅的VPN访问权限。此操作无法撤销。", "hint": "用户将失去已删除订阅的VPN访问权限。此操作无法撤销。",
"activePaidWarning": "所选订阅中有 {{count}} / {{total}} 是有效的付费订阅", "activePaidWarning": "所选订阅中有 {{count}} / {{total}} 是有效的付费订阅",
"forceDeleteConfirm": "我确认删除有效的付费订阅" "forceDeleteConfirm": "我确认删除有效的付费订阅"
@@ -3831,7 +3831,7 @@
"deleteUser": { "deleteUser": {
"warning": "选中的用户将从机器人中永久删除!他们的所有订阅、余额和数据将丢失!", "warning": "选中的用户将从机器人中永久删除!他们的所有订阅、余额和数据将丢失!",
"hint": "此操作无法撤销。用户需要重新启动机器人以创建新账户。", "hint": "此操作无法撤销。用户需要重新启动机器人以创建新账户。",
"deleteFromPanel": "同时从RemnaWave面板删除" "deleteFromPanel": "同时从Remnawave面板删除"
}, },
"params": { "params": {
"days": "天数", "days": "天数",

View File

@@ -3,6 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { adminAppsApi } from '../api/adminApps'; import { adminAppsApi } from '../api/adminApps';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
import { BackIcon } from '@/components/icons';
export default function AdminApps() { export default function AdminApps() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -10,7 +11,7 @@ export default function AdminApps() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { capabilities } = usePlatform(); const { capabilities } = usePlatform();
// RemnaWave status // Remnawave status
const { data: status } = useQuery({ const { data: status } = useQuery({
queryKey: ['remnawave-status'], queryKey: ['remnawave-status'],
queryFn: adminAppsApi.getRemnaWaveStatus, queryFn: adminAppsApi.getRemnaWaveStatus,
@@ -45,15 +46,7 @@ export default function AdminApps() {
onClick={() => navigate('/admin')} 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" 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"
> >
<svg <BackIcon className="h-5 w-5 text-dark-400" />
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
</button> </button>
)} )}
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('admin.apps.title')}</h1> <h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">{t('admin.apps.title')}</h1>
@@ -67,8 +60,8 @@ export default function AdminApps() {
/> />
<span className="text-sm font-medium text-dark-200"> <span className="text-sm font-medium text-dark-200">
{status?.enabled {status?.enabled
? t('admin.apps.remnaWaveConnected', 'RemnaWave connected') ? t('admin.apps.remnaWaveConnected', 'Remnawave connected')
: t('admin.apps.remnaWaveDisconnected', 'RemnaWave not connected')} : t('admin.apps.remnaWaveDisconnected', 'Remnawave not connected')}
</span> </span>
</div> </div>
{status?.config_uuid && ( {status?.config_uuid && (

View File

@@ -6,78 +6,14 @@ import type { TFunction } from 'i18next';
import { rbacApi, AuditLogEntry, AuditLogFilters } from '@/api/rbac'; import { rbacApi, AuditLogEntry, AuditLogFilters } from '@/api/rbac';
import { PermissionGate } from '@/components/auth/PermissionGate'; import { PermissionGate } from '@/components/auth/PermissionGate';
import { usePlatform } from '@/platform/hooks/usePlatform'; import { usePlatform } from '@/platform/hooks/usePlatform';
import {
// === Icons === BackIcon,
ChevronDownIcon,
const BackIcon = () => ( DownloadIcon,
<svg FilterIcon,
className="h-5 w-5 text-dark-400" RefreshIcon,
fill="none" SearchIcon,
viewBox="0 0 24 24" } from '@/components/icons';
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const DownloadIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
);
const FilterIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z"
/>
</svg>
);
const ChevronDownIcon = ({ className }: { className?: string }) => (
<svg
className={className || 'h-4 w-4'}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
const RefreshIcon = ({ className }: { className?: string }) => (
<svg
className={className || 'h-4 w-4'}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182"
/>
</svg>
);
const SearchIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
);
// === Constants === // === Constants ===

View File

@@ -20,137 +20,24 @@ import {
type BanHealthResponse, type BanHealthResponse,
} from '../api/banSystem'; } from '../api/banSystem';
// Icons import {
const ShieldIcon = () => ( ShieldIcon,
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> UsersIcon,
<path BanIcon,
strokeLinecap="round" ServerIcon,
strokeLinejoin="round" AgentIcon,
d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" WarningIcon,
/> RefreshIcon,
</svg> ChartIcon,
); SearchIcon,
SettingsIcon,
const UsersIcon = () => ( TrafficIcon,
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> ReportIcon,
<path HealthIcon,
strokeLinecap="round" ExclamationIcon,
strokeLinejoin="round" BackIcon,
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" XIcon,
/> } from '@/components/icons';
</svg>
);
const BanIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
);
const ServerIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
/>
</svg>
);
const AgentIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.348 14.651a3.75 3.75 0 010-5.303m5.304 0a3.75 3.75 0 010 5.303m-7.425 2.122a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.808-3.808-9.98 0-13.789m13.788 0c3.808 3.808 3.808 9.981 0 13.79M12 12h.008v.007H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</svg>
);
const WarningIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
);
const RefreshIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
const ChartIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
);
const SearchIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
);
const SettingsIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
const TrafficIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 7.5L7.5 3m0 0L12 7.5M7.5 3v13.5m13.5 0L16.5 21m0 0L12 16.5m4.5 4.5V7.5"
/>
</svg>
);
const ReportIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z"
/>
</svg>
);
const HealthIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12z"
/>
</svg>
);
type TabType = type TabType =
| 'dashboard' | 'dashboard'
@@ -475,7 +362,11 @@ export default function AdminBanSystem() {
}; };
const tabs = [ const tabs = [
{ id: 'dashboard' as TabType, label: t('banSystem.tabs.dashboard'), icon: <ChartIcon /> }, {
id: 'dashboard' as TabType,
label: t('banSystem.tabs.dashboard'),
icon: <ChartIcon className="h-5 w-5" />,
},
{ id: 'users' as TabType, label: t('banSystem.tabs.users'), icon: <UsersIcon /> }, { id: 'users' as TabType, label: t('banSystem.tabs.users'), icon: <UsersIcon /> },
{ id: 'punishments' as TabType, label: t('banSystem.tabs.punishments'), icon: <BanIcon /> }, { id: 'punishments' as TabType, label: t('banSystem.tabs.punishments'), icon: <BanIcon /> },
{ id: 'nodes' as TabType, label: t('banSystem.tabs.nodes'), icon: <ServerIcon /> }, { id: 'nodes' as TabType, label: t('banSystem.tabs.nodes'), icon: <ServerIcon /> },
@@ -505,34 +396,10 @@ export default function AdminBanSystem() {
<div className="mb-6 flex justify-center"> <div className="mb-6 flex justify-center">
<div className="relative"> <div className="relative">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-error-500/20 to-warning-500/20"> <div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-gradient-to-br from-error-500/20 to-warning-500/20">
<svg <ExclamationIcon className="h-10 w-10 text-error-400" />
className="h-10 w-10 text-error-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
/>
</svg>
</div> </div>
<div className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full border border-dark-600 bg-dark-800"> <div className="absolute -bottom-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full border border-dark-600 bg-dark-800">
<svg <SettingsIcon className="h-3.5 w-3.5 text-dark-400" />
className="h-3.5 w-3.5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
</svg>
</div> </div>
</div> </div>
</div> </div>
@@ -566,19 +433,7 @@ export default function AdminBanSystem() {
onClick={() => window.history.back()} 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" 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"
> >
<svg <BackIcon className="h-5 w-5" />
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"
/>
</svg>
{t('common.back')} {t('common.back')}
</button> </button>
</div> </div>
@@ -601,7 +456,7 @@ export default function AdminBanSystem() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<AdminBackButton /> <AdminBackButton />
<div className="rounded-xl bg-error-500/20 p-3"> <div className="rounded-xl bg-error-500/20 p-3">
<ShieldIcon /> <ShieldIcon className="h-6 w-6" />
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold text-dark-100">{t('banSystem.title')}</h1> <h1 className="text-2xl font-bold text-dark-100">{t('banSystem.title')}</h1>
@@ -613,7 +468,7 @@ export default function AdminBanSystem() {
disabled={loading} 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" 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"
> >
<RefreshIcon /> <RefreshIcon className="h-5 w-5" />
{t('common.refresh')} {t('common.refresh')}
</button> </button>
</div> </div>
@@ -683,7 +538,7 @@ export default function AdminBanSystem() {
<StatCard <StatCard
title={t('banSystem.stats.totalRequests')} title={t('banSystem.stats.totalRequests')}
value={stats.total_requests.toLocaleString()} value={stats.total_requests.toLocaleString()}
icon={<ChartIcon />} icon={<ChartIcon className="h-5 w-5" />}
color="accent" color="accent"
/> />
<StatCard <StatCard
@@ -699,7 +554,7 @@ export default function AdminBanSystem() {
<StatCard <StatCard
title={t('banSystem.stats.uptime')} title={t('banSystem.stats.uptime')}
value={formatUptime(stats.uptime_seconds)} value={formatUptime(stats.uptime_seconds)}
icon={<ChartIcon />} icon={<ChartIcon className="h-5 w-5" />}
color="info" color="info"
/> />
</div> </div>
@@ -930,7 +785,7 @@ export default function AdminBanSystem() {
<StatCard <StatCard
title={t('banSystem.agents.totalSent')} title={t('banSystem.agents.totalSent')}
value={agents.summary.total_sent.toLocaleString()} value={agents.summary.total_sent.toLocaleString()}
icon={<ChartIcon />} icon={<ChartIcon className="h-5 w-5" />}
color="accent" color="accent"
/> />
<StatCard <StatCard
@@ -1556,14 +1411,7 @@ export default function AdminBanSystem() {
aria-label={t('common.close')} aria-label={t('common.close')}
className="text-dark-400 hover:text-dark-200" className="text-dark-400 hover:text-dark-200"
> >
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <XIcon className="h-5 w-5" />
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button> </button>
</div> </div>
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">

View File

@@ -11,95 +11,18 @@ import {
} from '../api/adminBroadcasts'; } from '../api/adminBroadcasts';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { TelegramPreview, EmailPreview } from '../components/broadcasts/BroadcastPreview'; import { TelegramPreview, EmailPreview } from '../components/broadcasts/BroadcastPreview';
import {
// Icons BroadcastIcon,
const BroadcastIcon = () => ( ChevronDownIcon,
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> DocumentIcon,
<path EmailIcon,
strokeLinecap="round" PhotoIcon,
strokeLinejoin="round" RefreshIcon,
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" TelegramIcon,
/> UsersIcon,
</svg> VideoIcon,
); XIcon,
} from '@/components/icons';
const XIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const RefreshIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
const PhotoIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</svg>
);
const VideoIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z"
/>
</svg>
);
const DocumentIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
);
const UsersIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
);
const ChevronDownIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
const TelegramIcon = () => (
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
);
const EmailIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
);
// Filter labels // Filter labels
const FILTER_GROUP_LABEL_KEYS: Record<string, string> = { const FILTER_GROUP_LABEL_KEYS: Record<string, string> = {
@@ -173,7 +96,10 @@ export default function AdminBroadcastCreate() {
if (selectedButtons.length > 0) { if (selectedButtons.length > 0) {
const presetLabels: Record<string, string> = { const presetLabels: Record<string, string> = {
balance: t('admin.broadcasts.btnBalance', 'Пополнить баланс'), 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', 'Промокод'), promocode: t('admin.broadcasts.btnPromocode', 'Промокод'),
connect: t('admin.broadcasts.btnConnect', 'Подключиться'), connect: t('admin.broadcasts.btnConnect', 'Подключиться'),
subscription: t('admin.broadcasts.btnSubscription', 'Подписка'), subscription: t('admin.broadcasts.btnSubscription', 'Подписка'),
@@ -538,7 +464,7 @@ export default function AdminBroadcastCreate() {
</span> </span>
)} )}
</div> </div>
<ChevronDownIcon /> <ChevronDownIcon className="h-4 w-4" />
</button> </button>
{showFilters && ( {showFilters && (
@@ -581,7 +507,7 @@ export default function AdminBroadcastCreate() {
<AdminBackButton /> <AdminBackButton />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400"> <div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
<BroadcastIcon /> <BroadcastIcon className="h-6 w-6" />
</div> </div>
<div> <div>
<h1 className="text-xl font-bold text-dark-100">{t('admin.broadcasts.create')}</h1> <h1 className="text-xl font-bold text-dark-100">{t('admin.broadcasts.create')}</h1>
@@ -727,7 +653,7 @@ export default function AdminBroadcastCreate() {
className="rounded-lg p-2 text-dark-400 hover:bg-dark-700 hover:text-error-400" className="rounded-lg p-2 text-dark-400 hover:bg-dark-700 hover:text-error-400"
disabled={isUploading} disabled={isUploading}
> >
<XIcon /> <XIcon className="h-5 w-5" />
</button> </button>
</div> </div>
{mediaPreview && ( {mediaPreview && (
@@ -813,7 +739,7 @@ export default function AdminBroadcastCreate() {
onClick={() => removeCustomButton(index)} onClick={() => removeCustomButton(index)}
className="ml-2 shrink-0 rounded p-1 text-dark-400 hover:bg-dark-700 hover:text-error-400" className="ml-2 shrink-0 rounded p-1 text-dark-400 hover:bg-dark-700 hover:text-error-400"
> >
<XIcon /> <XIcon className="h-5 w-5" />
</button> </button>
</div> </div>
))} ))}
@@ -1013,7 +939,7 @@ export default function AdminBroadcastCreate() {
disabled={!isValid || isPending || isUploading} disabled={!isValid || isPending || isUploading}
className="btn-primary flex items-center gap-2" className="btn-primary flex items-center gap-2"
> >
{isPending ? <RefreshIcon /> : <BroadcastIcon />} {isPending ? <RefreshIcon /> : <BroadcastIcon className="h-6 w-6" />}
{t('admin.broadcasts.send')} {t('admin.broadcasts.send')}
</button> </button>
</div> </div>

View File

@@ -3,74 +3,15 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts'; import { adminBroadcastsApi, type BroadcastChannel } from '../api/adminBroadcasts';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import {
// Icons DocumentIcon,
EmailIcon,
const StopIcon = () => ( PhotoIcon,
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> RefreshIcon,
<path StopIcon,
strokeLinecap="round" TelegramIcon,
strokeLinejoin="round" VideoIcon,
d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z" } from '@/components/icons';
/>
</svg>
);
const PhotoIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</svg>
);
const VideoIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z"
/>
</svg>
);
const DocumentIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
);
const RefreshIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
const TelegramIcon = () => (
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
);
const EmailIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
);
// Channel badge component // Channel badge component
function ChannelBadge({ channel }: { channel?: BroadcastChannel }) { function ChannelBadge({ channel }: { channel?: BroadcastChannel }) {
@@ -240,7 +181,7 @@ export default function AdminBroadcastDetail() {
onClick={() => refetch()} onClick={() => refetch()}
className="rounded-lg p-2 transition-colors hover:bg-dark-700" className="rounded-lg p-2 transition-colors hover:bg-dark-700"
> >
<RefreshIcon /> <RefreshIcon className="h-5 w-5" />
</button> </button>
</div> </div>

View File

@@ -4,76 +4,15 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { adminBroadcastsApi } from '../api/adminBroadcasts'; import { adminBroadcastsApi } from '../api/adminBroadcasts';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
import {
// Icons BackIcon,
BroadcastIcon,
const BackIcon = () => ( DocumentIcon,
<svg PhotoIcon,
className="h-5 w-5 text-dark-400" PlusIcon,
fill="none" RefreshIcon,
viewBox="0 0 24 24" VideoIcon,
stroke="currentColor" } from '@/components/icons';
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const BroadcastIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
/>
</svg>
);
const PlusIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
const RefreshIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
const PhotoIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</svg>
);
const VideoIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z"
/>
</svg>
);
const DocumentIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
);
// Status badge component // Status badge component
const statusConfig: Record<string, { bg: string; text: string; labelKey: string }> = { const statusConfig: Record<string, { bg: string; text: string; labelKey: string }> = {
@@ -194,7 +133,7 @@ export default function AdminBroadcasts() {
</div> </div>
) : broadcasts.length === 0 ? ( ) : broadcasts.length === 0 ? (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400"> <div className="rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400">
<BroadcastIcon /> <BroadcastIcon className="h-6 w-6" />
<p className="mt-2">{t('admin.broadcasts.empty')}</p> <p className="mt-2">{t('admin.broadcasts.empty')}</p>
</div> </div>
) : ( ) : (

View File

@@ -20,9 +20,18 @@ import {
type BulkActionType, type BulkActionType,
type BulkActionParams, type BulkActionParams,
} from '../api/adminBulkActions'; } from '../api/adminBulkActions';
import { PiCaretDown } from 'react-icons/pi';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
import { cn } from '@/lib/utils'; 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 { ActionModal, type ModalState } from '@/components/admin/bulkActions/ActionModal';
import { DropdownSelect, type DropdownOption } from '@/components/admin/bulkActions/DropdownSelect'; import { DropdownSelect, type DropdownOption } from '@/components/admin/bulkActions/DropdownSelect';
import { FloatingActionBar } from '@/components/admin/bulkActions/FloatingActionBar'; import { FloatingActionBar } from '@/components/admin/bulkActions/FloatingActionBar';
@@ -41,77 +50,15 @@ type SubscriptionStatusFilter = '' | 'active' | 'expired' | 'trial' | 'limited'
// (ProgressState + ModalState moved into <ActionModal>; ModalState is re-exported.) // (ProgressState + ModalState moved into <ActionModal>; 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 = () => (
<svg
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={3}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
// ============ Icons ============ // ============ Icons ============
const BackIcon = () => ( // ChevronExpandIcon keeps a custom `expanded` prop (not the barrel's
<svg // className-only API), so it stays local as a thin wrapper over the panel's
className="h-5 w-5 text-dark-400" // Phosphor caret, preserving the rotate-on-expand behavior.
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const SearchIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
);
const ChevronLeftIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const ChevronRightIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
);
const RefreshIcon = ({ className = 'w-5 h-5' }: { className?: string }) => (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
const ChevronExpandIcon = ({ expanded }: { expanded: boolean }) => ( const ChevronExpandIcon = ({ expanded }: { expanded: boolean }) => (
<svg <PiCaretDown
className={cn('h-4 w-4 text-white transition-transform duration-200', expanded && 'rotate-180')} className={cn('h-4 w-4 text-white transition-transform duration-200', expanded && 'rotate-180')}
fill="none" />
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
); );
// (SUBSCRIPTION_LEVEL_ACTIONS + isSubscriptionLevelAction moved into ./bulkActions/actionTargets.ts) // (SUBSCRIPTION_LEVEL_ACTIONS + isSubscriptionLevelAction moved into ./bulkActions/actionTargets.ts)
@@ -632,7 +579,7 @@ export default function AdminBulkActions() {
aria-label={t('admin.bulkActions.selectAll')} aria-label={t('admin.bulkActions.selectAll')}
title={t('admin.bulkActions.selectAll')} title={t('admin.bulkActions.selectAll')}
> >
{table.getIsAllRowsSelected() && <CheckIcon />} {table.getIsAllRowsSelected() && <CheckIcon className="h-3 w-3" />}
{table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() && ( {table.getIsSomeRowsSelected() && !table.getIsAllRowsSelected() && (
<div className="h-0.5 w-2 rounded-full bg-white" /> <div className="h-0.5 w-2 rounded-full bg-white" />
)} )}
@@ -652,7 +599,7 @@ export default function AdminBulkActions() {
aria-label={t('admin.bulkActions.selectAllSubs')} aria-label={t('admin.bulkActions.selectAllSubs')}
title={t('admin.bulkActions.selectAllSubs')} title={t('admin.bulkActions.selectAllSubs')}
> >
{allSubsSelected && <CheckIcon />} {allSubsSelected && <CheckIcon className="h-3 w-3" />}
{someSubsSelected && <div className="h-0.5 w-2 rounded-full bg-white" />} {someSubsSelected && <div className="h-0.5 w-2 rounded-full bg-white" />}
</button> </button>
)} )}
@@ -678,7 +625,7 @@ export default function AdminBulkActions() {
: t('admin.bulkActions.selectUser', { name: userName }) : t('admin.bulkActions.selectUser', { name: userName })
} }
> >
{row.getIsSelected() && <CheckIcon />} {row.getIsSelected() && <CheckIcon className="h-3 w-3" />}
</button> </button>
</div> </div>
); );
@@ -988,7 +935,7 @@ export default function AdminBulkActions() {
)} )}
aria-pressed={trialOnly} aria-pressed={trialOnly}
> >
{trialOnly && <CheckIcon />} {trialOnly && <CheckIcon className="h-3 w-3" />}
</button> </button>
<span <span
className={cn('text-sm', trialOnly ? 'font-medium text-warning-400' : 'text-dark-400')} className={cn('text-sm', trialOnly ? 'font-medium text-warning-400' : 'text-dark-400')}

View File

@@ -13,32 +13,7 @@ import { partnerApi } from '../api/partners';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
import Twemoji from 'react-twemoji'; import Twemoji from 'react-twemoji';
// Icons import { CampaignIcon, CheckIcon, LinkIcon, RefreshIcon } from '@/components/icons';
const CampaignIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
/>
</svg>
);
const CheckIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
const RefreshIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
// Bonus type config // Bonus type config
const bonusTypeConfig: Record< const bonusTypeConfig: Record<
@@ -283,7 +258,7 @@ export default function AdminCampaignCreate() {
<AdminBackButton /> <AdminBackButton />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400"> <div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
<CampaignIcon /> <CampaignIcon className="h-6 w-6" />
</div> </div>
<div> <div>
<h1 className="text-xl font-bold text-dark-100"> <h1 className="text-xl font-bold text-dark-100">
@@ -298,19 +273,7 @@ export default function AdminCampaignCreate() {
{partnerId && partner && ( {partnerId && partner && (
<div className="rounded-xl border border-accent-500/20 bg-accent-500/5 p-4"> <div className="rounded-xl border border-accent-500/20 bg-accent-500/5 p-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<svg <LinkIcon className="mt-0.5 h-5 w-5 shrink-0 text-accent-400" />
className="mt-0.5 h-5 w-5 shrink-0 text-accent-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
/>
</svg>
<p className="text-sm text-accent-300"> <p className="text-sm text-accent-300">
{t('admin.campaigns.form.partnerAutoAssign', { {t('admin.campaigns.form.partnerAutoAssign', {
name: partner.first_name || partner.username || `#${partnerId}`, name: partner.first_name || partner.username || `#${partnerId}`,
@@ -556,7 +519,7 @@ export default function AdminCampaignCreate() {
disabled={!isValid || createMutation.isPending} disabled={!isValid || createMutation.isPending}
className="btn-primary flex items-center gap-2" className="btn-primary flex items-center gap-2"
> >
{createMutation.isPending ? <RefreshIcon /> : <CampaignIcon />} {createMutation.isPending ? <RefreshIcon /> : <CampaignIcon className="h-6 w-6" />}
{t('admin.campaigns.form.save')} {t('admin.campaigns.form.save')}
</button> </button>
</div> </div>

View File

@@ -10,47 +10,7 @@ import { PARTNER_STATS } from '../constants/partner';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
import { copyToClipboard } from '../utils/clipboard'; import { copyToClipboard } from '../utils/clipboard';
import { useHaptic } from '../platform'; import { useHaptic } from '../platform';
import { ChartIcon, ChevronDownIcon, CopyIcon, LinkIcon, UsersIcon } from '@/components/icons';
// Icons
const CopyIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
);
const LinkIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
/>
</svg>
);
const UsersIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
);
const ChartIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
);
// Bonus type config // Bonus type config
const bonusTypeConfig: Record< const bonusTypeConfig: Record<
@@ -205,7 +165,7 @@ export default function AdminCampaignStats() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<AdminBackButton to="/admin/campaigns" /> <AdminBackButton to="/admin/campaigns" />
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400"> <div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
<ChartIcon /> <ChartIcon className="h-6 w-6" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h1 className="truncate text-xl font-semibold text-dark-100">{stats.name}</h1> <h1 className="truncate text-xl font-semibold text-dark-100">{stats.name}</h1>
@@ -240,7 +200,7 @@ export default function AdminCampaignStats() {
</div> </div>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<LinkIcon /> <LinkIcon className="h-4 w-4" />
<span className="truncate text-sm text-dark-300">{stats.deep_link}</span> <span className="truncate text-sm text-dark-300">{stats.deep_link}</span>
</div> </div>
<button <button
@@ -264,7 +224,7 @@ export default function AdminCampaignStats() {
</div> </div>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<LinkIcon /> <LinkIcon className="h-4 w-4" />
<span className="truncate text-sm text-dark-300">{stats.web_link}</span> <span className="truncate text-sm text-dark-300">{stats.web_link}</span>
</div> </div>
<button <button
@@ -489,15 +449,9 @@ export default function AdminCampaignStats() {
{t('admin.campaigns.stats.users')} ({stats.registrations}) {t('admin.campaigns.stats.users')} ({stats.registrations})
</span> </span>
</div> </div>
<svg <ChevronDownIcon
className={`h-5 w-5 text-dark-400 transition-transform ${showUsers ? 'rotate-180' : ''}`} className={`h-5 w-5 text-dark-400 transition-transform ${showUsers ? 'rotate-180' : ''}`}
fill="none" />
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button> </button>
{showUsers && ( {showUsers && (

View File

@@ -4,7 +4,15 @@ import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tansta
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import i18n from '../i18n'; import i18n from '../i18n';
import { campaignsApi, CampaignListItem, CampaignBonusType } from '../api/campaigns'; 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 { usePlatform } from '../platform/hooks/usePlatform';
import { useFocusTrap } from '../hooks/useFocusTrap'; import { useFocusTrap } from '../hooks/useFocusTrap';
@@ -37,19 +45,6 @@ const bonusTypeConfig: Record<
}, },
}; };
// Icons
const BackIcon = () => (
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
// Locale mapping for formatting // Locale mapping for formatting
const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' }; const localeMap: Record<string, string> = { ru: 'ru-RU', en: 'en-US', zh: 'zh-CN', fa: 'fa-IR' };

View File

@@ -12,86 +12,17 @@ import {
import { adminSettingsApi, type SettingDefinition } from '../api/adminSettings'; import { adminSettingsApi, type SettingDefinition } from '../api/adminSettings';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { Toggle } from '../components/admin/Toggle'; import { Toggle } from '../components/admin/Toggle';
import {
// Icons ChannelIcon,
const ChannelIcon = () => ( PlusIcon,
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> RefreshIcon,
<path TrashIcon,
strokeLinecap="round" CheckIcon,
strokeLinejoin="round" XIcon,
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" EditIcon,
/> LinkIcon,
</svg> SettingsIcon,
); } from '@/components/icons';
const PlusIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
const RefreshIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
const TrashIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
const CheckIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
const XIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const EditIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
);
const LinkIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m9.86-2.239a4.5 4.5 0 00-1.242-7.244l-4.5-4.5a4.5 4.5 0 00-6.364 6.364L4.5 8.688"
/>
</svg>
);
const SettingsIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.241-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 010-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
// Setting toggle row for global settings // Setting toggle row for global settings
const CHANNEL_SETTING_KEYS = [ const CHANNEL_SETTING_KEYS = [
@@ -266,7 +197,7 @@ function ChannelCard({
{/* Link */} {/* Link */}
{hasLink && ( {hasLink && (
<div className="mt-1.5 flex items-center gap-1 text-xs text-accent-400"> <div className="mt-1.5 flex items-center gap-1 text-xs text-accent-400">
<LinkIcon /> <LinkIcon className="h-4 w-4" />
<a <a
href={channel.channel_link!} href={channel.channel_link!}
target="_blank" target="_blank"
@@ -352,7 +283,7 @@ function ChannelCard({
onClick={() => onDelete(channel.id)} onClick={() => onDelete(channel.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" 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"
> >
<TrashIcon /> <TrashIcon className="h-4 w-4" />
{t('admin.channelSubscriptions.delete')} {t('admin.channelSubscriptions.delete')}
</button> </button>
</div> </div>
@@ -688,7 +619,7 @@ export default function AdminChannelSubscriptions() {
<AdminBackButton /> <AdminBackButton />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400"> <div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
<ChannelIcon /> <ChannelIcon className="h-6 w-6" />
</div> </div>
<div> <div>
<h1 className="text-xl font-bold text-dark-100"> <h1 className="text-xl font-bold text-dark-100">
@@ -752,7 +683,7 @@ export default function AdminChannelSubscriptions() {
) : channels.length === 0 ? ( ) : channels.length === 0 ? (
<div className="rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400"> <div className="rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400">
<div className="mx-auto mb-2 w-fit"> <div className="mx-auto mb-2 w-fit">
<ChannelIcon /> <ChannelIcon className="h-6 w-6" />
</div> </div>
<p>{t('admin.channelSubscriptions.empty')}</p> <p>{t('admin.channelSubscriptions.empty')}</p>
</div> </div>

View File

@@ -9,152 +9,23 @@ const CABINET_VERSION = __APP_VERSION__;
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
// Icons - styled like main navigation import {
BackIcon,
const BackIcon = () => ( BanknotesIcon,
<svg ChartBarIcon,
className="h-5 w-5 text-dark-400" ChevronDownIcon,
fill="none" ExclamationIcon,
viewBox="0 0 24 24" MegaphoneIcon,
stroke="currentColor" PowerIcon,
strokeWidth={2} RefreshIcon,
> RestartIcon,
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" /> ServerIcon,
</svg> SparklesIcon,
); TagIcon,
UsersIcon,
const ServerIcon = () => ( UsersOnlineIcon,
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> WalletIcon,
<path } from '@/components/icons';
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 17.25v-.228a4.5 4.5 0 00-.12-1.03l-2.268-9.64a3.375 3.375 0 00-3.285-2.602H7.923a3.375 3.375 0 00-3.285 2.602l-2.268 9.64a4.5 4.5 0 00-.12 1.03v.228m19.5 0a3 3 0 01-3 3H5.25a3 3 0 01-3-3m19.5 0a3 3 0 00-3-3H5.25a3 3 0 00-3 3m16.5 0h.008v.008h-.008v-.008zm-3 0h.008v.008h-.008v-.008z"
/>
</svg>
);
const UsersOnlineIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
/>
</svg>
);
const WalletIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
/>
</svg>
);
const ChartBarIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
);
const SparklesIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
/>
</svg>
);
const TagIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.568 3H5.25A2.25 2.25 0 003 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 005.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 009.568 3z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6h.008v.008H6V6z" />
</svg>
);
const RefreshIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
const PowerIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.636 5.636a9 9 0 1012.728 0M12 3v9" />
</svg>
);
const RestartIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
const ChevronDownIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
const UsersIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
);
const MegaphoneIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
/>
</svg>
);
const BanknotesIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
/>
</svg>
);
const ExclamationIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
);
interface StatCardProps { interface StatCardProps {
title: string; title: string;
@@ -265,7 +136,7 @@ function NodeCard({ node, onRestart, onToggle, isLoading }: NodeCardProps) {
{hasError && ( {hasError && (
<div className="mb-3 rounded-lg border border-error-500/20 bg-error-500/10 p-2"> <div className="mb-3 rounded-lg border border-error-500/20 bg-error-500/10 p-2">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<ExclamationIcon /> <ExclamationIcon className="h-4 w-4" />
<span className="break-all text-xs text-error-400">{node.last_status_message}</span> <span className="break-all text-xs text-error-400">{node.last_status_message}</span>
</div> </div>
</div> </div>
@@ -296,7 +167,7 @@ function NodeCard({ node, onRestart, onToggle, isLoading }: NodeCardProps) {
: 'bg-warning-500/20 text-warning-400 hover:bg-warning-500/30' : 'bg-warning-500/20 text-warning-400 hover:bg-warning-500/30'
} disabled:opacity-50`} } disabled:opacity-50`}
> >
<PowerIcon /> <PowerIcon className="h-4 w-4" />
{node.is_disabled ? t('adminDashboard.nodes.enable') : t('adminDashboard.nodes.disable')} {node.is_disabled ? t('adminDashboard.nodes.enable') : t('adminDashboard.nodes.disable')}
</button> </button>
<button <button
@@ -304,7 +175,7 @@ function NodeCard({ node, onRestart, onToggle, isLoading }: NodeCardProps) {
disabled={isLoading || node.is_disabled} disabled={isLoading || node.is_disabled}
className="flex items-center justify-center gap-1.5 rounded-lg bg-accent-500/20 px-3 py-2 text-sm font-medium text-accent-400 transition-colors hover:bg-accent-500/30 disabled:opacity-50" className="flex items-center justify-center gap-1.5 rounded-lg bg-accent-500/20 px-3 py-2 text-sm font-medium text-accent-400 transition-colors hover:bg-accent-500/30 disabled:opacity-50"
> >
<RestartIcon /> <RestartIcon className="h-4 w-4" />
</button> </button>
</div> </div>
</div> </div>
@@ -464,7 +335,7 @@ export default function AdminDashboard() {
disabled={loading} 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" 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"
> >
<RefreshIcon /> <RefreshIcon className="h-5 w-5" />
{t('adminDashboard.refresh')} {t('adminDashboard.refresh')}
</button> </button>
</div> </div>

View File

@@ -13,6 +13,7 @@ import { useIsTelegram } from '../platform/hooks/usePlatform';
import { useNativeDialog } from '../platform/hooks/useNativeDialog'; import { useNativeDialog } from '../platform/hooks/useNativeDialog';
import { useNotify } from '@/platform'; import { useNotify } from '@/platform';
import { copyToClipboard } from '@/utils/clipboard'; import { copyToClipboard } from '@/utils/clipboard';
import { MailIcon, SaveIcon, EyeIcon, SendIcon, ResetIcon } from '@/components/icons';
// Hook to check if on mobile // Hook to check if on mobile
function useIsMobile() { function useIsMobile() {
@@ -30,55 +31,6 @@ function useIsMobile() {
return isMobile; return isMobile;
} }
// Icons
const MailIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
);
const SaveIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
const EyeIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
const SendIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
/>
</svg>
);
const ResetIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
const LANG_LABELS: Record<string, string> = { const LANG_LABELS: Record<string, string> = {
ru: 'RU', ru: 'RU',
en: 'EN', en: 'EN',
@@ -390,7 +342,7 @@ function TemplateEditor({
disabled={!isDirty || saveMutation.isPending} 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" 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"
> >
<SaveIcon /> <SaveIcon className="h-4 w-4" />
{saveMutation.isPending ? t('common.loading') : t('common.save')} {saveMutation.isPending ? t('common.loading') : t('common.save')}
</button> </button>
@@ -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" 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"
> >
<EyeIcon /> <EyeIcon className="h-4 w-4" />
{t('admin.emailTemplates.preview')} {t('admin.emailTemplates.preview')}
</button> </button>
</div> </div>
@@ -423,7 +375,7 @@ function TemplateEditor({
disabled={testMutation.isPending} 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" 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"
> >
<SendIcon /> <SendIcon className="h-4 w-4" />
{testMutation.isPending ? t('common.loading') : t('admin.emailTemplates.sendTest')} {testMutation.isPending ? t('common.loading') : t('admin.emailTemplates.sendTest')}
</button> </button>
@@ -437,7 +389,7 @@ function TemplateEditor({
disabled={resetMutation.isPending} 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" 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"
> >
<ResetIcon /> <ResetIcon className="h-4 w-4" />
<span className="truncate">{t('admin.emailTemplates.resetDefault')}</span> <span className="truncate">{t('admin.emailTemplates.resetDefault')}</span>
</button> </button>
)} )}
@@ -474,7 +426,7 @@ export default function AdminEmailTemplates() {
<AdminBackButton className="flex-shrink-0 rounded-xl border border-dark-700 bg-dark-800 p-1.5 transition-colors hover:bg-dark-700 sm:p-2" /> <AdminBackButton className="flex-shrink-0 rounded-xl border border-dark-700 bg-dark-800 p-1.5 transition-colors hover:bg-dark-700 sm:p-2" />
<div className="flex min-w-0 items-center gap-2 sm:gap-2.5"> <div className="flex min-w-0 items-center gap-2 sm:gap-2.5">
<div className="flex-shrink-0 rounded-xl bg-gradient-to-br from-accent-500/20 to-accent-600/10 p-1.5 text-accent-400 sm:p-2"> <div className="flex-shrink-0 rounded-xl bg-gradient-to-br from-accent-500/20 to-accent-600/10 p-1.5 text-accent-400 sm:p-2">
<MailIcon /> <MailIcon className="h-6 w-6" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h1 className="truncate text-lg font-bold text-dark-100 sm:text-xl"> <h1 className="truncate text-lg font-bold text-dark-100 sm:text-xl">

View File

@@ -18,102 +18,35 @@ import { AdminBackButton } from '../components/admin';
import { Toggle } from '../components/admin/Toggle'; import { Toggle } from '../components/admin/Toggle';
import { useHapticFeedback } from '../platform/hooks/useHaptic'; import { useHapticFeedback } from '../platform/hooks/useHaptic';
import { cn } from '../lib/utils'; 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'; import type { InfoPageType, FaqItem, ReplacesTab } from '../api/infoPages';
const AVAILABLE_LOCALES = ['ru', 'en', 'zh', 'fa'] as const; const AVAILABLE_LOCALES = ['ru', 'en', 'zh', 'fa'] as const;
type LocaleCode = (typeof AVAILABLE_LOCALES)[number]; type LocaleCode = (typeof AVAILABLE_LOCALES)[number];
// --- Icons --- // --- Icons ---
const BoldIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z" />
</svg>
);
const ItalicIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z" />
</svg>
);
const UnderlineIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z" />
</svg>
);
const StrikeIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z" />
</svg>
);
const H1Icon = () => <span className="text-xs font-bold">H1</span>; const H1Icon = () => <span className="text-xs font-bold">H1</span>;
const H2Icon = () => <span className="text-xs font-bold">H2</span>; const H2Icon = () => <span className="text-xs font-bold">H2</span>;
const H3Icon = () => <span className="text-xs font-bold">H3</span>; const H3Icon = () => <span className="text-xs font-bold">H3</span>;
const ListBulletIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z" />
</svg>
);
const ListOrderedIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
</svg>
);
const QuoteIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
</svg>
);
const CodeBlockIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z" />
</svg>
);
const ImageIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</svg>
);
const LinkIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
/>
</svg>
);
const AlignLeftIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M15 15H3v2h12v-2zm0-8H3v2h12V7zM3 13h18v-2H3v2zm0 8h18v-2H3v2zM3 3v2h18V3H3z" />
</svg>
);
const AlignCenterIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 15v2h10v-2H7zm-4 6h18v-2H3v2zm0-8h18v-2H3v2zm4-6v2h10V7H7zM3 3v2h18V3H3z" />
</svg>
);
const HighlightIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M16.56 3.44a1.5 1.5 0 012.12 0l1.88 1.88a1.5 1.5 0 010 2.12L8.44 19.56 3 21l1.44-5.44L16.56 3.44z" />
</svg>
);
// --- Toolbar Button --- // --- Toolbar Button ---
interface ToolbarButtonProps { interface ToolbarButtonProps {
onClick: () => void; onClick: () => void;
@@ -206,35 +139,6 @@ function generateSlug(title: string): string {
.substring(0, 100); .substring(0, 100);
} }
// --- FAQ Q&A Item Icons ---
const ChevronUpIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
</svg>
);
const ChevronDownIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
);
const TrashSmallIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
const PlusSmallIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
// --- FAQ Answer Rich Editor --- // --- FAQ Answer Rich Editor ---
const FAQ_EDITOR_EXTENSIONS = [ const FAQ_EDITOR_EXTENSIONS = [
@@ -415,28 +319,28 @@ function FaqAnswerEditor({ value, onChange }: { value: string; onChange: (html:
isActive={editor.isActive('bold')} isActive={editor.isActive('bold')}
title={t('news.admin.toolbar.bold')} title={t('news.admin.toolbar.bold')}
> >
<BoldIcon /> <BoldIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()} onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')} isActive={editor.isActive('italic')}
title={t('news.admin.toolbar.italic')} title={t('news.admin.toolbar.italic')}
> >
<ItalicIcon /> <ItalicIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => editor.chain().focus().toggleUnderline().run()} onClick={() => editor.chain().focus().toggleUnderline().run()}
isActive={editor.isActive('underline')} isActive={editor.isActive('underline')}
title={t('news.admin.toolbar.underline')} title={t('news.admin.toolbar.underline')}
> >
<UnderlineIcon /> <UnderlineIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()} onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive('strike')} isActive={editor.isActive('strike')}
title={t('news.admin.toolbar.strikethrough')} title={t('news.admin.toolbar.strikethrough')}
> >
<StrikeIcon /> <StrikeIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<div className="mx-0.5 h-4 w-px bg-dark-700" /> <div className="mx-0.5 h-4 w-px bg-dark-700" />
<ToolbarButton <ToolbarButton
@@ -458,14 +362,14 @@ function FaqAnswerEditor({ value, onChange }: { value: string; onChange: (html:
isActive={editor.isActive('orderedList')} isActive={editor.isActive('orderedList')}
title={t('news.admin.toolbar.orderedList')} title={t('news.admin.toolbar.orderedList')}
> >
<ListOrderedIcon /> <ListOrderedIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()} onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')} isActive={editor.isActive('blockquote')}
title={t('news.admin.toolbar.blockquote')} title={t('news.admin.toolbar.blockquote')}
> >
<QuoteIcon /> <QuoteIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<div className="mx-0.5 h-4 w-px bg-dark-700" /> <div className="mx-0.5 h-4 w-px bg-dark-700" />
<ToolbarButton <ToolbarButton
@@ -473,10 +377,10 @@ function FaqAnswerEditor({ value, onChange }: { value: string; onChange: (html:
isActive={editor.isActive('highlight')} isActive={editor.isActive('highlight')}
title={t('news.admin.toolbar.highlight')} title={t('news.admin.toolbar.highlight')}
> >
<HighlightIcon /> <HighlightIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton onClick={addLink} title={t('news.admin.toolbar.link')}> <ToolbarButton onClick={addLink} title={t('news.admin.toolbar.link')}>
<LinkIcon /> <LinkIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => mediaRef.current?.click()} onClick={() => mediaRef.current?.click()}
@@ -486,7 +390,7 @@ function FaqAnswerEditor({ value, onChange }: { value: string; onChange: (html:
{isUploading ? ( {isUploading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" />
) : ( ) : (
<ImageIcon /> <ImageIcon className="h-4 w-4" />
)} )}
</ToolbarButton> </ToolbarButton>
</div> </div>
@@ -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" 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')} title={t('admin.infoPages.faq.moveUp')}
> >
<ChevronUpIcon /> <ChevronUpIcon className="h-4 w-4" />
</button> </button>
<button <button
type="button" type="button"
@@ -669,7 +573,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" 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.moveDown')} title={t('admin.infoPages.faq.moveDown')}
> >
<ChevronDownIcon /> <ChevronDownIcon className="h-4 w-4" />
</button> </button>
<button <button
type="button" type="button"
@@ -713,7 +617,7 @@ function FaqBuilder({ items, onChange, locale, localeLabel }: FaqBuilderProps) {
onClick={handleAdd} onClick={handleAdd}
className="flex min-h-[44px] w-full items-center justify-center gap-2 rounded-xl border border-dashed border-dark-600 bg-dark-800/30 py-3 text-sm font-medium text-dark-300 transition-colors hover:border-dark-500 hover:bg-dark-800/50 hover:text-dark-100" className="flex min-h-[44px] w-full items-center justify-center gap-2 rounded-xl border border-dashed border-dark-600 bg-dark-800/30 py-3 text-sm font-medium text-dark-300 transition-colors hover:border-dark-500 hover:bg-dark-800/50 hover:text-dark-100"
> >
<PlusSmallIcon /> <PlusSmallIcon className="h-5 w-5" />
{t('admin.infoPages.faq.addQuestion')} {t('admin.infoPages.faq.addQuestion')}
</button> </button>
</div> </div>
@@ -1333,28 +1237,28 @@ export default function AdminInfoPageEditor() {
isActive={editor.isActive('bold')} isActive={editor.isActive('bold')}
title={t('news.admin.toolbar.bold')} title={t('news.admin.toolbar.bold')}
> >
<BoldIcon /> <BoldIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()} onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')} isActive={editor.isActive('italic')}
title={t('news.admin.toolbar.italic')} title={t('news.admin.toolbar.italic')}
> >
<ItalicIcon /> <ItalicIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => editor.chain().focus().toggleUnderline().run()} onClick={() => editor.chain().focus().toggleUnderline().run()}
isActive={editor.isActive('underline')} isActive={editor.isActive('underline')}
title={t('news.admin.toolbar.underline')} title={t('news.admin.toolbar.underline')}
> >
<UnderlineIcon /> <UnderlineIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()} onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive('strike')} isActive={editor.isActive('strike')}
title={t('news.admin.toolbar.strikethrough')} title={t('news.admin.toolbar.strikethrough')}
> >
<StrikeIcon /> <StrikeIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<div className="mx-1 h-5 w-px bg-dark-700" /> <div className="mx-1 h-5 w-px bg-dark-700" />
@@ -1395,21 +1299,21 @@ export default function AdminInfoPageEditor() {
isActive={editor.isActive('orderedList')} isActive={editor.isActive('orderedList')}
title={t('news.admin.toolbar.orderedList')} title={t('news.admin.toolbar.orderedList')}
> >
<ListOrderedIcon /> <ListOrderedIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()} onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')} isActive={editor.isActive('blockquote')}
title={t('news.admin.toolbar.blockquote')} title={t('news.admin.toolbar.blockquote')}
> >
<QuoteIcon /> <QuoteIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()} onClick={() => editor.chain().focus().toggleCodeBlock().run()}
isActive={editor.isActive('codeBlock')} isActive={editor.isActive('codeBlock')}
title={t('news.admin.toolbar.codeBlock')} title={t('news.admin.toolbar.codeBlock')}
> >
<CodeBlockIcon /> <CodeBlockIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<div className="mx-1 h-5 w-px bg-dark-700" /> <div className="mx-1 h-5 w-px bg-dark-700" />
@@ -1419,14 +1323,14 @@ export default function AdminInfoPageEditor() {
isActive={editor.isActive({ textAlign: 'left' })} isActive={editor.isActive({ textAlign: 'left' })}
title={t('news.admin.toolbar.alignLeft')} title={t('news.admin.toolbar.alignLeft')}
> >
<AlignLeftIcon /> <AlignLeftIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('center').run()} onClick={() => editor.chain().focus().setTextAlign('center').run()}
isActive={editor.isActive({ textAlign: 'center' })} isActive={editor.isActive({ textAlign: 'center' })}
title={t('news.admin.toolbar.alignCenter')} title={t('news.admin.toolbar.alignCenter')}
> >
<AlignCenterIcon /> <AlignCenterIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<div className="mx-1 h-5 w-px bg-dark-700" /> <div className="mx-1 h-5 w-px bg-dark-700" />
@@ -1436,10 +1340,10 @@ export default function AdminInfoPageEditor() {
isActive={editor.isActive('highlight')} isActive={editor.isActive('highlight')}
title={t('news.admin.toolbar.highlight')} title={t('news.admin.toolbar.highlight')}
> >
<HighlightIcon /> <HighlightIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton onClick={addLink} title={t('news.admin.toolbar.link')}> <ToolbarButton onClick={addLink} title={t('news.admin.toolbar.link')}>
<LinkIcon /> <LinkIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => mediaInputRef.current?.click()} onClick={() => mediaInputRef.current?.click()}
@@ -1449,7 +1353,7 @@ export default function AdminInfoPageEditor() {
{isUploading ? ( {isUploading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" />
) : ( ) : (
<ImageIcon /> <ImageIcon className="h-4 w-4" />
)} )}
</ToolbarButton> </ToolbarButton>
</div> </div>

View File

@@ -8,92 +8,11 @@ import { Toggle } from '../components/admin/Toggle';
import { useHapticFeedback } from '../platform/hooks/useHaptic'; import { useHapticFeedback } from '../platform/hooks/useHaptic';
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { FileTextIcon, PencilIcon, PlusIcon, RefreshIcon, TrashIcon } from '@/components/icons';
import type { InfoPageListItem, InfoPageType } from '../api/infoPages'; import type { InfoPageListItem, InfoPageType } from '../api/infoPages';
type FilterTab = 'all' | 'page' | 'faq'; type FilterTab = 'all' | 'page' | 'faq';
// Icons
const PlusIcon = () => (
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
const RefreshIcon = () => (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
const PencilIcon = () => (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
);
const TrashIcon = () => (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
const FileTextIcon = () => (
<svg
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
);
// --- Page Row --- // --- Page Row ---
const PageRow = memo(function PageRow({ const PageRow = memo(function PageRow({
@@ -179,7 +98,7 @@ const PageRow = memo(function PageRow({
title={t('admin.infoPages.delete')} title={t('admin.infoPages.delete')}
aria-label={t('admin.infoPages.delete')} aria-label={t('admin.infoPages.delete')}
> >
<TrashIcon /> <TrashIcon className="h-4 w-4" />
</button> </button>
</div> </div>
</div> </div>
@@ -374,7 +293,7 @@ export default function AdminInfoPages() {
</div> </div>
) : items.length === 0 ? ( ) : items.length === 0 ? (
<div className="flex flex-col items-center rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400"> <div className="flex flex-col items-center rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400">
<FileTextIcon /> <FileTextIcon className="h-6 w-6" />
<p className="mt-2">{t('admin.infoPages.noPages')}</p> <p className="mt-2">{t('admin.infoPages.noPages')}</p>
</div> </div>
) : ( ) : (

View File

@@ -1,4 +1,5 @@
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { PiCaretDown } from 'react-icons/pi';
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -52,15 +53,7 @@ function isoToDatetimeLocal(iso: string): string {
} }
const ChevronDownIcon = ({ open }: { open: boolean }) => ( const ChevronDownIcon = ({ open }: { open: boolean }) => (
<svg <PiCaretDown className={cn('h-5 w-5 transition-transform', open && 'rotate-180')} />
className={cn('h-5 w-5 transition-transform', open && 'rotate-180')}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
); );
// ============ Collapsible Section ============ // ============ Collapsible Section ============

View File

@@ -26,17 +26,15 @@ import { useCurrency } from '../hooks/useCurrency';
import { useChartColors } from '../hooks/useChartColors'; import { useChartColors } from '../hooks/useChartColors';
import { CHART_COMMON } from '../constants/charts'; import { CHART_COMMON } from '../constants/charts';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import {
// Icons ChartIcon,
const ChartIcon = () => ( EmailIcon,
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> TelegramSmallIcon,
<path ArrowRightIcon,
strokeLinecap="round" GiftIcon,
strokeLinejoin="round" ChevronLeftIcon as ChevronLeftSmall,
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" ChevronRightIcon as ChevronRightSmall,
/> } from '@/components/icons';
</svg>
);
const TARIFF_PALETTE = ['#818cf8', '#34d399', '#f59e0b', '#ec4899', '#06b6d4', '#8b5cf6']; const TARIFF_PALETTE = ['#818cf8', '#34d399', '#f59e0b', '#ec4899', '#06b6d4', '#8b5cf6'];
const GIFT_COLOR = '#a855f7'; const GIFT_COLOR = '#a855f7';
@@ -62,75 +60,15 @@ const PURCHASE_STATUS_OPTIONS: Array<PurchaseItemStatus | 'all'> = [
const PURCHASES_PAGE_SIZE = 20; const PURCHASES_PAGE_SIZE = 20;
// Small icons for the purchase cards
const EmailIcon = () => (
<svg
className="h-3.5 w-3.5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
);
const TelegramSmallIcon = () => (
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
);
const ArrowRightIcon = () => (
<svg
className="h-3 w-3 shrink-0 text-dark-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
</svg>
);
const GiftIcon = () => (
<svg
className="h-3.5 w-3.5 shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
);
const ChevronLeftSmall = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const ChevronRightSmall = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
);
// Contact display helper // Contact display helper
function ContactDisplay({ type, value }: { type: 'email' | 'telegram'; value: string }) { function ContactDisplay({ type, value }: { type: 'email' | 'telegram'; value: string }) {
return ( return (
<span className="flex items-center gap-1 text-dark-300"> <span className="flex items-center gap-1 text-dark-300">
{type === 'email' ? <EmailIcon /> : <TelegramSmallIcon />} {type === 'email' ? (
<EmailIcon className="h-3.5 w-3.5" />
) : (
<TelegramSmallIcon className="h-3.5 w-3.5" />
)}
<span className="min-w-0 truncate text-xs">{value}</span> <span className="min-w-0 truncate text-xs">{value}</span>
</span> </span>
); );
@@ -184,7 +122,7 @@ function PurchaseCard({ item, formatPrice, lang, t }: PurchaseCardProps) {
<ContactDisplay type={item.contact_type} value={item.contact_value} /> <ContactDisplay type={item.contact_type} value={item.contact_value} />
{item.is_gift && item.gift_recipient_type && item.gift_recipient_value && ( {item.is_gift && item.gift_recipient_type && item.gift_recipient_value && (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<ArrowRightIcon /> <ArrowRightIcon className="h-3 w-3" />
<ContactDisplay type={item.gift_recipient_type} value={item.gift_recipient_value} /> <ContactDisplay type={item.gift_recipient_type} value={item.gift_recipient_value} />
</span> </span>
)} )}
@@ -212,7 +150,7 @@ function PurchaseCard({ item, formatPrice, lang, t }: PurchaseCardProps) {
{item.is_gift && ( {item.is_gift && (
<div className="shrink-0"> <div className="shrink-0">
<span className="inline-flex items-center gap-1 rounded-md bg-purple-500/20 px-1.5 py-0.5 text-xs text-purple-400"> <span className="inline-flex items-center gap-1 rounded-md bg-purple-500/20 px-1.5 py-0.5 text-xs text-purple-400">
<GiftIcon /> <GiftIcon className="h-3.5 w-3.5" />
{t('admin.landings.purchases.gift')} {t('admin.landings.purchases.gift')}
</span> </span>
</div> </div>
@@ -379,7 +317,7 @@ export default function AdminLandingStats() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<AdminBackButton to="/admin/landings" /> <AdminBackButton to="/admin/landings" />
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400"> <div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
<ChartIcon /> <ChartIcon className="h-6 w-6" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h1 className="truncate text-xl font-semibold text-dark-100">{landingTitle}</h1> <h1 className="truncate text-xl font-semibold text-dark-100">{landingTitle}</h1>

View File

@@ -9,6 +9,15 @@ import { getApiErrorMessage } from '../utils/api-error';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { BackIcon, PlusIcon, TrashIcon, GripIcon } from '../components/icons/LandingIcons'; import { BackIcon, PlusIcon, TrashIcon, GripIcon } from '../components/icons/LandingIcons';
import {
EditIcon,
CheckIcon,
XIcon,
GiftIcon,
SaveIcon,
CopyIcon,
StatsChartIcon,
} from '@/components/icons';
import { import {
DndContext, DndContext,
KeyboardSensor, KeyboardSensor,
@@ -26,69 +35,6 @@ import {
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
// Icons (non-shared, page-specific)
const EditIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
);
const CheckIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
const XIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
);
const GiftIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg>
);
const SaveIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
);
const CopyIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
/>
</svg>
);
const StatsChartIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
);
// ============ Sortable Landing Card ============ // ============ Sortable Landing Card ============
interface SortableLandingCardProps { 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" 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')} title={t('admin.landings.statistics')}
> >
<StatsChartIcon /> <StatsChartIcon className="h-4 w-4" />
</button> </button>
<button <button
onClick={onToggle} onClick={onToggle}
@@ -273,7 +219,7 @@ function SortableLandingCard({
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100" 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')} title={t('admin.landings.statistics')}
> >
<StatsChartIcon /> <StatsChartIcon className="h-4 w-4" />
</button> </button>
<button <button
onClick={onToggle} onClick={onToggle}
@@ -470,7 +416,7 @@ export default function AdminLandings() {
{saveOrderMutation.isPending ? ( {saveOrderMutation.isPending ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : ( ) : (
<SaveIcon /> <SaveIcon className="h-4 w-4" />
)} )}
{t('admin.landings.saveOrder')} {t('admin.landings.saveOrder')}
</button> </button>

View File

@@ -8,105 +8,14 @@ import { Toggle } from '../components/admin/Toggle';
import { useHapticFeedback } from '../platform/hooks/useHaptic'; import { useHapticFeedback } from '../platform/hooks/useHaptic';
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
import type { NewsListItem } from '../types/news'; import type { NewsListItem } from '../types/news';
import {
// Icons PlusIcon,
const PlusIcon = () => ( RefreshIcon,
<svg PencilIcon,
className="h-5 w-5" TrashIcon,
fill="none" StarIcon,
viewBox="0 0 24 24" NewsIcon,
stroke="currentColor" } from '@/components/icons';
strokeWidth={2}
aria-hidden="true"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
);
const RefreshIcon = () => (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
);
const PencilIcon = () => (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
);
const TrashIcon = () => (
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
);
const StarIcon = ({ filled }: { filled: boolean }) => (
<svg
className="h-4 w-4"
fill={filled ? 'currentColor' : 'none'}
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
/>
</svg>
);
const NewsIcon = () => (
<svg
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 7.5h1.5m-1.5 3h1.5m-7.5 3h7.5m-7.5 3h7.5m3-9h3.375c.621 0 1.125.504 1.125 1.125V18a2.25 2.25 0 01-2.25 2.25M16.5 7.5V18a2.25 2.25 0 002.25 2.25M16.5 7.5V4.875c0-.621-.504-1.125-1.125-1.125H4.125C3.504 3.75 3 4.254 3 4.875V18a2.25 2.25 0 002.25 2.25h13.5M6 7.5h3v3H6v-3z"
/>
</svg>
);
// --- Security: hex color validation to prevent CSS injection --- // --- 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})$/; 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')} title={t('news.admin.featured')}
aria-label={t('news.admin.featured')} aria-label={t('news.admin.featured')}
> >
<StarIcon filled={article.is_featured} /> <StarIcon className="h-4 w-4" filled={article.is_featured} />
</button> </button>
<Toggle <Toggle
checked={article.is_published} checked={article.is_published}
@@ -218,7 +127,7 @@ const ArticleRow = memo(function ArticleRow({
title={t('news.admin.delete')} title={t('news.admin.delete')}
aria-label={t('news.admin.delete')} aria-label={t('news.admin.delete')}
> >
<TrashIcon /> <TrashIcon className="h-4 w-4" />
</button> </button>
</div> </div>
</div> </div>
@@ -401,7 +310,7 @@ export default function AdminNews() {
</div> </div>
) : articles.length === 0 ? ( ) : articles.length === 0 ? (
<div className="flex flex-col items-center rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400"> <div className="flex flex-col items-center rounded-xl border border-dark-700 bg-dark-800/50 p-8 text-center text-dark-400">
<NewsIcon /> <NewsIcon className="h-6 w-6" />
<p className="mt-2">{t('news.noNews')}</p> <p className="mt-2">{t('news.noNews')}</p>
</div> </div>
) : ( ) : (

View File

@@ -19,109 +19,25 @@ import { Toggle } from '../components/admin/Toggle';
import { useHapticFeedback } from '../platform/hooks/useHaptic'; import { useHapticFeedback } from '../platform/hooks/useHaptic';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import type { NewsCategory, NewsTag, NewsCreateRequest } from '../types/news'; import type { NewsCategory, NewsTag, NewsCreateRequest } from '../types/news';
import {
// --- Icons --- BoldIcon,
const BoldIcon = () => ( ItalicIcon,
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"> UnderlineIcon,
<path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z" /> StrikeIcon,
</svg> H1Icon,
); H2Icon,
H3Icon,
const ItalicIcon = () => ( ListBulletIcon,
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"> ListOrderedIcon,
<path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z" /> QuoteIcon,
</svg> CodeBlockIcon,
); ImageIcon,
LinkIcon,
const UnderlineIcon = () => ( AlignLeftIcon,
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor"> AlignCenterIcon,
<path d="M12 17c3.31 0 6-2.69 6-6V3h-2.5v8c0 1.93-1.57 3.5-3.5 3.5S8.5 12.93 8.5 11V3H6v8c0 3.31 2.69 6 6 6zm-7 2v2h14v-2H5z" /> HighlightIcon,
</svg> UploadIcon,
); } from '@/components/icons';
const StrikeIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z" />
</svg>
);
const H1Icon = () => <span className="text-xs font-bold">H1</span>;
const H2Icon = () => <span className="text-xs font-bold">H2</span>;
const H3Icon = () => <span className="text-xs font-bold">H3</span>;
const ListBulletIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z" />
</svg>
);
const ListOrderedIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
</svg>
);
const QuoteIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
</svg>
);
const CodeBlockIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z" />
</svg>
);
const ImageIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</svg>
);
const LinkIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244"
/>
</svg>
);
const AlignLeftIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M15 15H3v2h12v-2zm0-8H3v2h12V7zM3 13h18v-2H3v2zm0 8h18v-2H3v2zM3 3v2h18V3H3z" />
</svg>
);
const AlignCenterIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M7 15v2h10v-2H7zm-4 6h18v-2H3v2zm0-8h18v-2H3v2zm4-6v2h10V7H7zM3 3v2h18V3H3z" />
</svg>
);
const HighlightIcon = () => (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M16.56 3.44a1.5 1.5 0 012.12 0l1.88 1.88a1.5 1.5 0 010 2.12L8.44 19.56 3 21l1.44-5.44L16.56 3.44z" />
</svg>
);
const UploadIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/>
</svg>
);
// --- Toolbar Button --- // --- Toolbar Button ---
interface ToolbarButtonProps { interface ToolbarButtonProps {
@@ -765,7 +681,7 @@ export default function AdminNewsCreate() {
{isFeaturedImageUploading ? ( {isFeaturedImageUploading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" />
) : ( ) : (
<UploadIcon /> <UploadIcon className="h-4 w-4" />
)} )}
<span className="hidden sm:inline">{t('news.admin.uploadFeaturedImage')}</span> <span className="hidden sm:inline">{t('news.admin.uploadFeaturedImage')}</span>
</button> </button>
@@ -848,28 +764,28 @@ export default function AdminNewsCreate() {
isActive={editor.isActive('bold')} isActive={editor.isActive('bold')}
title={t('news.admin.toolbar.bold')} title={t('news.admin.toolbar.bold')}
> >
<BoldIcon /> <BoldIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => editor.chain().focus().toggleItalic().run()} onClick={() => editor.chain().focus().toggleItalic().run()}
isActive={editor.isActive('italic')} isActive={editor.isActive('italic')}
title={t('news.admin.toolbar.italic')} title={t('news.admin.toolbar.italic')}
> >
<ItalicIcon /> <ItalicIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => editor.chain().focus().toggleUnderline().run()} onClick={() => editor.chain().focus().toggleUnderline().run()}
isActive={editor.isActive('underline')} isActive={editor.isActive('underline')}
title={t('news.admin.toolbar.underline')} title={t('news.admin.toolbar.underline')}
> >
<UnderlineIcon /> <UnderlineIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => editor.chain().focus().toggleStrike().run()} onClick={() => editor.chain().focus().toggleStrike().run()}
isActive={editor.isActive('strike')} isActive={editor.isActive('strike')}
title={t('news.admin.toolbar.strikethrough')} title={t('news.admin.toolbar.strikethrough')}
> >
<StrikeIcon /> <StrikeIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<div className="mx-1 h-5 w-px bg-dark-700" /> <div className="mx-1 h-5 w-px bg-dark-700" />
@@ -879,7 +795,7 @@ export default function AdminNewsCreate() {
isActive={editor.isActive('heading', { level: 1 })} isActive={editor.isActive('heading', { level: 1 })}
title={t('news.admin.toolbar.heading1')} title={t('news.admin.toolbar.heading1')}
> >
<H1Icon /> <H1Icon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
@@ -910,21 +826,21 @@ export default function AdminNewsCreate() {
isActive={editor.isActive('orderedList')} isActive={editor.isActive('orderedList')}
title={t('news.admin.toolbar.orderedList')} title={t('news.admin.toolbar.orderedList')}
> >
<ListOrderedIcon /> <ListOrderedIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => editor.chain().focus().toggleBlockquote().run()} onClick={() => editor.chain().focus().toggleBlockquote().run()}
isActive={editor.isActive('blockquote')} isActive={editor.isActive('blockquote')}
title={t('news.admin.toolbar.blockquote')} title={t('news.admin.toolbar.blockquote')}
> >
<QuoteIcon /> <QuoteIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()} onClick={() => editor.chain().focus().toggleCodeBlock().run()}
isActive={editor.isActive('codeBlock')} isActive={editor.isActive('codeBlock')}
title={t('news.admin.toolbar.codeBlock')} title={t('news.admin.toolbar.codeBlock')}
> >
<CodeBlockIcon /> <CodeBlockIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<div className="mx-1 h-5 w-px bg-dark-700" /> <div className="mx-1 h-5 w-px bg-dark-700" />
@@ -934,14 +850,14 @@ export default function AdminNewsCreate() {
isActive={editor.isActive({ textAlign: 'left' })} isActive={editor.isActive({ textAlign: 'left' })}
title={t('news.admin.toolbar.alignLeft')} title={t('news.admin.toolbar.alignLeft')}
> >
<AlignLeftIcon /> <AlignLeftIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => editor.chain().focus().setTextAlign('center').run()} onClick={() => editor.chain().focus().setTextAlign('center').run()}
isActive={editor.isActive({ textAlign: 'center' })} isActive={editor.isActive({ textAlign: 'center' })}
title={t('news.admin.toolbar.alignCenter')} title={t('news.admin.toolbar.alignCenter')}
> >
<AlignCenterIcon /> <AlignCenterIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<div className="mx-1 h-5 w-px bg-dark-700" /> <div className="mx-1 h-5 w-px bg-dark-700" />
@@ -951,10 +867,10 @@ export default function AdminNewsCreate() {
isActive={editor.isActive('highlight')} isActive={editor.isActive('highlight')}
title={t('news.admin.toolbar.highlight')} title={t('news.admin.toolbar.highlight')}
> >
<HighlightIcon /> <HighlightIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton onClick={addLink} title={t('news.admin.toolbar.link')}> <ToolbarButton onClick={addLink} title={t('news.admin.toolbar.link')}>
<LinkIcon /> <LinkIcon className="h-4 w-4" />
</ToolbarButton> </ToolbarButton>
<ToolbarButton <ToolbarButton
onClick={() => mediaInputRef.current?.click()} onClick={() => mediaInputRef.current?.click()}
@@ -964,7 +880,7 @@ export default function AdminNewsCreate() {
{isUploading ? ( {isUploading ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-accent-400 border-t-transparent" />
) : ( ) : (
<ImageIcon /> <ImageIcon className="h-4 w-4" />
)} )}
</ToolbarButton> </ToolbarButton>
</div> </div>

View File

@@ -7,365 +7,91 @@ import { statsApi, type SystemInfo, type DashboardStats } from '@/api/admin';
import { useAnimatedNumber } from '@/hooks/useAnimatedNumber'; import { useAnimatedNumber } from '@/hooks/useAnimatedNumber';
import { useTelegramSDK } from '@/hooks/useTelegramSDK'; import { useTelegramSDK } from '@/hooks/useTelegramSDK';
import { cn } from '@/lib/utils'; 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 CABINET_VERSION = __APP_VERSION__;
const IS_MAC = /Mac|iPhone|iPod|iPad/i.test(navigator.userAgent); 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<SVGSVGElement> & { children: React.ReactNode }) => (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.7}
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
{...props}
>
{children}
</svg>
);
// Stats bar icons (16x16 viewBox)
const StatUptimeIcon = () => (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
className="h-3.5 w-3.5"
aria-hidden="true"
>
<circle cx="8" cy="8" r="6.5" />
<path d="M8 4.5V8l2.5 1.5" />
</svg>
);
const StatBotIcon = () => (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
className="h-3.5 w-3.5"
aria-hidden="true"
>
<rect x="3" y="4" width="10" height="8" rx="2" />
<path d="M6 8h.01M10 8h.01" />
<path d="M8 2v2M4 14h8" />
</svg>
);
const StatCabinetIcon = () => (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
className="h-3.5 w-3.5"
aria-hidden="true"
>
<rect x="2" y="3" width="12" height="10" rx="1.5" />
<path d="M2 6h12" />
<path d="M5 3v3" />
</svg>
);
const StatTrialIcon = () => (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
className="h-3.5 w-3.5"
aria-hidden="true"
>
<path d="M8 2v2M8 12v2M4 8H2M14 8h-2" />
<circle cx="8" cy="8" r="3" />
</svg>
);
const StatPaidIcon = () => (
<svg
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
className="h-3.5 w-3.5"
aria-hidden="true"
>
<path d="M4 6c0-1.7 1.8-3 4-3s4 1.3 4 3-1.8 3-4 3-4 1.3-4 3 1.8 3 4 3 4-1.3 4-3" />
<path d="M8 1v2M8 13v2" />
</svg>
);
// Section nav icons (24x24 viewBox) // Section nav icons (24x24 viewBox)
const icons = { const icons = {
'bar-chart': ( 'bar-chart': <ChartBarIcon />,
<SvgIcon> 'credit-card': <CreditCardIcon />,
<path d="M3 3v18h18" /> activity: <TrafficIcon />,
<path d="M7 16V8" /> trending: <StatsChartIcon />,
<path d="M11 16V11" /> users: <UsersIcon />,
<path d="M15 16V5" /> ticket: <TicketIcon />,
<path d="M19 16v-3" /> 'shield-alert': <ShieldIcon />,
</SvgIcon> tag: <TagIcon />,
), gift: <GiftIcon />,
'credit-card': ( percent: <PercentIcon />,
<SvgIcon> sparkle: <SparklesIcon />,
<rect x="2" y="5" width="20" height="14" rx="2" /> wallet: <WalletIcon />,
<path d="M2 10h20" /> layout: <CabinetIcon />,
</SvgIcon> newspaper: <NewsIcon />,
), megaphone: <MegaphoneIcon />,
activity: ( send: <SendIcon />,
<SvgIcon> pin: <PinIcon />,
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" /> 'circle-dot': <WheelIcon />,
</SvgIcon> handshake: <PartnerIcon />,
), 'arrow-up': <ArrowUpIcon />,
trending: ( network: <ShareIcon />,
<SvgIcon> radio: <BroadcastIcon />,
<polyline points="22 7 13.5 15.5 8.5 10.5 2 17" /> settings: <SettingsIcon />,
<polyline points="16 7 22 7 22 13" /> app: <CabinetIcon />,
</SvgIcon> server: <ServerIcon />,
), remnawave: <RemnawaveIcon />,
users: ( mail: <MailIcon />,
<SvgIcon> refresh: <SyncIcon />,
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /> shield: <ShieldIcon />,
<circle cx="9" cy="7" r="4" /> 'user-check': <UserPlusIcon />,
<path d="M22 21v-2a4 4 0 0 0-3-3.87" /> lock: <LockIcon />,
<path d="M16 3.13a4 4 0 0 1 0 7.75" /> scroll: <HistoryIcon />,
</SvgIcon> 'list-checks': <ClipboardIcon />,
), search: <SearchIcon />,
ticket: ( 'file-text': <FileTextIcon />,
<SvgIcon> chevron: <ChevronRightIcon />,
<path d="M2 9a3 3 0 0 1 0 6v2a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-2a3 3 0 0 1 0-6V7a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2Z" /> x: <XIcon />,
<path d="M13 5v2M13 17v2M13 11v2" />
</SvgIcon>
),
'shield-alert': (
<SvgIcon>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10" />
<path d="M12 8v4" />
<path d="M12 16h.01" />
</SvgIcon>
),
tag: (
<SvgIcon>
<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" />
<circle cx="7.5" cy="7.5" r=".5" fill="currentColor" />
</SvgIcon>
),
gift: (
<SvgIcon>
<rect x="3" y="8" width="18" height="4" rx="1" />
<path d="M12 8v13" />
<path d="M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7" />
</SvgIcon>
),
percent: (
<SvgIcon>
<line x1="19" y1="5" x2="5" y2="19" />
<circle cx="6.5" cy="6.5" r="2.5" />
<circle cx="17.5" cy="17.5" r="2.5" />
</SvgIcon>
),
sparkle: (
<SvgIcon>
<path d="m12 3-1.9 5.8a2 2 0 0 1-1.287 1.288L3 12l5.8 1.9a2 2 0 0 1 1.288 1.287L12 21l1.9-5.8a2 2 0 0 1 1.287-1.288L21 12l-5.8-1.9a2 2 0 0 1-1.288-1.287Z" />
</SvgIcon>
),
wallet: (
<SvgIcon>
<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4" />
<path d="M3 5v14a2 2 0 0 0 2 2h16v-5" />
<path d="M18 12a2 2 0 0 0 0 4h4v-4Z" />
</SvgIcon>
),
layout: (
<SvgIcon>
<rect x="3" y="3" width="18" height="18" rx="2" />
<path d="M3 9h18" />
<path d="M9 21V9" />
</SvgIcon>
),
newspaper: (
<SvgIcon>
<path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2" />
<path d="M18 14h-8M15 18h-5" />
<path d="M10 6h8v4h-8V6Z" />
</SvgIcon>
),
megaphone: (
<SvgIcon>
<path d="m3 11 18-5v12L3 13v-2z" />
<path d="M11.6 16.8a3 3 0 1 1-5.8-1.6" />
</SvgIcon>
),
send: (
<SvgIcon>
<path d="m22 2-7 20-4-9-9-4Z" />
<path d="M22 2 11 13" />
</SvgIcon>
),
pin: (
<SvgIcon>
<line x1="12" y1="17" x2="12" y2="22" />
<path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z" />
</SvgIcon>
),
'circle-dot': (
<SvgIcon>
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="1" fill="currentColor" />
</SvgIcon>
),
handshake: (
<SvgIcon>
<path d="m11 17 2 2a1 1 0 1 0 3-3" />
<path d="m14 14 2.5 2.5a1 1 0 1 0 3-3l-3.88-3.88a3 3 0 0 0-4.24 0l-.88.88" />
<path d="m2 12 5.56-5.56a3 3 0 0 1 2.22-.88L12 5.5" />
<path d="M22 12 16.44 6.44a3 3 0 0 0-2.22-.88L12 5.5" />
</SvgIcon>
),
'arrow-up': (
<SvgIcon>
<path d="m18 9-6-6-6 6" />
<path d="M12 3v14" />
<path d="M5 21h14" />
</SvgIcon>
),
network: (
<SvgIcon>
<rect x="16" y="16" width="6" height="6" rx="1" />
<rect x="2" y="16" width="6" height="6" rx="1" />
<rect x="9" y="2" width="6" height="6" rx="1" />
<path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" />
<path d="M12 12V8" />
</SvgIcon>
),
radio: (
<SvgIcon>
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9" />
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.4" />
<circle cx="12" cy="12" r="2" />
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.4" />
<path d="M19.1 4.9C23 8.8 23 15.1 19.1 19" />
</SvgIcon>
),
settings: (
<SvgIcon>
<circle cx="12" cy="12" r="3" />
<path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42" />
</SvgIcon>
),
app: (
<SvgIcon>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M10 4v4M2 8h20M6 4v4" />
</SvgIcon>
),
server: (
<SvgIcon>
<rect x="2" y="2" width="20" height="8" rx="2" />
<rect x="2" y="14" width="20" height="8" rx="2" />
<circle cx="6" cy="6" r="1" fill="currentColor" />
<circle cx="6" cy="18" r="1" fill="currentColor" />
</SvgIcon>
),
remnawave: (
<svg
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="h-[13px] w-[13px]"
aria-hidden="true"
>
<path
clipRule="evenodd"
d="M8 1a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-1.5 0V1.75A.75.75 0 0 1 8 1Zm6 2a.75.75 0 0 1 .75.75v8.5a.75.75 0 0 1-1.5 0v-8.5A.75.75 0 0 1 14 3ZM5 4a.75.75 0 0 1 .75.75v6.5a.75.75 0 0 1-1.5 0v-6.5A.75.75 0 0 1 5 4Zm6 1a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 11 5ZM2 6a.75.75 0 0 1 .75.75v2.5a.75.75 0 0 1-1.5 0v-2.5A.75.75 0 0 1 2 6Z"
fill="currentColor"
fillRule="evenodd"
/>
</svg>
),
mail: (
<SvgIcon>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</SvgIcon>
),
refresh: (
<SvgIcon>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</SvgIcon>
),
shield: (
<SvgIcon>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10" />
</SvgIcon>
),
'user-check': (
<SvgIcon>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<polyline points="16 11 18 13 22 9" />
</SvgIcon>
),
lock: (
<SvgIcon>
<rect x="3" y="11" width="18" height="11" rx="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</SvgIcon>
),
scroll: (
<SvgIcon>
<path d="M8 21h12a2 2 0 0 0 2-2v-2H10v2a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v3h4" />
<path d="M19 17V5a2 2 0 0 0-2-2H4" />
<path d="M15 8h-5M15 12h-5" />
</SvgIcon>
),
'list-checks': (
<SvgIcon>
<path d="M10 6h11M10 12h11M10 18h11" />
<path d="m3 6 1 1 2-2M3 12l1 1 2-2M3 18l1 1 2-2" />
</SvgIcon>
),
search: (
<SvgIcon>
<circle cx="11" cy="11" r="8" />
<path d="m21 21-4.3-4.3" />
</SvgIcon>
),
'file-text': (
<SvgIcon>
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<line x1="10" y1="9" x2="8" y2="9" />
</SvgIcon>
),
chevron: (
<SvgIcon>
<path d="m9 18 6-6-6-6" />
</SvgIcon>
),
x: (
<SvgIcon>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</SvgIcon>
),
} as const; } as const;
type IconName = keyof typeof icons; 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', colorClass: 'text-success-400 bg-success-400/10 border-success-400/20',
}, },
{ {
icon: <StatBotIcon />, icon: <StatBotIcon className="h-3.5 w-3.5" />,
label: t('admin.panel.statsBot'), label: t('admin.panel.statsBot'),
value: systemInfo?.bot_version ?? '--', value: systemInfo?.bot_version ?? '--',
colorClass: 'text-accent-400 bg-accent-400/10 border-accent-400/20', colorClass: 'text-accent-400 bg-accent-400/10 border-accent-400/20',
}, },
{ {
icon: <StatCabinetIcon />, icon: <StatCabinetIcon className="h-3.5 w-3.5" />,
label: t('admin.panel.statsCabinet'), label: t('admin.panel.statsCabinet'),
value: `v${CABINET_VERSION}`, value: `v${CABINET_VERSION}`,
colorClass: 'text-accent-300 bg-accent-300/10 border-accent-300/20', colorClass: 'text-accent-300 bg-accent-300/10 border-accent-300/20',
}, },
{ {
icon: <StatTrialIcon />, icon: <StatTrialIcon className="h-3.5 w-3.5" />,
label: t('admin.panel.statsTrials'), label: t('admin.panel.statsTrials'),
numericValue: trial, numericValue: trial,
colorClass: 'text-warning-400 bg-warning-400/10 border-warning-400/20', colorClass: 'text-warning-400 bg-warning-400/10 border-warning-400/20',
}, },
{ {
icon: <StatPaidIcon />, icon: <StatPaidIcon className="h-3.5 w-3.5" />,
label: t('admin.panel.statsPaid'), label: t('admin.panel.statsPaid'),
numericValue: paid, numericValue: paid,
delta: purchasedToday > 0 ? `+${purchasedToday}` : undefined, delta: purchasedToday > 0 ? `+${purchasedToday}` : undefined,
@@ -912,19 +638,19 @@ export default function AdminPanel() {
cls: 'text-success-400', cls: 'text-success-400',
}, },
{ {
icon: <StatBotIcon />, icon: <StatBotIcon className="h-3.5 w-3.5" />,
label: t('admin.panel.statsBot'), label: t('admin.panel.statsBot'),
value: systemInfo?.bot_version ?? '--', value: systemInfo?.bot_version ?? '--',
cls: 'text-accent-400', cls: 'text-accent-400',
}, },
{ {
icon: <StatTrialIcon />, icon: <StatTrialIcon className="h-3.5 w-3.5" />,
label: t('admin.panel.statsTrials'), label: t('admin.panel.statsTrials'),
value: dashboardStats?.subscriptions.trial?.toLocaleString() ?? '--', value: dashboardStats?.subscriptions.trial?.toLocaleString() ?? '--',
cls: 'text-warning-400', cls: 'text-warning-400',
}, },
{ {
icon: <StatPaidIcon />, icon: <StatPaidIcon className="h-3.5 w-3.5" />,
label: t('admin.panel.statsPaid'), label: t('admin.panel.statsPaid'),
value: dashboardStats?.subscriptions.paid?.toLocaleString() ?? '--', value: dashboardStats?.subscriptions.paid?.toLocaleString() ?? '--',
delta: delta:

View File

@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
import { partnerApi } from '../api/partners'; import { partnerApi } from '../api/partners';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
import { XIcon } from '@/components/icons';
// Status badge config — keys must match backend PartnerStatus enum values // Status badge config — keys must match backend PartnerStatus enum values
const statusConfig: Record<string, { labelKey: string; color: string; bgColor: string }> = { const statusConfig: Record<string, { labelKey: string; color: string; bgColor: string }> = {
@@ -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" className="rounded p-1 text-dark-500 transition-colors hover:bg-error-500/10 hover:text-error-400"
title={t('admin.partnerDetail.campaigns.unassign')} title={t('admin.partnerDetail.campaigns.unassign')}
> >
<svg <XIcon className="h-4 w-4" />
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -5,20 +5,10 @@ import { useTranslation } from 'react-i18next';
import { partnerApi } from '../api/partners'; import { partnerApi } from '../api/partners';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { toNumber } from '../utils/inputHelpers'; import { toNumber } from '../utils/inputHelpers';
import { SettingsIcon } from '@/components/icons';
type NumberOrEmpty = number | ''; type NumberOrEmpty = number | '';
const SettingsIcon = () => (
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
);
export default function AdminPartnerSettings() { export default function AdminPartnerSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -126,7 +116,7 @@ export default function AdminPartnerSettings() {
<div className="mb-6 flex items-center gap-3"> <div className="mb-6 flex items-center gap-3">
<AdminBackButton to="/admin/partners" /> <AdminBackButton to="/admin/partners" />
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400"> <div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
<SettingsIcon /> <SettingsIcon className="h-6 w-6" />
</div> </div>
<div> <div>
<h1 className="text-xl font-semibold text-dark-100">{t('admin.partners.settings')}</h1> <h1 className="text-xl font-semibold text-dark-100">{t('admin.partners.settings')}</h1>

View File

@@ -9,6 +9,7 @@ import {
} from '../api/partners'; } from '../api/partners';
import { AdminBackButton } from '../components/admin'; import { AdminBackButton } from '../components/admin';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
import { ChevronRightIcon, SettingsIcon } from '@/components/icons';
export default function AdminPartners() { export default function AdminPartners() {
const { t } = useTranslation(); 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" 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')} title={t('admin.partners.settings')}
> >
<svg <SettingsIcon className="h-5 w-5" />
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
</button> </button>
</div> </div>
@@ -167,19 +151,7 @@ export default function AdminPartners() {
</span> </span>
</div> </div>
</div> </div>
<svg <ChevronRightIcon className="h-5 w-5 shrink-0 text-dark-500" />
className="h-5 w-5 shrink-0 text-dark-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</div> </div>
</button> </button>
))} ))}

View File

@@ -7,33 +7,7 @@ import { METHOD_LABELS } from '../constants/paymentMethods';
import type { PromoGroupSimple } from '../types'; import type { PromoGroupSimple } from '../types';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
import { createNumberInputHandler, toNumber } from '../utils/inputHelpers'; import { createNumberInputHandler, toNumber } from '../utils/inputHelpers';
const BackIcon = () => ( import { BackIcon, CheckIcon, SaveIcon } from '@/components/icons';
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const CheckIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
);
const SaveIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
);
export default function AdminPaymentMethodEdit() { export default function AdminPaymentMethodEdit() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -462,7 +436,7 @@ export default function AdminPaymentMethodEdit() {
{updateMethodMutation.isPending ? ( {updateMethodMutation.isPending ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : ( ) : (
<SaveIcon /> <SaveIcon className="h-4 w-4" />
)} )}
{t('admin.paymentMethods.saveButton')} {t('admin.paymentMethods.saveButton')}
</button> </button>

View File

@@ -23,43 +23,7 @@ import {
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'; import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
import type { PaymentMethodConfig } from '../types'; import type { PaymentMethodConfig } from '../types';
const BackIcon = () => ( import { BackIcon, GripIcon, ChevronRightIcon, SaveIcon } from '@/components/icons';
<svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
const GripIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
);
const ChevronRightIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
);
const SaveIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
);
interface SortableCardProps { interface SortableCardProps {
config: PaymentMethodConfig; config: PaymentMethodConfig;
@@ -267,7 +231,7 @@ export default function AdminPaymentMethods() {
{saveOrderMutation.isPending ? ( {saveOrderMutation.isPending ? (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : ( ) : (
<SaveIcon /> <SaveIcon className="h-4 w-4" />
)} )}
{t('admin.paymentMethods.saveOrder')} {t('admin.paymentMethods.saveOrder')}
</button> </button>

View File

@@ -6,41 +6,13 @@ import { adminPaymentsApi, type SearchStats } from '../api/adminPayments';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
import type { PendingPayment, PaginatedResponse } from '../types'; import type { PendingPayment, PaginatedResponse } from '../types';
import { usePlatform } from '../platform/hooks/usePlatform'; import { usePlatform } from '../platform/hooks/usePlatform';
import {
// BackIcon BackIcon,
const BackIcon = () => ( SearchIcon,
<svg CalendarIcon,
className="h-5 w-5 text-dark-400" RefreshIcon,
fill="none" CheckCircleIcon,
viewBox="0 0 24 24" } from '@/components/icons';
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg>
);
// SearchIcon
const SearchIcon = () => (
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
);
// CalendarIcon
const CalendarIcon = () => (
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
/>
</svg>
);
interface StatusBadgeProps { interface StatusBadgeProps {
status: string; status: string;
@@ -244,19 +216,7 @@ export default function AdminPayments() {
</div> </div>
</div> </div>
<button onClick={() => refetch()} className="btn-secondary flex items-center gap-2"> <button onClick={() => refetch()} className="btn-secondary flex items-center gap-2">
<svg <RefreshIcon className="h-4 w-4" />
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
{t('common.refresh')} {t('common.refresh')}
</button> </button>
</div> </div>
@@ -338,7 +298,7 @@ export default function AdminPayments() {
: 'bg-dark-800 text-dark-300 hover:bg-dark-700' : 'bg-dark-800 text-dark-300 hover:bg-dark-700'
}`} }`}
> >
<CalendarIcon /> <CalendarIcon className="h-4 w-4" />
{t('admin.payments.periodCustom')} {t('admin.payments.periodCustom')}
</button> </button>
@@ -608,19 +568,7 @@ export default function AdminPayments() {
) : ( ) : (
<div className="py-12 text-center"> <div className="py-12 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800"> <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
<svg <CheckCircleIcon className="h-8 w-8 text-dark-500" />
className="h-8 w-8 text-dark-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div> </div>
<div className="text-dark-400">{t('admin.payments.noPayments')}</div> <div className="text-dark-400">{t('admin.payments.noPayments')}</div>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More