refactor: migrate to eslint flat config and format codebase with prettier

- Remove legacy .eslintrc.cjs and .eslintignore
- Add eslint.config.js with flat config, security rules (no-eval, no-implied-eval, no-new-func, no-script-url)
- Add .prettierrc and .prettierignore
- Format entire codebase with prettier
This commit is contained in:
c0mrade
2026-01-27 17:37:31 +03:00
parent 111ccc4e7a
commit bc90ba3779
133 changed files with 19972 additions and 15523 deletions

View File

@@ -1,6 +0,0 @@
dist
node_modules
*.config.js
*.config.ts
.github
public

View File

@@ -1,22 +0,0 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'react-hooks/exhaustive-deps': 'warn',
'no-empty': 'warn',
},
}

6
.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
dist
node_modules
public
*.min.js
*.min.css
package-lock.json

13
.prettierrc Normal file
View File

@@ -0,0 +1,13 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"useTabs": false,
"trailingComma": "all",
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "auto",
"jsxSingleQuote": false,
"plugins": ["prettier-plugin-tailwindcss"]
}

48
eslint.config.js Normal file
View File

@@ -0,0 +1,48 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import eslintConfigPrettier from 'eslint-config-prettier'
export default tseslint.config(
{
ignores: ['dist', 'node_modules', 'public'],
},
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
...tseslint.configs.recommended,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-hooks/set-state-in-effect': 'off',
'react-hooks/static-components': 'off',
'react-hooks/immutability': 'off',
'react-hooks/refs': 'off',
'react-hooks/purity': 'off',
'react-hooks/variables': 'off',
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
'no-empty': 'warn',
'no-eval': 'error',
'no-implied-eval': 'error',
'no-new-func': 'error',
'no-script-url': 'error',
},
},
eslintConfigPrettier,
)

View File

@@ -1,116 +1,112 @@
import { lazy, Suspense } from 'react' import { lazy, Suspense } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from './store/auth' import { useAuthStore } from './store/auth';
import { useBlockingStore } from './store/blocking' import { useBlockingStore } from './store/blocking';
import Layout from './components/layout/Layout' import Layout from './components/layout/Layout';
import PageLoader from './components/common/PageLoader' import PageLoader from './components/common/PageLoader';
import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking' import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking';
import { saveReturnUrl } from './utils/token' import { saveReturnUrl } from './utils/token';
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters' import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
// Auth pages - load immediately (small) // Auth pages - load immediately (small)
import Login from './pages/Login' import Login from './pages/Login';
import TelegramCallback from './pages/TelegramCallback' import TelegramCallback from './pages/TelegramCallback';
import TelegramRedirect from './pages/TelegramRedirect' import TelegramRedirect from './pages/TelegramRedirect';
import DeepLinkRedirect from './pages/DeepLinkRedirect' import DeepLinkRedirect from './pages/DeepLinkRedirect';
import VerifyEmail from './pages/VerifyEmail' import VerifyEmail from './pages/VerifyEmail';
import ResetPassword from './pages/ResetPassword' import ResetPassword from './pages/ResetPassword';
// User pages - lazy load // User pages - lazy load
const Dashboard = lazy(() => import('./pages/Dashboard')) const Dashboard = lazy(() => import('./pages/Dashboard'));
const Subscription = lazy(() => import('./pages/Subscription')) const Subscription = lazy(() => import('./pages/Subscription'));
const Balance = lazy(() => import('./pages/Balance')) const Balance = lazy(() => import('./pages/Balance'));
const Referral = lazy(() => import('./pages/Referral')) const Referral = lazy(() => import('./pages/Referral'));
const Support = lazy(() => import('./pages/Support')) const Support = lazy(() => import('./pages/Support'));
const Profile = lazy(() => import('./pages/Profile')) const Profile = lazy(() => import('./pages/Profile'));
const Contests = lazy(() => import('./pages/Contests')) const Contests = lazy(() => import('./pages/Contests'));
const Polls = lazy(() => import('./pages/Polls')) const Polls = lazy(() => import('./pages/Polls'));
const Info = lazy(() => import('./pages/Info')) const Info = lazy(() => import('./pages/Info'));
const Wheel = lazy(() => import('./pages/Wheel')) const Wheel = lazy(() => import('./pages/Wheel'));
// Admin pages - lazy load (only for admins) // Admin pages - lazy load (only for admins)
const AdminPanel = lazy(() => import('./pages/AdminPanel')) const AdminPanel = lazy(() => import('./pages/AdminPanel'));
const AdminTickets = lazy(() => import('./pages/AdminTickets')) const AdminTickets = lazy(() => import('./pages/AdminTickets'));
const AdminSettings = lazy(() => import('./pages/AdminSettings')) const AdminSettings = lazy(() => import('./pages/AdminSettings'));
const AdminApps = lazy(() => import('./pages/AdminApps')) const AdminApps = lazy(() => import('./pages/AdminApps'));
const AdminWheel = lazy(() => import('./pages/AdminWheel')) const AdminWheel = lazy(() => import('./pages/AdminWheel'));
const AdminTariffs = lazy(() => import('./pages/AdminTariffs')) const AdminTariffs = lazy(() => import('./pages/AdminTariffs'));
const AdminServers = lazy(() => import('./pages/AdminServers')) const AdminServers = lazy(() => import('./pages/AdminServers'));
const AdminDashboard = lazy(() => import('./pages/AdminDashboard')) const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
const AdminBanSystem = lazy(() => import('./pages/AdminBanSystem')) const AdminBanSystem = lazy(() => import('./pages/AdminBanSystem'));
const AdminBroadcasts = lazy(() => import('./pages/AdminBroadcasts')) const AdminBroadcasts = lazy(() => import('./pages/AdminBroadcasts'));
const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes')) const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes'));
const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns')) const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns'));
const AdminUsers = lazy(() => import('./pages/AdminUsers')) const AdminUsers = lazy(() => import('./pages/AdminUsers'));
const AdminPayments = lazy(() => import('./pages/AdminPayments')) const AdminPayments = lazy(() => import('./pages/AdminPayments'));
const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods')) const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'));
const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers')) const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers'));
const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave')) const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave'));
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates')) const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'));
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuthStore() const { isAuthenticated, isLoading } = useAuthStore();
const location = useLocation() const location = useLocation();
if (isLoading) { if (isLoading) {
return <PageLoader variant="dark" /> return <PageLoader variant="dark" />;
} }
if (!isAuthenticated) { if (!isAuthenticated) {
// Сохраняем текущий URL для возврата после авторизации // Сохраняем текущий URL для возврата после авторизации
saveReturnUrl() saveReturnUrl();
return <Navigate to="/login" replace state={{ from: location.pathname }} /> return <Navigate to="/login" replace state={{ from: location.pathname }} />;
} }
return <Layout>{children}</Layout> return <Layout>{children}</Layout>;
} }
function AdminRoute({ children }: { children: React.ReactNode }) { function AdminRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading, isAdmin } = useAuthStore() const { isAuthenticated, isLoading, isAdmin } = useAuthStore();
const location = useLocation() const location = useLocation();
if (isLoading) { if (isLoading) {
return <PageLoader variant="light" /> return <PageLoader variant="light" />;
} }
if (!isAuthenticated) { if (!isAuthenticated) {
// Сохраняем текущий URL для возврата после авторизации // Сохраняем текущий URL для возврата после авторизации
saveReturnUrl() saveReturnUrl();
return <Navigate to="/login" replace state={{ from: location.pathname }} /> return <Navigate to="/login" replace state={{ from: location.pathname }} />;
} }
if (!isAdmin) { if (!isAdmin) {
return <Navigate to="/" replace /> return <Navigate to="/" replace />;
} }
return <Layout>{children}</Layout> return <Layout>{children}</Layout>;
} }
// Suspense wrapper for lazy components // Suspense wrapper for lazy components
function LazyPage({ children }: { children: React.ReactNode }) { function LazyPage({ children }: { children: React.ReactNode }) {
return ( return <Suspense fallback={<PageLoader variant="dark" />}>{children}</Suspense>;
<Suspense fallback={<PageLoader variant="dark" />}>
{children}
</Suspense>
)
} }
function BlockingOverlay() { function BlockingOverlay() {
const { blockingType } = useBlockingStore() const { blockingType } = useBlockingStore();
if (blockingType === 'maintenance') { if (blockingType === 'maintenance') {
return <MaintenanceScreen /> return <MaintenanceScreen />;
} }
if (blockingType === 'channel_subscription') { if (blockingType === 'channel_subscription') {
return <ChannelSubscriptionScreen /> return <ChannelSubscriptionScreen />;
} }
return null return null;
} }
function App() { function App() {
useAnalyticsCounters() useAnalyticsCounters();
return ( return (
<> <>
@@ -131,7 +127,9 @@ function App() {
path="/" path="/"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<LazyPage><Dashboard /></LazyPage> <LazyPage>
<Dashboard />
</LazyPage>
</ProtectedRoute> </ProtectedRoute>
} }
/> />
@@ -139,7 +137,9 @@ function App() {
path="/subscription" path="/subscription"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<LazyPage><Subscription /></LazyPage> <LazyPage>
<Subscription />
</LazyPage>
</ProtectedRoute> </ProtectedRoute>
} }
/> />
@@ -147,7 +147,9 @@ function App() {
path="/balance" path="/balance"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<LazyPage><Balance /></LazyPage> <LazyPage>
<Balance />
</LazyPage>
</ProtectedRoute> </ProtectedRoute>
} }
/> />
@@ -155,7 +157,9 @@ function App() {
path="/referral" path="/referral"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<LazyPage><Referral /></LazyPage> <LazyPage>
<Referral />
</LazyPage>
</ProtectedRoute> </ProtectedRoute>
} }
/> />
@@ -163,7 +167,9 @@ function App() {
path="/support" path="/support"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<LazyPage><Support /></LazyPage> <LazyPage>
<Support />
</LazyPage>
</ProtectedRoute> </ProtectedRoute>
} }
/> />
@@ -171,7 +177,9 @@ function App() {
path="/profile" path="/profile"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<LazyPage><Profile /></LazyPage> <LazyPage>
<Profile />
</LazyPage>
</ProtectedRoute> </ProtectedRoute>
} }
/> />
@@ -179,7 +187,9 @@ function App() {
path="/contests" path="/contests"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<LazyPage><Contests /></LazyPage> <LazyPage>
<Contests />
</LazyPage>
</ProtectedRoute> </ProtectedRoute>
} }
/> />
@@ -187,7 +197,9 @@ function App() {
path="/polls" path="/polls"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<LazyPage><Polls /></LazyPage> <LazyPage>
<Polls />
</LazyPage>
</ProtectedRoute> </ProtectedRoute>
} }
/> />
@@ -195,7 +207,9 @@ function App() {
path="/info" path="/info"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<LazyPage><Info /></LazyPage> <LazyPage>
<Info />
</LazyPage>
</ProtectedRoute> </ProtectedRoute>
} }
/> />
@@ -203,7 +217,9 @@ function App() {
path="/wheel" path="/wheel"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<LazyPage><Wheel /></LazyPage> <LazyPage>
<Wheel />
</LazyPage>
</ProtectedRoute> </ProtectedRoute>
} }
/> />
@@ -213,7 +229,9 @@ function App() {
path="/admin" path="/admin"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminPanel /></LazyPage> <LazyPage>
<AdminPanel />
</LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
@@ -221,7 +239,9 @@ function App() {
path="/admin/tickets" path="/admin/tickets"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminTickets /></LazyPage> <LazyPage>
<AdminTickets />
</LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
@@ -229,7 +249,9 @@ function App() {
path="/admin/settings" path="/admin/settings"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminSettings /></LazyPage> <LazyPage>
<AdminSettings />
</LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
@@ -237,7 +259,9 @@ function App() {
path="/admin/apps" path="/admin/apps"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminApps /></LazyPage> <LazyPage>
<AdminApps />
</LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
@@ -245,7 +269,9 @@ function App() {
path="/admin/wheel" path="/admin/wheel"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminWheel /></LazyPage> <LazyPage>
<AdminWheel />
</LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
@@ -253,7 +279,9 @@ function App() {
path="/admin/tariffs" path="/admin/tariffs"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminTariffs /></LazyPage> <LazyPage>
<AdminTariffs />
</LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
@@ -261,7 +289,9 @@ function App() {
path="/admin/servers" path="/admin/servers"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminServers /></LazyPage> <LazyPage>
<AdminServers />
</LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
@@ -269,7 +299,9 @@ function App() {
path="/admin/dashboard" path="/admin/dashboard"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminDashboard /></LazyPage> <LazyPage>
<AdminDashboard />
</LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
@@ -277,7 +309,9 @@ function App() {
path="/admin/ban-system" path="/admin/ban-system"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminBanSystem /></LazyPage> <LazyPage>
<AdminBanSystem />
</LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
@@ -285,7 +319,9 @@ function App() {
path="/admin/broadcasts" path="/admin/broadcasts"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminBroadcasts /></LazyPage> <LazyPage>
<AdminBroadcasts />
</LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
@@ -293,7 +329,9 @@ function App() {
path="/admin/promocodes" path="/admin/promocodes"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminPromocodes /></LazyPage> <LazyPage>
<AdminPromocodes />
</LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
@@ -301,7 +339,9 @@ function App() {
path="/admin/campaigns" path="/admin/campaigns"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminCampaigns /></LazyPage> <LazyPage>
<AdminCampaigns />
</LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
@@ -309,7 +349,9 @@ function App() {
path="/admin/users" path="/admin/users"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminUsers /></LazyPage> <LazyPage>
<AdminUsers />
</LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
@@ -317,7 +359,9 @@ function App() {
path="/admin/payments" path="/admin/payments"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminPayments /></LazyPage> <LazyPage>
<AdminPayments />
</LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
@@ -325,7 +369,9 @@ function App() {
path="/admin/payment-methods" path="/admin/payment-methods"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminPaymentMethods /></LazyPage> <LazyPage>
<AdminPaymentMethods />
</LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
@@ -333,7 +379,9 @@ function App() {
path="/admin/promo-offers" path="/admin/promo-offers"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminPromoOffers /></LazyPage> <LazyPage>
<AdminPromoOffers />
</LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
@@ -341,7 +389,9 @@ function App() {
path="/admin/remnawave" path="/admin/remnawave"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminRemnawave /></LazyPage> <LazyPage>
<AdminRemnawave />
</LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
@@ -349,7 +399,9 @@ function App() {
path="/admin/email-templates" path="/admin/email-templates"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminEmailTemplates /></LazyPage> <LazyPage>
<AdminEmailTemplates />
</LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
@@ -358,7 +410,7 @@ function App() {
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</> </>
) );
} }
export default App export default App;

View File

@@ -1,306 +1,310 @@
import apiClient from './client' import apiClient from './client';
export interface AdminTicketUser { export interface AdminTicketUser {
id: number id: number;
telegram_id: number telegram_id: number;
username: string | null username: string | null;
first_name: string | null first_name: string | null;
last_name: string | null last_name: string | null;
} }
export interface AdminTicketMessage { export interface AdminTicketMessage {
id: number id: number;
message_text: string message_text: string;
is_from_admin: boolean is_from_admin: boolean;
has_media: boolean has_media: boolean;
media_type: string | null media_type: string | null;
media_file_id: string | null media_file_id: string | null;
media_caption: string | null media_caption: string | null;
created_at: string created_at: string;
} }
export interface AdminTicket { export interface AdminTicket {
id: number id: number;
title: string title: string;
status: string status: string;
priority: string priority: string;
created_at: string created_at: string;
updated_at: string updated_at: string;
closed_at: string | null closed_at: string | null;
messages_count: number messages_count: number;
user: AdminTicketUser | null user: AdminTicketUser | null;
last_message: AdminTicketMessage | null last_message: AdminTicketMessage | null;
} }
export interface AdminTicketDetail { export interface AdminTicketDetail {
id: number id: number;
title: string title: string;
status: string status: string;
priority: string priority: string;
created_at: string created_at: string;
updated_at: string updated_at: string;
closed_at: string | null closed_at: string | null;
is_reply_blocked: boolean is_reply_blocked: boolean;
user: AdminTicketUser | null user: AdminTicketUser | null;
messages: AdminTicketMessage[] messages: AdminTicketMessage[];
} }
export interface AdminTicketStats { export interface AdminTicketStats {
total: number total: number;
open: number open: number;
pending: number pending: number;
answered: number answered: number;
closed: number closed: number;
} }
export interface TicketSettings { export interface TicketSettings {
sla_enabled: boolean sla_enabled: boolean;
sla_minutes: number sla_minutes: number;
sla_check_interval_seconds: number sla_check_interval_seconds: number;
sla_reminder_cooldown_minutes: number sla_reminder_cooldown_minutes: number;
support_system_mode: string // tickets, contact, both support_system_mode: string; // tickets, contact, both
cabinet_user_notifications_enabled: boolean cabinet_user_notifications_enabled: boolean;
cabinet_admin_notifications_enabled: boolean cabinet_admin_notifications_enabled: boolean;
} }
export interface TicketSettingsUpdate { export interface TicketSettingsUpdate {
sla_enabled?: boolean sla_enabled?: boolean;
sla_minutes?: number sla_minutes?: number;
sla_check_interval_seconds?: number sla_check_interval_seconds?: number;
sla_reminder_cooldown_minutes?: number sla_reminder_cooldown_minutes?: number;
support_system_mode?: string support_system_mode?: string;
cabinet_user_notifications_enabled?: boolean cabinet_user_notifications_enabled?: boolean;
cabinet_admin_notifications_enabled?: boolean cabinet_admin_notifications_enabled?: boolean;
} }
export interface AdminTicketListResponse { export interface AdminTicketListResponse {
items: AdminTicket[] items: AdminTicket[];
total: number total: number;
page: number page: number;
per_page: number per_page: number;
pages: number pages: number;
} }
export const adminApi = { export const adminApi = {
// Check if current user is admin // Check if current user is admin
checkIsAdmin: async (): Promise<{ is_admin: boolean }> => { checkIsAdmin: async (): Promise<{ is_admin: boolean }> => {
const response = await apiClient.get('/cabinet/auth/me/is-admin') const response = await apiClient.get('/cabinet/auth/me/is-admin');
return response.data return response.data;
}, },
// Get ticket statistics // Get ticket statistics
getTicketStats: async (): Promise<AdminTicketStats> => { getTicketStats: async (): Promise<AdminTicketStats> => {
const response = await apiClient.get('/cabinet/admin/tickets/stats') const response = await apiClient.get('/cabinet/admin/tickets/stats');
return response.data return response.data;
}, },
// Get all tickets // Get all tickets
getTickets: async (params: { getTickets: async (
page?: number params: {
per_page?: number page?: number;
status?: string per_page?: number;
priority?: string status?: string;
} = {}): Promise<AdminTicketListResponse> => { priority?: string;
const response = await apiClient.get('/cabinet/admin/tickets', { params }) } = {},
return response.data ): Promise<AdminTicketListResponse> => {
const response = await apiClient.get('/cabinet/admin/tickets', { params });
return response.data;
}, },
// Get single ticket with messages // Get single ticket with messages
getTicket: async (ticketId: number): Promise<AdminTicketDetail> => { getTicket: async (ticketId: number): Promise<AdminTicketDetail> => {
const response = await apiClient.get(`/cabinet/admin/tickets/${ticketId}`) const response = await apiClient.get(`/cabinet/admin/tickets/${ticketId}`);
return response.data return response.data;
}, },
// Reply to ticket // Reply to ticket
replyToTicket: async (ticketId: number, message: string): Promise<AdminTicketMessage> => { replyToTicket: async (ticketId: number, message: string): Promise<AdminTicketMessage> => {
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, { message }) const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, { message });
return response.data return response.data;
}, },
// Update ticket status // Update ticket status
updateTicketStatus: async (ticketId: number, status: string): Promise<AdminTicketDetail> => { updateTicketStatus: async (ticketId: number, status: string): Promise<AdminTicketDetail> => {
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/status`, { status }) const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/status`, { status });
return response.data return response.data;
}, },
// Update ticket priority // Update ticket priority
updateTicketPriority: async (ticketId: number, priority: string): Promise<AdminTicketDetail> => { updateTicketPriority: async (ticketId: number, priority: string): Promise<AdminTicketDetail> => {
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/priority`, { priority }) const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/priority`, {
return response.data priority,
});
return response.data;
}, },
// Get ticket settings // Get ticket settings
getTicketSettings: async (): Promise<TicketSettings> => { getTicketSettings: async (): Promise<TicketSettings> => {
const response = await apiClient.get('/cabinet/admin/tickets/settings') const response = await apiClient.get('/cabinet/admin/tickets/settings');
return response.data return response.data;
}, },
// Update ticket settings // Update ticket settings
updateTicketSettings: async (settings: TicketSettingsUpdate): Promise<TicketSettings> => { updateTicketSettings: async (settings: TicketSettingsUpdate): Promise<TicketSettings> => {
const response = await apiClient.patch('/cabinet/admin/tickets/settings', settings) const response = await apiClient.patch('/cabinet/admin/tickets/settings', settings);
return response.data return response.data;
}, },
} };
// ============ Dashboard Stats Types ============ // ============ Dashboard Stats Types ============
export interface NodeStatus { export interface NodeStatus {
uuid: string uuid: string;
name: string name: string;
address: string address: string;
is_connected: boolean is_connected: boolean;
is_disabled: boolean is_disabled: boolean;
users_online: number users_online: number;
traffic_used_bytes?: number traffic_used_bytes?: number;
uptime?: string uptime?: string;
xray_version?: string xray_version?: string;
node_version?: string node_version?: string;
last_status_message?: string last_status_message?: string;
xray_uptime?: string xray_uptime?: string;
is_xray_running?: boolean is_xray_running?: boolean;
cpu_count?: number cpu_count?: number;
cpu_model?: string cpu_model?: string;
total_ram?: string total_ram?: string;
country_code?: string country_code?: string;
} }
export interface NodesOverview { export interface NodesOverview {
total: number total: number;
online: number online: number;
offline: number offline: number;
disabled: number disabled: number;
total_users_online: number total_users_online: number;
nodes: NodeStatus[] nodes: NodeStatus[];
} }
export interface RevenueData { export interface RevenueData {
date: string date: string;
amount_kopeks: number amount_kopeks: number;
amount_rubles: number amount_rubles: number;
} }
export interface SubscriptionStats { export interface SubscriptionStats {
total: number total: number;
active: number active: number;
trial: number trial: number;
paid: number paid: number;
expired: number expired: number;
purchased_today: number purchased_today: number;
purchased_week: number purchased_week: number;
purchased_month: number purchased_month: number;
trial_to_paid_conversion: number trial_to_paid_conversion: number;
} }
export interface FinancialStats { export interface FinancialStats {
income_today_kopeks: number income_today_kopeks: number;
income_today_rubles: number income_today_rubles: number;
income_month_kopeks: number income_month_kopeks: number;
income_month_rubles: number income_month_rubles: number;
income_total_kopeks: number income_total_kopeks: number;
income_total_rubles: number income_total_rubles: number;
subscription_income_kopeks: number subscription_income_kopeks: number;
subscription_income_rubles: number subscription_income_rubles: number;
} }
export interface ServerStats { export interface ServerStats {
total_servers: number total_servers: number;
available_servers: number available_servers: number;
servers_with_connections: number servers_with_connections: number;
total_revenue_kopeks: number total_revenue_kopeks: number;
total_revenue_rubles: number total_revenue_rubles: number;
} }
export interface TariffStatItem { export interface TariffStatItem {
tariff_id: number tariff_id: number;
tariff_name: string tariff_name: string;
active_subscriptions: number active_subscriptions: number;
trial_subscriptions: number trial_subscriptions: number;
purchased_today: number purchased_today: number;
purchased_week: number purchased_week: number;
purchased_month: number purchased_month: number;
} }
export interface TariffStats { export interface TariffStats {
tariffs: TariffStatItem[] tariffs: TariffStatItem[];
total_tariff_subscriptions: number total_tariff_subscriptions: number;
} }
export interface DashboardStats { export interface DashboardStats {
nodes: NodesOverview nodes: NodesOverview;
subscriptions: SubscriptionStats subscriptions: SubscriptionStats;
financial: FinancialStats financial: FinancialStats;
servers: ServerStats servers: ServerStats;
revenue_chart: RevenueData[] revenue_chart: RevenueData[];
tariff_stats?: TariffStats tariff_stats?: TariffStats;
} }
// ============ Extended Stats Types ============ // ============ Extended Stats Types ============
export interface TopReferrerItem { export interface TopReferrerItem {
user_id: number user_id: number;
telegram_id: number telegram_id: number;
username?: string username?: string;
display_name: string display_name: string;
invited_count: number invited_count: number;
invited_today: number invited_today: number;
invited_week: number invited_week: number;
invited_month: number invited_month: number;
earnings_today_kopeks: number earnings_today_kopeks: number;
earnings_week_kopeks: number earnings_week_kopeks: number;
earnings_month_kopeks: number earnings_month_kopeks: number;
earnings_total_kopeks: number earnings_total_kopeks: number;
} }
export interface TopReferrersResponse { export interface TopReferrersResponse {
by_earnings: TopReferrerItem[] by_earnings: TopReferrerItem[];
by_invited: TopReferrerItem[] by_invited: TopReferrerItem[];
total_referrers: number total_referrers: number;
total_referrals: number total_referrals: number;
total_earnings_kopeks: number total_earnings_kopeks: number;
} }
export interface TopCampaignItem { export interface TopCampaignItem {
id: number id: number;
name: string name: string;
start_parameter: string start_parameter: string;
bonus_type: string bonus_type: string;
is_active: boolean is_active: boolean;
registrations: number registrations: number;
conversions: number conversions: number;
conversion_rate: number conversion_rate: number;
total_revenue_kopeks: number total_revenue_kopeks: number;
avg_revenue_per_user_kopeks: number avg_revenue_per_user_kopeks: number;
created_at?: string created_at?: string;
} }
export interface TopCampaignsResponse { export interface TopCampaignsResponse {
campaigns: TopCampaignItem[] campaigns: TopCampaignItem[];
total_campaigns: number total_campaigns: number;
total_registrations: number total_registrations: number;
total_revenue_kopeks: number total_revenue_kopeks: number;
} }
export interface RecentPaymentItem { export interface RecentPaymentItem {
id: number id: number;
user_id: number user_id: number;
telegram_id: number telegram_id: number;
username?: string username?: string;
display_name: string display_name: string;
amount_kopeks: number amount_kopeks: number;
amount_rubles: number amount_rubles: number;
type: string type: string;
type_display: string type_display: string;
payment_method?: string payment_method?: string;
description?: string description?: string;
created_at: string created_at: string;
is_completed: boolean is_completed: boolean;
} }
export interface RecentPaymentsResponse { export interface RecentPaymentsResponse {
payments: RecentPaymentItem[] payments: RecentPaymentItem[];
total_count: number total_count: number;
total_today_kopeks: number total_today_kopeks: number;
total_week_kopeks: number total_week_kopeks: number;
} }
// ============ Dashboard Stats API ============ // ============ Dashboard Stats API ============
@@ -308,43 +312,51 @@ export interface RecentPaymentsResponse {
export const statsApi = { export const statsApi = {
// Get complete dashboard stats // Get complete dashboard stats
getDashboardStats: async (): Promise<DashboardStats> => { getDashboardStats: async (): Promise<DashboardStats> => {
const response = await apiClient.get('/cabinet/admin/stats/dashboard') const response = await apiClient.get('/cabinet/admin/stats/dashboard');
return response.data return response.data;
}, },
// Get nodes status // Get nodes status
getNodesStatus: async (): Promise<NodesOverview> => { getNodesStatus: async (): Promise<NodesOverview> => {
const response = await apiClient.get('/cabinet/admin/stats/nodes') const response = await apiClient.get('/cabinet/admin/stats/nodes');
return response.data return response.data;
}, },
// Restart a node // Restart a node
restartNode: async (nodeUuid: string): Promise<{ success: boolean; message: string }> => { restartNode: async (nodeUuid: string): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post(`/cabinet/admin/stats/nodes/${nodeUuid}/restart`) const response = await apiClient.post(`/cabinet/admin/stats/nodes/${nodeUuid}/restart`);
return response.data return response.data;
}, },
// Toggle node (enable/disable) // Toggle node (enable/disable)
toggleNode: async (nodeUuid: string): Promise<{ success: boolean; message: string; is_disabled: boolean }> => { toggleNode: async (
const response = await apiClient.post(`/cabinet/admin/stats/nodes/${nodeUuid}/toggle`) nodeUuid: string,
return response.data ): Promise<{ success: boolean; message: string; is_disabled: boolean }> => {
const response = await apiClient.post(`/cabinet/admin/stats/nodes/${nodeUuid}/toggle`);
return response.data;
}, },
// Get top referrers // Get top referrers
getTopReferrers: async (limit: number = 20): Promise<TopReferrersResponse> => { getTopReferrers: async (limit: number = 20): Promise<TopReferrersResponse> => {
const response = await apiClient.get('/cabinet/admin/stats/referrals/top', { params: { limit } }) const response = await apiClient.get('/cabinet/admin/stats/referrals/top', {
return response.data params: { limit },
});
return response.data;
}, },
// Get top campaigns // Get top campaigns
getTopCampaigns: async (limit: number = 20): Promise<TopCampaignsResponse> => { getTopCampaigns: async (limit: number = 20): Promise<TopCampaignsResponse> => {
const response = await apiClient.get('/cabinet/admin/stats/campaigns/top', { params: { limit } }) const response = await apiClient.get('/cabinet/admin/stats/campaigns/top', {
return response.data params: { limit },
});
return response.data;
}, },
// Get recent payments // Get recent payments
getRecentPayments: async (limit: number = 50): Promise<RecentPaymentsResponse> => { getRecentPayments: async (limit: number = 50): Promise<RecentPaymentsResponse> => {
const response = await apiClient.get('/cabinet/admin/stats/payments/recent', { params: { limit } }) const response = await apiClient.get('/cabinet/admin/stats/payments/recent', {
return response.data params: { limit },
});
return response.data;
}, },
} };

View File

@@ -1,220 +1,240 @@
import apiClient from './client' import apiClient from './client';
export interface LocalizedText { export interface LocalizedText {
en: string en: string;
ru: string ru: string;
zh?: string zh?: string;
fa?: string fa?: string;
fr?: string fr?: string;
} }
export interface AppButton { export interface AppButton {
id?: string // Unique identifier for React key (client-side only) id?: string; // Unique identifier for React key (client-side only)
buttonLink: string buttonLink: string;
buttonText: LocalizedText buttonText: LocalizedText;
} }
export interface AppStep { export interface AppStep {
description: LocalizedText description: LocalizedText;
buttons?: AppButton[] buttons?: AppButton[];
title?: LocalizedText title?: LocalizedText;
} }
export interface AppDefinition { export interface AppDefinition {
id: string id: string;
name: string name: string;
isFeatured: boolean isFeatured: boolean;
urlScheme: string urlScheme: string;
isNeedBase64Encoding?: boolean isNeedBase64Encoding?: boolean;
installationStep: AppStep installationStep: AppStep;
addSubscriptionStep: AppStep addSubscriptionStep: AppStep;
connectAndUseStep: AppStep connectAndUseStep: AppStep;
additionalBeforeAddSubscriptionStep?: AppStep additionalBeforeAddSubscriptionStep?: AppStep;
additionalAfterAddSubscriptionStep?: AppStep additionalAfterAddSubscriptionStep?: AppStep;
} }
export interface AppConfigBranding { export interface AppConfigBranding {
name: string name: string;
logoUrl: string logoUrl: string;
supportUrl: string supportUrl: string;
} }
export interface AppConfigConfig { export interface AppConfigConfig {
additionalLocales: string[] additionalLocales: string[];
branding: AppConfigBranding branding: AppConfigBranding;
} }
export interface AppConfigResponse { export interface AppConfigResponse {
config: AppConfigConfig config: AppConfigConfig;
platforms: Record<string, AppDefinition[]> platforms: Record<string, AppDefinition[]>;
} }
export const adminAppsApi = { export const adminAppsApi = {
// Get full app config // Get full app config
getConfig: async (): Promise<AppConfigResponse> => { getConfig: async (): Promise<AppConfigResponse> => {
const response = await apiClient.get<AppConfigResponse>('/cabinet/admin/apps') const response = await apiClient.get<AppConfigResponse>('/cabinet/admin/apps');
return response.data return response.data;
}, },
// Get available platforms // Get available platforms
getPlatforms: async (): Promise<string[]> => { getPlatforms: async (): Promise<string[]> => {
const response = await apiClient.get<string[]>('/cabinet/admin/apps/platforms') const response = await apiClient.get<string[]>('/cabinet/admin/apps/platforms');
return response.data return response.data;
}, },
// Get apps for a platform // Get apps for a platform
getPlatformApps: async (platform: string): Promise<AppDefinition[]> => { getPlatformApps: async (platform: string): Promise<AppDefinition[]> => {
const response = await apiClient.get<AppDefinition[]>(`/cabinet/admin/apps/platforms/${platform}`) const response = await apiClient.get<AppDefinition[]>(
return response.data `/cabinet/admin/apps/platforms/${platform}`,
);
return response.data;
}, },
// Create a new app // Create a new app
createApp: async (platform: string, app: AppDefinition): Promise<AppDefinition> => { createApp: async (platform: string, app: AppDefinition): Promise<AppDefinition> => {
const response = await apiClient.post<AppDefinition>(`/cabinet/admin/apps/platforms/${platform}`, { const response = await apiClient.post<AppDefinition>(
`/cabinet/admin/apps/platforms/${platform}`,
{
platform, platform,
app, app,
}) },
return response.data );
return response.data;
}, },
// Update an app // Update an app
updateApp: async (platform: string, appId: string, app: AppDefinition): Promise<AppDefinition> => { updateApp: async (
const response = await apiClient.put<AppDefinition>(`/cabinet/admin/apps/platforms/${platform}/${appId}`, { platform: string,
appId: string,
app: AppDefinition,
): Promise<AppDefinition> => {
const response = await apiClient.put<AppDefinition>(
`/cabinet/admin/apps/platforms/${platform}/${appId}`,
{
app, app,
}) },
return response.data );
return response.data;
}, },
// Delete an app // Delete an app
deleteApp: async (platform: string, appId: string): Promise<void> => { deleteApp: async (platform: string, appId: string): Promise<void> => {
await apiClient.delete(`/cabinet/admin/apps/platforms/${platform}/${appId}`) await apiClient.delete(`/cabinet/admin/apps/platforms/${platform}/${appId}`);
}, },
// Reorder apps // Reorder apps
reorderApps: async (platform: string, appIds: string[]): Promise<void> => { reorderApps: async (platform: string, appIds: string[]): Promise<void> => {
await apiClient.post(`/cabinet/admin/apps/platforms/${platform}/reorder`, { await apiClient.post(`/cabinet/admin/apps/platforms/${platform}/reorder`, {
app_ids: appIds, app_ids: appIds,
}) });
}, },
// Copy app to another platform // Copy app to another platform
copyApp: async (platform: string, appId: string, targetPlatform: string): Promise<{ new_id: string }> => { copyApp: async (
platform: string,
appId: string,
targetPlatform: string,
): Promise<{ new_id: string }> => {
const response = await apiClient.post<{ new_id: string; target_platform: string }>( const response = await apiClient.post<{ new_id: string; target_platform: string }>(
`/cabinet/admin/apps/platforms/${platform}/copy/${appId}?target_platform=${targetPlatform}` `/cabinet/admin/apps/platforms/${platform}/copy/${appId}?target_platform=${targetPlatform}`,
) );
return response.data return response.data;
}, },
// Get branding // Get branding
getBranding: async (): Promise<AppConfigBranding> => { getBranding: async (): Promise<AppConfigBranding> => {
const response = await apiClient.get<AppConfigBranding>('/cabinet/admin/apps/branding') const response = await apiClient.get<AppConfigBranding>('/cabinet/admin/apps/branding');
return response.data return response.data;
}, },
// Update branding // Update branding
updateBranding: async (branding: AppConfigBranding): Promise<AppConfigBranding> => { updateBranding: async (branding: AppConfigBranding): Promise<AppConfigBranding> => {
const response = await apiClient.put<AppConfigBranding>('/cabinet/admin/apps/branding', { const response = await apiClient.put<AppConfigBranding>('/cabinet/admin/apps/branding', {
branding, branding,
}) });
return response.data return response.data;
}, },
// 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',
) );
return response.data return response.data;
}, },
// Set RemnaWave config UUID // Set RemnaWave config UUID
setRemnaWaveUuid: async (uuid: string | null): Promise<{ enabled: boolean; config_uuid: string | null }> => { setRemnaWaveUuid: async (
uuid: string | null,
): Promise<{ enabled: boolean; config_uuid: string | null }> => {
const response = await apiClient.put<{ enabled: boolean; config_uuid: string | null }>( const response = await apiClient.put<{ enabled: boolean; config_uuid: string | null }>(
'/cabinet/admin/apps/remnawave/uuid', '/cabinet/admin/apps/remnawave/uuid',
{ uuid } { uuid },
) );
return response.data return response.data;
}, },
// List available RemnaWave configs // List available RemnaWave configs
listRemnaWaveConfigs: async (): Promise<{ uuid: string; name: string; view_position: number }[]> => { listRemnaWaveConfigs: async (): Promise<
{ uuid: string; name: string; view_position: number }[]
> => {
const response = await apiClient.get<{ uuid: string; name: string; view_position: number }[]>( const response = await apiClient.get<{ uuid: string; name: string; view_position: number }[]>(
'/cabinet/admin/apps/remnawave/configs' '/cabinet/admin/apps/remnawave/configs',
) );
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;
name: string name: string;
view_position: number view_position: number;
config: RemnawaveConfig config: RemnawaveConfig;
}>('/cabinet/admin/apps/remnawave/config') }>('/cabinet/admin/apps/remnawave/config');
return response.data.config return response.data.config;
}, },
} };
// ============== RemnaWave Format Types ============== // ============== RemnaWave Format Types ==============
export interface RemnawaveButton { export interface RemnawaveButton {
url: string url: string;
text: LocalizedText text: LocalizedText;
} }
export interface RemnawaveBlock { export interface RemnawaveBlock {
title: LocalizedText title: LocalizedText;
description: LocalizedText description: LocalizedText;
buttons?: RemnawaveButton[] buttons?: RemnawaveButton[];
svgIconKey?: string svgIconKey?: string;
svgIconColor?: string svgIconColor?: string;
} }
export interface RemnawaveApp { export interface RemnawaveApp {
name: string name: string;
featured?: boolean featured?: boolean;
urlScheme?: string urlScheme?: string;
isNeedBase64Encoding?: boolean isNeedBase64Encoding?: boolean;
blocks: RemnawaveBlock[] blocks: RemnawaveBlock[];
} }
export interface RemnawavePlatform { export interface RemnawavePlatform {
apps: RemnawaveApp[] apps: RemnawaveApp[];
} }
export interface RemnawaveSvgItem { export interface RemnawaveSvgItem {
svgString: string svgString: string;
tags?: string[] tags?: string[];
} }
export interface RemnawaveBaseSettings { export interface RemnawaveBaseSettings {
isShowTutorialButton: boolean isShowTutorialButton: boolean;
tutorialUrl: string tutorialUrl: string;
} }
export interface RemnawaveBaseTranslations { export interface RemnawaveBaseTranslations {
installApp: LocalizedText installApp: LocalizedText;
addSubscription: LocalizedText addSubscription: LocalizedText;
connectAndUse: LocalizedText connectAndUse: LocalizedText;
copyLink: LocalizedText copyLink: LocalizedText;
openApp: LocalizedText openApp: LocalizedText;
tutorial: LocalizedText tutorial: LocalizedText;
close: LocalizedText close: LocalizedText;
} }
export interface RemnawaveBrandingSettings { export interface RemnawaveBrandingSettings {
name: string name: string;
logoUrl: string logoUrl: string;
supportUrl: string supportUrl: string;
} }
export interface RemnawaveConfig { export interface RemnawaveConfig {
platforms: Record<string, RemnawavePlatform> platforms: Record<string, RemnawavePlatform>;
svgLibrary?: Record<string, RemnawaveSvgItem> svgLibrary?: Record<string, RemnawaveSvgItem>;
baseSettings?: RemnawaveBaseSettings baseSettings?: RemnawaveBaseSettings;
baseTranslations?: RemnawaveBaseTranslations baseTranslations?: RemnawaveBaseTranslations;
brandingSettings?: RemnawaveBrandingSettings brandingSettings?: RemnawaveBrandingSettings;
} }
// ============== Converter Functions ============== // ============== Converter Functions ==============
@@ -225,23 +245,29 @@ const emptyLocalizedText = (): LocalizedText => ({
zh: '', zh: '',
fa: '', fa: '',
fr: '', fr: '',
}) });
// Convert Cabinet button to RemnaWave button // Convert Cabinet button to RemnaWave button
const cabinetButtonToRemnawave = (button: AppButton): RemnawaveButton => ({ const cabinetButtonToRemnawave = (button: AppButton): RemnawaveButton => ({
url: button.buttonLink, url: button.buttonLink,
text: { ...emptyLocalizedText(), ...button.buttonText }, text: { ...emptyLocalizedText(), ...button.buttonText },
}) });
// Convert RemnaWave button to Cabinet button // Convert RemnaWave button to Cabinet button
const remnawaveButtonToCabinet = (button: RemnawaveButton): AppButton => ({ const remnawaveButtonToCabinet = (button: RemnawaveButton): AppButton => ({
buttonLink: button.url, buttonLink: button.url,
buttonText: { en: button.text.en || '', ru: button.text.ru || '', zh: button.text.zh, fa: button.text.fa, fr: button.text.fr }, buttonText: {
}) en: button.text.en || '',
ru: button.text.ru || '',
zh: button.text.zh,
fa: button.text.fa,
fr: button.text.fr,
},
});
// Convert Cabinet app to RemnaWave app format // Convert Cabinet app to RemnaWave app format
export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => { export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
const blocks: RemnawaveBlock[] = [] const blocks: RemnawaveBlock[] = [];
// Block 1: Installation // Block 1: Installation
blocks.push({ blocks.push({
@@ -250,17 +276,27 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
buttons: app.installationStep.buttons?.map(cabinetButtonToRemnawave), buttons: app.installationStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'download', svgIconKey: 'download',
svgIconColor: '#3B82F6', svgIconColor: '#3B82F6',
}) });
// Block 2 (optional): Additional before subscription // Block 2 (optional): Additional before subscription
if (app.additionalBeforeAddSubscriptionStep?.description?.en || app.additionalBeforeAddSubscriptionStep?.description?.ru) { if (
app.additionalBeforeAddSubscriptionStep?.description?.en ||
app.additionalBeforeAddSubscriptionStep?.description?.ru
) {
blocks.push({ blocks.push({
title: app.additionalBeforeAddSubscriptionStep.title || { ...emptyLocalizedText(), en: 'Preparation', ru: 'Подготовка' }, title: app.additionalBeforeAddSubscriptionStep.title || {
description: { ...emptyLocalizedText(), ...app.additionalBeforeAddSubscriptionStep.description }, ...emptyLocalizedText(),
en: 'Preparation',
ru: 'Подготовка',
},
description: {
...emptyLocalizedText(),
...app.additionalBeforeAddSubscriptionStep.description,
},
buttons: app.additionalBeforeAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave), buttons: app.additionalBeforeAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'settings', svgIconKey: 'settings',
svgIconColor: '#8B5CF6', svgIconColor: '#8B5CF6',
}) });
} }
// Block 3: Add subscription // Block 3: Add subscription
@@ -270,17 +306,27 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
buttons: app.addSubscriptionStep.buttons?.map(cabinetButtonToRemnawave), buttons: app.addSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'plus', svgIconKey: 'plus',
svgIconColor: '#10B981', svgIconColor: '#10B981',
}) });
// Block 4 (optional): Additional after subscription // Block 4 (optional): Additional after subscription
if (app.additionalAfterAddSubscriptionStep?.description?.en || app.additionalAfterAddSubscriptionStep?.description?.ru) { if (
app.additionalAfterAddSubscriptionStep?.description?.en ||
app.additionalAfterAddSubscriptionStep?.description?.ru
) {
blocks.push({ blocks.push({
title: app.additionalAfterAddSubscriptionStep.title || { ...emptyLocalizedText(), en: 'Configuration', ru: 'Настройка' }, title: app.additionalAfterAddSubscriptionStep.title || {
description: { ...emptyLocalizedText(), ...app.additionalAfterAddSubscriptionStep.description }, ...emptyLocalizedText(),
en: 'Configuration',
ru: 'Настройка',
},
description: {
...emptyLocalizedText(),
...app.additionalAfterAddSubscriptionStep.description,
},
buttons: app.additionalAfterAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave), buttons: app.additionalAfterAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'settings', svgIconKey: 'settings',
svgIconColor: '#F59E0B', svgIconColor: '#F59E0B',
}) });
} }
// Block 5: Connect and use // Block 5: Connect and use
@@ -290,7 +336,7 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
buttons: app.connectAndUseStep.buttons?.map(cabinetButtonToRemnawave), buttons: app.connectAndUseStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'check', svgIconKey: 'check',
svgIconColor: '#22C55E', svgIconColor: '#22C55E',
}) });
return { return {
name: app.name, name: app.name,
@@ -298,78 +344,104 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
urlScheme: app.urlScheme, urlScheme: app.urlScheme,
isNeedBase64Encoding: app.isNeedBase64Encoding, isNeedBase64Encoding: app.isNeedBase64Encoding,
blocks, blocks,
} };
} };
// Convert RemnaWave app to Cabinet app format // Convert RemnaWave app to Cabinet app format
export const remnawaveAppToCabinet = (app: RemnawaveApp, platform: string): AppDefinition => { export const remnawaveAppToCabinet = (app: RemnawaveApp, platform: string): AppDefinition => {
const blocks = app.blocks || [] const blocks = app.blocks || [];
// Default empty step // Default empty step
const emptyStep = (): AppStep => ({ const emptyStep = (): AppStep => ({
description: emptyLocalizedText(), description: emptyLocalizedText(),
buttons: [], buttons: [],
}) });
// Try to identify blocks by their titles or position // Try to identify blocks by their titles or position
let installationBlock: RemnawaveBlock | undefined let installationBlock: RemnawaveBlock | undefined;
let subscriptionBlock: RemnawaveBlock | undefined let subscriptionBlock: RemnawaveBlock | undefined;
let connectBlock: RemnawaveBlock | undefined let connectBlock: RemnawaveBlock | undefined;
let beforeSubBlock: RemnawaveBlock | undefined let beforeSubBlock: RemnawaveBlock | undefined;
let afterSubBlock: RemnawaveBlock | undefined let afterSubBlock: RemnawaveBlock | undefined;
// First pass: try to identify by title keywords // First pass: try to identify by title keywords
for (const block of blocks) { for (const block of blocks) {
const enTitle = (block.title?.en || '').toLowerCase() const enTitle = (block.title?.en || '').toLowerCase();
const ruTitle = (block.title?.ru || '').toLowerCase() const ruTitle = (block.title?.ru || '').toLowerCase();
if (enTitle.includes('install') || ruTitle.includes('установ') || ruTitle.includes('скачай')) { if (enTitle.includes('install') || ruTitle.includes('установ') || ruTitle.includes('скачай')) {
if (!installationBlock) installationBlock = block if (!installationBlock) installationBlock = block;
} else if (enTitle.includes('subscription') || enTitle.includes('add') || ruTitle.includes('подписк') || ruTitle.includes('добав')) { } else if (
if (!subscriptionBlock) subscriptionBlock = block enTitle.includes('subscription') ||
} else if (enTitle.includes('connect') || enTitle.includes('use') || ruTitle.includes('подключ') || ruTitle.includes('использ')) { enTitle.includes('add') ||
if (!connectBlock) connectBlock = block ruTitle.includes('подписк') ||
ruTitle.includes('добав')
) {
if (!subscriptionBlock) subscriptionBlock = block;
} else if (
enTitle.includes('connect') ||
enTitle.includes('use') ||
ruTitle.includes('подключ') ||
ruTitle.includes('использ')
) {
if (!connectBlock) connectBlock = block;
} }
} }
// Second pass: assign remaining blocks by position if not found by title // Second pass: assign remaining blocks by position if not found by title
if (!installationBlock && blocks.length > 0) { if (!installationBlock && blocks.length > 0) {
installationBlock = blocks[0] installationBlock = blocks[0];
} }
if (!subscriptionBlock && blocks.length > 1) { if (!subscriptionBlock && blocks.length > 1) {
subscriptionBlock = blocks.find(b => b !== installationBlock) || blocks[1] subscriptionBlock = blocks.find((b) => b !== installationBlock) || blocks[1];
} }
if (!connectBlock && blocks.length > 2) { if (!connectBlock && blocks.length > 2) {
connectBlock = blocks.find(b => b !== installationBlock && b !== subscriptionBlock) || blocks[blocks.length - 1] connectBlock =
blocks.find((b) => b !== installationBlock && b !== subscriptionBlock) ||
blocks[blocks.length - 1];
} }
// Assign additional blocks // Assign additional blocks
const additionalBlocks = blocks.filter(b => const additionalBlocks = blocks.filter(
b !== installationBlock && b !== subscriptionBlock && b !== connectBlock (b) => b !== installationBlock && b !== subscriptionBlock && b !== connectBlock,
) );
if (additionalBlocks.length >= 1) { if (additionalBlocks.length >= 1) {
// Check if block appears before subscription // Check if block appears before subscription
const subIndex = blocks.indexOf(subscriptionBlock!) const subIndex = blocks.indexOf(subscriptionBlock!);
const firstAdditionalIndex = blocks.indexOf(additionalBlocks[0]) const firstAdditionalIndex = blocks.indexOf(additionalBlocks[0]);
if (firstAdditionalIndex < subIndex) { if (firstAdditionalIndex < subIndex) {
beforeSubBlock = additionalBlocks[0] beforeSubBlock = additionalBlocks[0];
if (additionalBlocks.length >= 2) { if (additionalBlocks.length >= 2) {
afterSubBlock = additionalBlocks[1] afterSubBlock = additionalBlocks[1];
} }
} else { } else {
afterSubBlock = additionalBlocks[0] afterSubBlock = additionalBlocks[0];
} }
} }
// Convert block to cabinet step // Convert block to cabinet step
const blockToStep = (block: RemnawaveBlock | undefined): AppStep => { const blockToStep = (block: RemnawaveBlock | undefined): AppStep => {
if (!block) return emptyStep() if (!block) return emptyStep();
return { return {
description: { en: block.description?.en || '', ru: block.description?.ru || '', zh: block.description?.zh, fa: block.description?.fa, fr: block.description?.fr }, description: {
title: block.title ? { en: block.title.en || '', ru: block.title.ru || '', zh: block.title.zh, fa: block.title.fa, fr: block.title.fr } : undefined, en: block.description?.en || '',
ru: block.description?.ru || '',
zh: block.description?.zh,
fa: block.description?.fa,
fr: block.description?.fr,
},
title: block.title
? {
en: block.title.en || '',
ru: block.title.ru || '',
zh: block.title.zh,
fa: block.title.fa,
fr: block.title.fr,
}
: undefined,
buttons: block.buttons?.map(remnawaveButtonToCabinet), buttons: block.buttons?.map(remnawaveButtonToCabinet),
} };
} };
return { return {
id: `${app.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${platform}-${Date.now()}`, id: `${app.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${platform}-${Date.now()}`,
@@ -382,66 +454,114 @@ export const remnawaveAppToCabinet = (app: RemnawaveApp, platform: string): AppD
connectAndUseStep: blockToStep(connectBlock), connectAndUseStep: blockToStep(connectBlock),
additionalBeforeAddSubscriptionStep: beforeSubBlock ? blockToStep(beforeSubBlock) : undefined, additionalBeforeAddSubscriptionStep: beforeSubBlock ? blockToStep(beforeSubBlock) : undefined,
additionalAfterAddSubscriptionStep: afterSubBlock ? blockToStep(afterSubBlock) : undefined, additionalAfterAddSubscriptionStep: afterSubBlock ? blockToStep(afterSubBlock) : undefined,
} };
} };
// Export all apps from cabinet to RemnaWave format // Export all apps from cabinet to RemnaWave format
export const exportToRemnawaveFormat = ( export const exportToRemnawaveFormat = (
platformApps: Record<string, AppDefinition[]>, platformApps: Record<string, AppDefinition[]>,
branding?: AppConfigBranding branding?: AppConfigBranding,
): RemnawaveConfig => { ): RemnawaveConfig => {
const platforms: Record<string, RemnawavePlatform> = {} const platforms: Record<string, RemnawavePlatform> = {};
for (const [platform, apps] of Object.entries(platformApps)) { for (const [platform, apps] of Object.entries(platformApps)) {
platforms[platform] = { platforms[platform] = {
apps: apps.map(cabinetAppToRemnawave), apps: apps.map(cabinetAppToRemnawave),
} };
} }
return { return {
platforms, platforms,
svgLibrary: { svgLibrary: {
download: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="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>' }, download: {
plus: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' }, svgString:
check: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>' }, '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="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>',
settings: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="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 stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>' }, },
plus: {
svgString:
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>',
},
check: {
svgString:
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>',
},
settings: {
svgString:
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="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 stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>',
},
}, },
baseSettings: { baseSettings: {
isShowTutorialButton: false, isShowTutorialButton: false,
tutorialUrl: '', tutorialUrl: '',
}, },
baseTranslations: { baseTranslations: {
installApp: { en: 'Install App', ru: 'Установить приложение', zh: '安装应用', fa: 'نصب برنامه', fr: 'Installer l\'application' }, installApp: {
addSubscription: { en: 'Add Subscription', ru: 'Добавить подписку', zh: '添加订阅', fa: 'اضافه کردن اشتراک', fr: 'Ajouter un abonnement' }, en: 'Install App',
connectAndUse: { en: 'Connect and Use', ru: 'Подключиться и использовать', zh: '连接并使用', fa: 'اتصال و استفاده', fr: 'Connecter et utiliser' }, ru: 'Установить приложение',
copyLink: { en: 'Copy Link', ru: 'Скопировать ссылку', zh: '复制链接', fa: 'کپی لینک', fr: 'Copier le lien' }, zh: '安装应用',
openApp: { en: 'Open App', ru: 'Открыть приложение', zh: '打开应用', fa: 'باز کردن برنامه', fr: 'Ouvrir l\'application' }, fa: صب برنامه',
fr: "Installer l'application",
},
addSubscription: {
en: 'Add Subscription',
ru: 'Добавить подписку',
zh: '添加订阅',
fa: 'اضافه کردن اشتراک',
fr: 'Ajouter un abonnement',
},
connectAndUse: {
en: 'Connect and Use',
ru: 'Подключиться и использовать',
zh: '连接并使用',
fa: 'اتصال و استفاده',
fr: 'Connecter et utiliser',
},
copyLink: {
en: 'Copy Link',
ru: 'Скопировать ссылку',
zh: '复制链接',
fa: 'کپی لینک',
fr: 'Copier le lien',
},
openApp: {
en: 'Open App',
ru: 'Открыть приложение',
zh: '打开应用',
fa: 'باز کردن برنامه',
fr: "Ouvrir l'application",
},
tutorial: { en: 'Tutorial', ru: 'Инструкция', zh: '教程', fa: 'آموزش', fr: 'Tutoriel' }, tutorial: { en: 'Tutorial', ru: 'Инструкция', zh: '教程', fa: 'آموزش', fr: 'Tutoriel' },
close: { en: 'Close', ru: 'Закрыть', zh: '关闭', fa: 'بستن', fr: 'Fermer' }, close: { en: 'Close', ru: 'Закрыть', zh: '关闭', fa: 'بستن', fr: 'Fermer' },
}, },
brandingSettings: branding ? { brandingSettings: branding
? {
name: branding.name, name: branding.name,
logoUrl: branding.logoUrl, logoUrl: branding.logoUrl,
supportUrl: branding.supportUrl, supportUrl: branding.supportUrl,
} : undefined,
} }
} : undefined,
};
};
// Import apps from RemnaWave format to cabinet // Import apps from RemnaWave format to cabinet
export const importFromRemnawaveFormat = ( export const importFromRemnawaveFormat = (
config: RemnawaveConfig config: RemnawaveConfig,
): { platformApps: Record<string, AppDefinition[]>; branding?: AppConfigBranding } => { ): { platformApps: Record<string, AppDefinition[]>; branding?: AppConfigBranding } => {
const platformApps: Record<string, AppDefinition[]> = {} const platformApps: Record<string, AppDefinition[]> = {};
for (const [platform, platformData] of Object.entries(config.platforms || {})) { for (const [platform, platformData] of Object.entries(config.platforms || {})) {
platformApps[platform] = (platformData.apps || []).map(app => remnawaveAppToCabinet(app, platform)) platformApps[platform] = (platformData.apps || []).map((app) =>
remnawaveAppToCabinet(app, platform),
);
} }
const branding = config.brandingSettings ? { const branding = config.brandingSettings
? {
name: config.brandingSettings.name, name: config.brandingSettings.name,
logoUrl: config.brandingSettings.logoUrl, logoUrl: config.brandingSettings.logoUrl,
supportUrl: config.brandingSettings.supportUrl, supportUrl: config.brandingSettings.supportUrl,
} : undefined }
: undefined;
return { platformApps, branding } return { platformApps, branding };
} };

View File

@@ -1,168 +1,187 @@
import apiClient from './client' import apiClient from './client';
// Types // Types
export interface BroadcastFilter { export interface BroadcastFilter {
key: string key: string;
label: string label: string;
count: number | null count: number | null;
group: string | null group: string | null;
} }
export interface TariffFilter { export interface TariffFilter {
key: string key: string;
label: string label: string;
tariff_id: number tariff_id: number;
count: number count: number;
} }
export interface BroadcastFiltersResponse { export interface BroadcastFiltersResponse {
filters: BroadcastFilter[] filters: BroadcastFilter[];
tariff_filters: TariffFilter[] tariff_filters: TariffFilter[];
custom_filters: BroadcastFilter[] custom_filters: BroadcastFilter[];
} }
export interface TariffForBroadcast { export interface TariffForBroadcast {
id: number id: number;
name: string name: string;
filter_key: string filter_key: string;
active_users_count: number active_users_count: number;
} }
export interface BroadcastTariffsResponse { export interface BroadcastTariffsResponse {
tariffs: TariffForBroadcast[] tariffs: TariffForBroadcast[];
} }
export interface BroadcastButton { export interface BroadcastButton {
key: string key: string;
label: string label: string;
default: boolean default: boolean;
} }
export interface BroadcastButtonsResponse { export interface BroadcastButtonsResponse {
buttons: BroadcastButton[] buttons: BroadcastButton[];
} }
export interface BroadcastMedia { export interface BroadcastMedia {
type: 'photo' | 'video' | 'document' type: 'photo' | 'video' | 'document';
file_id: string file_id: string;
caption?: string caption?: string;
} }
export interface BroadcastCreateRequest { export interface BroadcastCreateRequest {
target: string target: string;
message_text: string message_text: string;
selected_buttons: string[] selected_buttons: string[];
media?: BroadcastMedia media?: BroadcastMedia;
} }
export interface Broadcast { export interface Broadcast {
id: number id: number;
target_type: string target_type: string;
message_text: string message_text: string;
has_media: boolean has_media: boolean;
media_type: string | null media_type: string | null;
media_file_id: string | null media_file_id: string | null;
media_caption: string | null media_caption: string | null;
total_count: number total_count: number;
sent_count: number sent_count: number;
failed_count: number failed_count: number;
status: 'queued' | 'in_progress' | 'completed' | 'partial' | 'failed' | 'cancelled' | 'cancelling' status:
admin_id: number | null | 'queued'
admin_name: string | null | 'in_progress'
created_at: string | 'completed'
completed_at: string | null | 'partial'
progress_percent: number | 'failed'
| 'cancelled'
| 'cancelling';
admin_id: number | null;
admin_name: string | null;
created_at: string;
completed_at: string | null;
progress_percent: number;
} }
export interface BroadcastListResponse { export interface BroadcastListResponse {
items: Broadcast[] items: Broadcast[];
total: number total: number;
limit: number limit: number;
offset: number offset: number;
} }
export interface BroadcastPreviewRequest { export interface BroadcastPreviewRequest {
target: string target: string;
} }
export interface BroadcastPreviewResponse { export interface BroadcastPreviewResponse {
target: string target: string;
count: number count: number;
} }
export interface MediaUploadResponse { export interface MediaUploadResponse {
media_type: string media_type: string;
file_id: string file_id: string;
file_unique_id: string | null file_unique_id: string | null;
media_url: string media_url: string;
} }
export const adminBroadcastsApi = { export const adminBroadcastsApi = {
// Get all available filters with counts // Get all available filters with counts
getFilters: async (): Promise<BroadcastFiltersResponse> => { getFilters: async (): Promise<BroadcastFiltersResponse> => {
const response = await apiClient.get<BroadcastFiltersResponse>('/cabinet/admin/broadcasts/filters') const response = await apiClient.get<BroadcastFiltersResponse>(
return response.data '/cabinet/admin/broadcasts/filters',
);
return response.data;
}, },
// Get tariffs for filtering // Get tariffs for filtering
getTariffs: async (): Promise<BroadcastTariffsResponse> => { getTariffs: async (): Promise<BroadcastTariffsResponse> => {
const response = await apiClient.get<BroadcastTariffsResponse>('/cabinet/admin/broadcasts/tariffs') const response = await apiClient.get<BroadcastTariffsResponse>(
return response.data '/cabinet/admin/broadcasts/tariffs',
);
return response.data;
}, },
// Get available buttons // Get available buttons
getButtons: async (): Promise<BroadcastButtonsResponse> => { getButtons: async (): Promise<BroadcastButtonsResponse> => {
const response = await apiClient.get<BroadcastButtonsResponse>('/cabinet/admin/broadcasts/buttons') const response = await apiClient.get<BroadcastButtonsResponse>(
return response.data '/cabinet/admin/broadcasts/buttons',
);
return response.data;
}, },
// Preview broadcast (get recipients count) // Preview broadcast (get recipients count)
preview: async (target: string): Promise<BroadcastPreviewResponse> => { preview: async (target: string): Promise<BroadcastPreviewResponse> => {
const response = await apiClient.post<BroadcastPreviewResponse>('/cabinet/admin/broadcasts/preview', { const response = await apiClient.post<BroadcastPreviewResponse>(
'/cabinet/admin/broadcasts/preview',
{
target, target,
}) },
return response.data );
return response.data;
}, },
// Create and start broadcast // Create and start broadcast
create: async (data: BroadcastCreateRequest): Promise<Broadcast> => { create: async (data: BroadcastCreateRequest): Promise<Broadcast> => {
const response = await apiClient.post<Broadcast>('/cabinet/admin/broadcasts', data) const response = await apiClient.post<Broadcast>('/cabinet/admin/broadcasts', data);
return response.data return response.data;
}, },
// Get list of broadcasts // Get list of broadcasts
list: async (limit = 20, offset = 0): Promise<BroadcastListResponse> => { list: async (limit = 20, offset = 0): Promise<BroadcastListResponse> => {
const response = await apiClient.get<BroadcastListResponse>('/cabinet/admin/broadcasts', { const response = await apiClient.get<BroadcastListResponse>('/cabinet/admin/broadcasts', {
params: { limit, offset }, params: { limit, offset },
}) });
return response.data return response.data;
}, },
// Get broadcast details // Get broadcast details
get: async (id: number): Promise<Broadcast> => { get: async (id: number): Promise<Broadcast> => {
const response = await apiClient.get<Broadcast>(`/cabinet/admin/broadcasts/${id}`) const response = await apiClient.get<Broadcast>(`/cabinet/admin/broadcasts/${id}`);
return response.data return response.data;
}, },
// Stop broadcast // Stop broadcast
stop: async (id: number): Promise<Broadcast> => { stop: async (id: number): Promise<Broadcast> => {
const response = await apiClient.post<Broadcast>(`/cabinet/admin/broadcasts/${id}/stop`) const response = await apiClient.post<Broadcast>(`/cabinet/admin/broadcasts/${id}/stop`);
return response.data return response.data;
}, },
// Upload media (uses existing media endpoint) // Upload media (uses existing media endpoint)
uploadMedia: async (file: File, mediaType: 'photo' | 'video' | 'document'): Promise<MediaUploadResponse> => { uploadMedia: async (
const formData = new FormData() file: File,
formData.append('file', file) mediaType: 'photo' | 'video' | 'document',
formData.append('media_type', mediaType) ): Promise<MediaUploadResponse> => {
const formData = new FormData();
formData.append('file', file);
formData.append('media_type', mediaType);
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData, { const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
}) });
return response.data return response.data;
}, },
} };
export default adminBroadcastsApi export default adminBroadcastsApi;

View File

@@ -1,85 +1,105 @@
import apiClient from './client' import apiClient from './client';
export interface EmailTemplateLanguageStatus { export interface EmailTemplateLanguageStatus {
has_custom: boolean has_custom: boolean;
} }
export interface EmailTemplateType { export interface EmailTemplateType {
type: string type: string;
label: Record<string, string> label: Record<string, string>;
description: Record<string, string> description: Record<string, string>;
context_vars: string[] context_vars: string[];
languages: Record<string, EmailTemplateLanguageStatus> languages: Record<string, EmailTemplateLanguageStatus>;
} }
export interface EmailTemplateListResponse { export interface EmailTemplateListResponse {
items: EmailTemplateType[] items: EmailTemplateType[];
available_languages: string[] available_languages: string[];
} }
export interface EmailTemplateLanguageData { export interface EmailTemplateLanguageData {
subject: string subject: string;
body_html: string body_html: string;
is_default: boolean is_default: boolean;
default_subject: string default_subject: string;
default_body_html: string default_body_html: string;
} }
export interface EmailTemplateDetail { export interface EmailTemplateDetail {
notification_type: string notification_type: string;
label: Record<string, string> label: Record<string, string>;
description: Record<string, string> description: Record<string, string>;
context_vars: string[] context_vars: string[];
languages: Record<string, EmailTemplateLanguageData> languages: Record<string, EmailTemplateLanguageData>;
} }
export interface EmailTemplateUpdateRequest { export interface EmailTemplateUpdateRequest {
subject: string subject: string;
body_html: string body_html: string;
} }
export interface EmailTemplatePreviewRequest { export interface EmailTemplatePreviewRequest {
language: string language: string;
subject?: string subject?: string;
body_html?: string body_html?: string;
} }
export interface EmailTemplatePreviewResponse { export interface EmailTemplatePreviewResponse {
subject: string subject: string;
body_html: string body_html: string;
} }
export interface EmailTemplateSendTestRequest { export interface EmailTemplateSendTestRequest {
language: string language: string;
email?: string email?: string;
} }
export const adminEmailTemplatesApi = { export const adminEmailTemplatesApi = {
getTemplateTypes: async (): Promise<EmailTemplateListResponse> => { getTemplateTypes: async (): Promise<EmailTemplateListResponse> => {
const response = await apiClient.get<EmailTemplateListResponse>('/cabinet/admin/email-templates') const response = await apiClient.get<EmailTemplateListResponse>(
return response.data '/cabinet/admin/email-templates',
);
return response.data;
}, },
getTemplate: async (notificationType: string): Promise<EmailTemplateDetail> => { getTemplate: async (notificationType: string): Promise<EmailTemplateDetail> => {
const response = await apiClient.get<EmailTemplateDetail>(`/cabinet/admin/email-templates/${notificationType}`) const response = await apiClient.get<EmailTemplateDetail>(
return response.data `/cabinet/admin/email-templates/${notificationType}`,
);
return response.data;
}, },
updateTemplate: async (notificationType: string, language: string, data: EmailTemplateUpdateRequest): Promise<void> => { updateTemplate: async (
await apiClient.put(`/cabinet/admin/email-templates/${notificationType}/${language}`, data) notificationType: string,
language: string,
data: EmailTemplateUpdateRequest,
): Promise<void> => {
await apiClient.put(`/cabinet/admin/email-templates/${notificationType}/${language}`, data);
}, },
deleteTemplate: async (notificationType: string, language: string): Promise<void> => { deleteTemplate: async (notificationType: string, language: string): Promise<void> => {
await apiClient.delete(`/cabinet/admin/email-templates/${notificationType}/${language}`) await apiClient.delete(`/cabinet/admin/email-templates/${notificationType}/${language}`);
}, },
previewTemplate: async (notificationType: string, data: EmailTemplatePreviewRequest): Promise<EmailTemplatePreviewResponse> => { previewTemplate: async (
const response = await apiClient.post<EmailTemplatePreviewResponse>(`/cabinet/admin/email-templates/${notificationType}/preview`, data) notificationType: string,
return response.data data: EmailTemplatePreviewRequest,
): Promise<EmailTemplatePreviewResponse> => {
const response = await apiClient.post<EmailTemplatePreviewResponse>(
`/cabinet/admin/email-templates/${notificationType}/preview`,
data,
);
return response.data;
}, },
sendTestEmail: async (notificationType: string, data: EmailTemplateSendTestRequest): Promise<{ sent_to: string }> => { sendTestEmail: async (
const response = await apiClient.post<{ status: string; sent_to: string }>(`/cabinet/admin/email-templates/${notificationType}/test`, data) notificationType: string,
return response.data data: EmailTemplateSendTestRequest,
): Promise<{ sent_to: string }> => {
const response = await apiClient.post<{ status: string; sent_to: string }>(
`/cabinet/admin/email-templates/${notificationType}/test`,
data,
);
return response.data;
}, },
} };

View File

@@ -1,31 +1,35 @@
import apiClient from './client' import apiClient from './client';
import type { PaymentMethodConfig, PromoGroupSimple } from '../types' import type { PaymentMethodConfig, PromoGroupSimple } from '../types';
export const adminPaymentMethodsApi = { export const adminPaymentMethodsApi = {
getAll: async (): Promise<PaymentMethodConfig[]> => { getAll: async (): Promise<PaymentMethodConfig[]> => {
const response = await apiClient.get<PaymentMethodConfig[]>('/cabinet/admin/payment-methods') const response = await apiClient.get<PaymentMethodConfig[]>('/cabinet/admin/payment-methods');
return response.data return response.data;
}, },
getOne: async (methodId: string): Promise<PaymentMethodConfig> => { getOne: async (methodId: string): Promise<PaymentMethodConfig> => {
const response = await apiClient.get<PaymentMethodConfig>(`/cabinet/admin/payment-methods/${methodId}`) const response = await apiClient.get<PaymentMethodConfig>(
return response.data `/cabinet/admin/payment-methods/${methodId}`,
);
return response.data;
}, },
update: async (methodId: string, data: Record<string, unknown>): Promise<PaymentMethodConfig> => { update: async (methodId: string, data: Record<string, unknown>): Promise<PaymentMethodConfig> => {
const response = await apiClient.put<PaymentMethodConfig>( const response = await apiClient.put<PaymentMethodConfig>(
`/cabinet/admin/payment-methods/${methodId}`, `/cabinet/admin/payment-methods/${methodId}`,
data data,
) );
return response.data return response.data;
}, },
updateOrder: async (methodIds: string[]): Promise<void> => { updateOrder: async (methodIds: string[]): Promise<void> => {
await apiClient.put('/cabinet/admin/payment-methods/order', { method_ids: methodIds }) await apiClient.put('/cabinet/admin/payment-methods/order', { method_ids: methodIds });
}, },
getPromoGroups: async (): Promise<PromoGroupSimple[]> => { getPromoGroups: async (): Promise<PromoGroupSimple[]> => {
const response = await apiClient.get<PromoGroupSimple[]>('/cabinet/admin/payment-methods/promo-groups') const response = await apiClient.get<PromoGroupSimple[]>(
return response.data '/cabinet/admin/payment-methods/promo-groups',
);
return response.data;
}, },
} };

View File

@@ -1,39 +1,46 @@
import apiClient from './client' import apiClient from './client';
import type { PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types' import type { PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types';
export interface PaymentsStats { export interface PaymentsStats {
total_pending: number total_pending: number;
by_method: Record<string, number> by_method: Record<string, number>;
} }
export const adminPaymentsApi = { export const adminPaymentsApi = {
// Get all pending payments (admin) // Get all pending payments (admin)
getPendingPayments: async (params?: { getPendingPayments: async (params?: {
page?: number page?: number;
per_page?: number per_page?: number;
method_filter?: string method_filter?: string;
}): Promise<PaginatedResponse<PendingPayment>> => { }): Promise<PaginatedResponse<PendingPayment>> => {
const response = await apiClient.get<PaginatedResponse<PendingPayment>>('/cabinet/admin/payments', { const response = await apiClient.get<PaginatedResponse<PendingPayment>>(
'/cabinet/admin/payments',
{
params, params,
}) },
return response.data );
return response.data;
}, },
// Get payments statistics // Get payments statistics
getStats: async (): Promise<PaymentsStats> => { getStats: async (): Promise<PaymentsStats> => {
const response = await apiClient.get<PaymentsStats>('/cabinet/admin/payments/stats') const response = await apiClient.get<PaymentsStats>('/cabinet/admin/payments/stats');
return response.data return response.data;
}, },
// Get specific payment details // Get specific payment details
getPayment: async (method: string, paymentId: number): Promise<PendingPayment> => { getPayment: async (method: string, paymentId: number): Promise<PendingPayment> => {
const response = await apiClient.get<PendingPayment>(`/cabinet/admin/payments/${method}/${paymentId}`) const response = await apiClient.get<PendingPayment>(
return response.data `/cabinet/admin/payments/${method}/${paymentId}`,
);
return response.data;
}, },
// Manually check payment status // Manually check payment status
checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => { checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => {
const response = await apiClient.post<ManualCheckResponse>(`/cabinet/admin/payments/${method}/${paymentId}/check`) const response = await apiClient.post<ManualCheckResponse>(
return response.data `/cabinet/admin/payments/${method}/${paymentId}/check`,
);
return response.data;
}, },
} };

View File

@@ -1,227 +1,227 @@
import { apiClient } from './client' import { apiClient } from './client';
// ============ Types ============ // ============ Types ============
// Status & Connection // Status & Connection
export interface ConnectionStatus { export interface ConnectionStatus {
status: string status: string;
message: string message: string;
api_url?: string api_url?: string;
status_code?: number status_code?: number;
system_info?: Record<string, unknown> system_info?: Record<string, unknown>;
} }
export interface RemnaWaveStatusResponse { export interface RemnaWaveStatusResponse {
is_configured: boolean is_configured: boolean;
configuration_error?: string configuration_error?: string;
connection?: ConnectionStatus connection?: ConnectionStatus;
} }
// System Statistics // System Statistics
export interface SystemSummary { export interface SystemSummary {
users_online: number users_online: number;
total_users: number total_users: number;
active_connections: number active_connections: number;
nodes_online: number nodes_online: 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;
} }
export interface ServerInfo { export interface ServerInfo {
cpu_cores: number cpu_cores: number;
cpu_physical_cores: number cpu_physical_cores: number;
memory_total: number memory_total: number;
memory_used: number memory_used: number;
memory_free: number memory_free: number;
memory_available: number memory_available: number;
uptime_seconds: number uptime_seconds: number;
} }
export interface Bandwidth { export interface Bandwidth {
realtime_download: number realtime_download: number;
realtime_upload: number realtime_upload: number;
realtime_total: number realtime_total: number;
} }
export interface TrafficPeriod { export interface TrafficPeriod {
current: number current: number;
previous: number previous: number;
difference?: string difference?: string;
} }
export interface TrafficPeriods { export interface TrafficPeriods {
last_2_days: TrafficPeriod last_2_days: TrafficPeriod;
last_7_days: TrafficPeriod last_7_days: TrafficPeriod;
last_30_days: TrafficPeriod last_30_days: TrafficPeriod;
current_month: TrafficPeriod current_month: TrafficPeriod;
current_year: TrafficPeriod current_year: TrafficPeriod;
} }
export interface SystemStatsResponse { export interface SystemStatsResponse {
system: SystemSummary system: SystemSummary;
users_by_status: Record<string, number> users_by_status: Record<string, number>;
server_info: ServerInfo server_info: ServerInfo;
bandwidth: Bandwidth bandwidth: Bandwidth;
traffic_periods: TrafficPeriods traffic_periods: TrafficPeriods;
nodes_realtime: Record<string, unknown>[] nodes_realtime: Record<string, unknown>[];
nodes_weekly: Record<string, unknown>[] nodes_weekly: Record<string, unknown>[];
last_updated?: string last_updated?: string;
} }
// Nodes // Nodes
export interface NodeInfo { export interface NodeInfo {
uuid: string uuid: string;
name: string name: string;
address: string address: string;
country_code?: string country_code?: string;
is_connected: boolean is_connected: boolean;
is_disabled: boolean is_disabled: boolean;
is_node_online: boolean is_node_online: boolean;
is_xray_running: boolean is_xray_running: boolean;
users_online?: number users_online?: number;
traffic_used_bytes?: number traffic_used_bytes?: number;
traffic_limit_bytes?: number traffic_limit_bytes?: number;
last_status_change?: string last_status_change?: string;
last_status_message?: string last_status_message?: string;
xray_uptime?: string xray_uptime?: string;
is_traffic_tracking_active: boolean is_traffic_tracking_active: boolean;
traffic_reset_day?: number traffic_reset_day?: number;
notify_percent?: number notify_percent?: number;
consumption_multiplier: number consumption_multiplier: number;
cpu_count?: number cpu_count?: number;
cpu_model?: string cpu_model?: string;
total_ram?: string total_ram?: string;
created_at?: string created_at?: string;
updated_at?: string updated_at?: string;
provider_uuid?: string provider_uuid?: string;
} }
export interface NodesListResponse { export interface NodesListResponse {
items: NodeInfo[] items: NodeInfo[];
total: number total: number;
} }
export interface NodesOverview { export interface NodesOverview {
total: number total: number;
online: number online: number;
offline: number offline: number;
disabled: number disabled: number;
total_users_online: number total_users_online: number;
nodes: NodeInfo[] nodes: NodeInfo[];
} }
export interface NodeStatisticsResponse { export interface NodeStatisticsResponse {
node: NodeInfo node: NodeInfo;
realtime?: Record<string, unknown> realtime?: Record<string, unknown>;
usage_history: Record<string, unknown>[] usage_history: Record<string, unknown>[];
last_updated?: string last_updated?: string;
} }
export interface NodeActionResponse { export interface NodeActionResponse {
success: boolean success: boolean;
message?: string message?: string;
is_disabled?: boolean is_disabled?: boolean;
} }
// Squads // Squads
export interface SquadWithLocalInfo { export interface SquadWithLocalInfo {
uuid: string uuid: string;
name: string name: string;
members_count: number members_count: number;
inbounds_count: number inbounds_count: number;
inbounds: Record<string, unknown>[] inbounds: Record<string, unknown>[];
local_id?: number local_id?: number;
display_name?: string display_name?: string;
country_code?: string country_code?: string;
is_available?: boolean is_available?: boolean;
is_trial_eligible?: boolean is_trial_eligible?: boolean;
price_kopeks?: number price_kopeks?: number;
max_users?: number max_users?: number;
current_users?: number current_users?: number;
is_synced: boolean is_synced: boolean;
} }
export interface SquadsListResponse { export interface SquadsListResponse {
items: SquadWithLocalInfo[] items: SquadWithLocalInfo[];
total: number total: number;
} }
export interface SquadDetailResponse extends SquadWithLocalInfo { export interface SquadDetailResponse extends SquadWithLocalInfo {
description?: string description?: string;
sort_order?: number sort_order?: number;
active_subscriptions: number active_subscriptions: number;
} }
export interface SquadOperationResponse { export interface SquadOperationResponse {
success: boolean success: boolean;
message?: string message?: string;
data?: Record<string, unknown> data?: Record<string, unknown>;
} }
// Migration // Migration
export interface MigrationPreviewResponse { export interface MigrationPreviewResponse {
squad_uuid: string squad_uuid: string;
squad_name: string squad_name: string;
current_users: number current_users: number;
max_users?: number max_users?: number;
users_to_migrate: number users_to_migrate: number;
} }
export interface MigrationStats { export interface MigrationStats {
source_uuid: string source_uuid: string;
target_uuid: string target_uuid: string;
total: number total: number;
updated: number updated: number;
panel_updated: number panel_updated: number;
panel_failed: number panel_failed: number;
source_removed: number source_removed: number;
target_added: number target_added: number;
} }
export interface MigrationResponse { export interface MigrationResponse {
success: boolean success: boolean;
message?: string message?: string;
error?: string error?: string;
data?: MigrationStats data?: MigrationStats;
} }
// Inbounds // Inbounds
export interface InboundsListResponse { export interface InboundsListResponse {
items: Record<string, unknown>[] items: Record<string, unknown>[];
total: number total: number;
} }
// Auto Sync // Auto Sync
export interface AutoSyncStatus { export interface AutoSyncStatus {
enabled: boolean enabled: boolean;
times: string[] times: string[];
next_run?: string next_run?: string;
is_running: boolean is_running: boolean;
last_run_started_at?: string last_run_started_at?: string;
last_run_finished_at?: string last_run_finished_at?: string;
last_run_success?: boolean last_run_success?: boolean;
last_run_reason?: string last_run_reason?: string;
last_run_error?: string last_run_error?: string;
last_user_stats?: Record<string, unknown> last_user_stats?: Record<string, unknown>;
last_server_stats?: Record<string, unknown> last_server_stats?: Record<string, unknown>;
} }
export interface AutoSyncRunResponse { export interface AutoSyncRunResponse {
started: boolean started: boolean;
success?: boolean success?: boolean;
error?: string error?: string;
user_stats?: Record<string, unknown> user_stats?: Record<string, unknown>;
server_stats?: Record<string, unknown> server_stats?: Record<string, unknown>;
reason?: string reason?: string;
} }
// Sync // Sync
export interface SyncResponse { export interface SyncResponse {
success: boolean success: boolean;
message?: string message?: string;
data?: Record<string, unknown> data?: Record<string, unknown>;
} }
// ============ API ============ // ============ API ============
@@ -229,158 +229,176 @@ export interface SyncResponse {
export const adminRemnawaveApi = { export const adminRemnawaveApi = {
// Status & Connection // Status & Connection
getStatus: async (): Promise<RemnaWaveStatusResponse> => { getStatus: async (): Promise<RemnaWaveStatusResponse> => {
const response = await apiClient.get('/cabinet/admin/remnawave/status') const response = await apiClient.get('/cabinet/admin/remnawave/status');
return response.data return response.data;
}, },
// System Statistics // System Statistics
getSystemStats: async (): Promise<SystemStatsResponse> => { getSystemStats: async (): Promise<SystemStatsResponse> => {
const response = await apiClient.get('/cabinet/admin/remnawave/system') const response = await apiClient.get('/cabinet/admin/remnawave/system');
return response.data 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');
return response.data return response.data;
}, },
getNodesOverview: async (): Promise<NodesOverview> => { getNodesOverview: async (): Promise<NodesOverview> => {
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/overview') const response = await apiClient.get('/cabinet/admin/remnawave/nodes/overview');
return response.data return response.data;
}, },
getNodesRealtime: async (): Promise<Record<string, unknown>[]> => { getNodesRealtime: async (): Promise<Record<string, unknown>[]> => {
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/realtime') const response = await apiClient.get('/cabinet/admin/remnawave/nodes/realtime');
return response.data return response.data;
}, },
getNode: async (uuid: string): Promise<NodeInfo> => { getNode: async (uuid: string): Promise<NodeInfo> => {
const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}`) const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}`);
return response.data return response.data;
}, },
getNodeStatistics: async (uuid: string): Promise<NodeStatisticsResponse> => { getNodeStatistics: async (uuid: string): Promise<NodeStatisticsResponse> => {
const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}/statistics`) const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}/statistics`);
return response.data return response.data;
}, },
nodeAction: async (uuid: string, action: 'enable' | 'disable' | 'restart'): Promise<NodeActionResponse> => { nodeAction: async (
const response = await apiClient.post(`/cabinet/admin/remnawave/nodes/${uuid}/action`, { action }) uuid: string,
return response.data action: 'enable' | 'disable' | 'restart',
): Promise<NodeActionResponse> => {
const response = await apiClient.post(`/cabinet/admin/remnawave/nodes/${uuid}/action`, {
action,
});
return response.data;
}, },
restartAllNodes: async (): Promise<NodeActionResponse> => { restartAllNodes: async (): Promise<NodeActionResponse> => {
const response = await apiClient.post('/cabinet/admin/remnawave/nodes/restart-all') const response = await apiClient.post('/cabinet/admin/remnawave/nodes/restart-all');
return response.data return response.data;
}, },
// Squads // Squads
getSquads: async (): Promise<SquadsListResponse> => { getSquads: async (): Promise<SquadsListResponse> => {
const response = await apiClient.get('/cabinet/admin/remnawave/squads') const response = await apiClient.get('/cabinet/admin/remnawave/squads');
return response.data return response.data;
}, },
getSquad: async (uuid: string): Promise<SquadDetailResponse> => { getSquad: async (uuid: string): Promise<SquadDetailResponse> => {
const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}`) const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}`);
return response.data return response.data;
}, },
createSquad: async (data: { name: string; inbound_uuids?: string[] }): Promise<SquadOperationResponse> => { createSquad: async (data: {
const response = await apiClient.post('/cabinet/admin/remnawave/squads', data) name: string;
return response.data inbound_uuids?: string[];
}): Promise<SquadOperationResponse> => {
const response = await apiClient.post('/cabinet/admin/remnawave/squads', data);
return response.data;
}, },
updateSquad: async (uuid: string, data: { name?: string; inbound_uuids?: string[] }): Promise<SquadOperationResponse> => { updateSquad: async (
const response = await apiClient.patch(`/cabinet/admin/remnawave/squads/${uuid}`, data) uuid: string,
return response.data data: { name?: string; inbound_uuids?: string[] },
): Promise<SquadOperationResponse> => {
const response = await apiClient.patch(`/cabinet/admin/remnawave/squads/${uuid}`, data);
return response.data;
}, },
deleteSquad: async (uuid: string): Promise<SquadOperationResponse> => { deleteSquad: async (uuid: string): Promise<SquadOperationResponse> => {
const response = await apiClient.delete(`/cabinet/admin/remnawave/squads/${uuid}`) const response = await apiClient.delete(`/cabinet/admin/remnawave/squads/${uuid}`);
return response.data return response.data;
}, },
squadAction: async (uuid: string, data: { squadAction: async (
action: 'add_all_users' | 'remove_all_users' | 'delete' | 'rename' | 'update_inbounds' uuid: string,
name?: string data: {
inbound_uuids?: string[] action: 'add_all_users' | 'remove_all_users' | 'delete' | 'rename' | 'update_inbounds';
}): Promise<SquadOperationResponse> => { name?: string;
const response = await apiClient.post(`/cabinet/admin/remnawave/squads/${uuid}/action`, data) inbound_uuids?: string[];
return response.data },
): Promise<SquadOperationResponse> => {
const response = await apiClient.post(`/cabinet/admin/remnawave/squads/${uuid}/action`, data);
return response.data;
}, },
// Migration // Migration
getMigrationPreview: async (uuid: string): Promise<MigrationPreviewResponse> => { getMigrationPreview: async (uuid: string): Promise<MigrationPreviewResponse> => {
const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}/migration-preview`) const response = await apiClient.get(
return response.data `/cabinet/admin/remnawave/squads/${uuid}/migration-preview`,
);
return response.data;
}, },
migrateSquad: async (sourceUuid: string, targetUuid: string): Promise<MigrationResponse> => { migrateSquad: async (sourceUuid: string, targetUuid: string): Promise<MigrationResponse> => {
const response = await apiClient.post('/cabinet/admin/remnawave/squads/migrate', { const response = await apiClient.post('/cabinet/admin/remnawave/squads/migrate', {
source_uuid: sourceUuid, source_uuid: sourceUuid,
target_uuid: targetUuid, target_uuid: targetUuid,
}) });
return response.data return response.data;
}, },
// Inbounds // Inbounds
getInbounds: async (): Promise<InboundsListResponse> => { getInbounds: async (): Promise<InboundsListResponse> => {
const response = await apiClient.get('/cabinet/admin/remnawave/inbounds') const response = await apiClient.get('/cabinet/admin/remnawave/inbounds');
return response.data return response.data;
}, },
// Auto Sync // Auto Sync
getAutoSyncStatus: async (): Promise<AutoSyncStatus> => { getAutoSyncStatus: async (): Promise<AutoSyncStatus> => {
const response = await apiClient.get('/cabinet/admin/remnawave/sync/auto/status') const response = await apiClient.get('/cabinet/admin/remnawave/sync/auto/status');
return response.data return response.data;
}, },
toggleAutoSync: async (enabled: boolean): Promise<SyncResponse> => { toggleAutoSync: async (enabled: boolean): Promise<SyncResponse> => {
const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/toggle', { enabled }) const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/toggle', { enabled });
return response.data return response.data;
}, },
runAutoSync: async (): Promise<AutoSyncRunResponse> => { runAutoSync: async (): Promise<AutoSyncRunResponse> => {
const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/run') const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/run');
return response.data return response.data;
}, },
// Manual Sync // Manual Sync
syncFromPanel: async (mode: 'all' | 'new_only' | 'update_only' = 'all'): Promise<SyncResponse> => { syncFromPanel: async (
const response = await apiClient.post('/cabinet/admin/remnawave/sync/from-panel', { mode }) mode: 'all' | 'new_only' | 'update_only' = 'all',
return response.data ): Promise<SyncResponse> => {
const response = await apiClient.post('/cabinet/admin/remnawave/sync/from-panel', { mode });
return response.data;
}, },
syncToPanel: async (): Promise<SyncResponse> => { syncToPanel: async (): Promise<SyncResponse> => {
const response = await apiClient.post('/cabinet/admin/remnawave/sync/to-panel') const response = await apiClient.post('/cabinet/admin/remnawave/sync/to-panel');
return response.data return response.data;
}, },
syncServers: async (): Promise<SyncResponse> => { syncServers: async (): Promise<SyncResponse> => {
const response = await apiClient.post('/cabinet/admin/remnawave/sync/servers') const response = await apiClient.post('/cabinet/admin/remnawave/sync/servers');
return response.data return response.data;
}, },
validateSubscriptions: async (): Promise<SyncResponse> => { validateSubscriptions: async (): Promise<SyncResponse> => {
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/validate') const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/validate');
return response.data return response.data;
}, },
cleanupSubscriptions: async (): Promise<SyncResponse> => { cleanupSubscriptions: async (): Promise<SyncResponse> => {
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/cleanup') const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/cleanup');
return response.data return response.data;
}, },
syncSubscriptionStatuses: async (): Promise<SyncResponse> => { syncSubscriptionStatuses: async (): Promise<SyncResponse> => {
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/statuses') const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/statuses');
return response.data return response.data;
}, },
getSyncRecommendations: async (): Promise<SyncResponse> => { getSyncRecommendations: async (): Promise<SyncResponse> => {
const response = await apiClient.get('/cabinet/admin/remnawave/sync/recommendations') const response = await apiClient.get('/cabinet/admin/remnawave/sync/recommendations');
return response.data return response.data;
}, },
} };
export default adminRemnawaveApi export default adminRemnawaveApi;

View File

@@ -1,73 +1,79 @@
import apiClient from './client' import apiClient from './client';
export interface SettingCategoryRef { export interface SettingCategoryRef {
key: string key: string;
label: string label: string;
} }
export interface SettingCategorySummary { export interface SettingCategorySummary {
key: string key: string;
label: string label: string;
description: string description: string;
items: number items: number;
} }
export interface SettingChoice { export interface SettingChoice {
value: unknown value: unknown;
label: string label: string;
description?: string | null description?: string | null;
} }
export interface SettingHint { export interface SettingHint {
description: string description: string;
format: string format: string;
example: string example: string;
warning: string warning: string;
} }
export interface SettingDefinition { export interface SettingDefinition {
key: string key: string;
name: string name: string;
category: SettingCategoryRef category: SettingCategoryRef;
type: string type: string;
is_optional: boolean is_optional: boolean;
current: unknown current: unknown;
original: unknown original: unknown;
has_override: boolean has_override: boolean;
read_only: boolean read_only: boolean;
choices: SettingChoice[] choices: SettingChoice[];
hint?: SettingHint | null hint?: SettingHint | null;
} }
export const adminSettingsApi = { export const adminSettingsApi = {
// Get list of setting categories // Get list of setting categories
getCategories: async (): Promise<SettingCategorySummary[]> => { getCategories: async (): Promise<SettingCategorySummary[]> => {
const response = await apiClient.get<SettingCategorySummary[]>('/cabinet/admin/settings/categories') const response = await apiClient.get<SettingCategorySummary[]>(
return response.data '/cabinet/admin/settings/categories',
);
return response.data;
}, },
// Get all settings or settings for a specific category // Get all settings or settings for a specific category
getSettings: async (categoryKey?: string): Promise<SettingDefinition[]> => { getSettings: async (categoryKey?: string): Promise<SettingDefinition[]> => {
const params = categoryKey ? { category_key: categoryKey } : {} const params = categoryKey ? { category_key: categoryKey } : {};
const response = await apiClient.get<SettingDefinition[]>('/cabinet/admin/settings', { params }) const response = await apiClient.get<SettingDefinition[]>('/cabinet/admin/settings', {
return response.data params,
});
return response.data;
}, },
// Get a specific setting by key // Get a specific setting by key
getSetting: async (key: string): Promise<SettingDefinition> => { getSetting: async (key: string): Promise<SettingDefinition> => {
const response = await apiClient.get<SettingDefinition>(`/cabinet/admin/settings/${key}`) const response = await apiClient.get<SettingDefinition>(`/cabinet/admin/settings/${key}`);
return response.data return response.data;
}, },
// Update a setting value // Update a setting value
updateSetting: async (key: string, value: unknown): Promise<SettingDefinition> => { updateSetting: async (key: string, value: unknown): Promise<SettingDefinition> => {
const response = await apiClient.put<SettingDefinition>(`/cabinet/admin/settings/${key}`, { value }) const response = await apiClient.put<SettingDefinition>(`/cabinet/admin/settings/${key}`, {
return response.data value,
});
return response.data;
}, },
// Reset a setting to default // Reset a setting to default
resetSetting: async (key: string): Promise<SettingDefinition> => { resetSetting: async (key: string): Promise<SettingDefinition> => {
const response = await apiClient.delete<SettingDefinition>(`/cabinet/admin/settings/${key}`) const response = await apiClient.delete<SettingDefinition>(`/cabinet/admin/settings/${key}`);
return response.data return response.data;
}, },
} };

View File

@@ -1,418 +1,478 @@
import apiClient from './client' import apiClient from './client';
// ============ Types ============ // ============ Types ============
export interface UserSubscriptionInfo { export interface UserSubscriptionInfo {
id: number id: number;
status: string status: string;
is_trial: boolean is_trial: boolean;
start_date: string | null start_date: string | null;
end_date: string | null end_date: string | null;
traffic_limit_gb: number traffic_limit_gb: number;
traffic_used_gb: number traffic_used_gb: number;
device_limit: number device_limit: number;
tariff_id: number | null tariff_id: number | null;
tariff_name: string | null tariff_name: string | null;
autopay_enabled: boolean autopay_enabled: boolean;
is_active: boolean is_active: boolean;
days_remaining: number days_remaining: number;
} }
export interface UserPromoGroupInfo { export interface UserPromoGroupInfo {
id: number id: number;
name: string name: string;
is_default: boolean is_default: boolean;
} }
export interface UserListItem { export interface UserListItem {
id: number id: number;
telegram_id: number telegram_id: number;
username: string | null username: string | null;
first_name: string | null first_name: string | null;
last_name: string | null last_name: string | null;
full_name: string full_name: string;
status: string status: string;
balance_kopeks: number balance_kopeks: number;
balance_rubles: number balance_rubles: number;
created_at: string created_at: string;
last_activity: string | null last_activity: string | null;
has_subscription: boolean has_subscription: boolean;
subscription_status: string | null subscription_status: string | null;
subscription_is_trial: boolean subscription_is_trial: boolean;
subscription_end_date: string | null subscription_end_date: string | null;
promo_group_id: number | null promo_group_id: number | null;
promo_group_name: string | null promo_group_name: string | null;
total_spent_kopeks: number total_spent_kopeks: number;
purchase_count: number purchase_count: number;
has_restrictions: boolean has_restrictions: boolean;
restriction_topup: boolean restriction_topup: boolean;
restriction_subscription: boolean restriction_subscription: boolean;
} }
export interface UsersListResponse { export interface UsersListResponse {
users: UserListItem[] users: UserListItem[];
total: number total: number;
offset: number offset: number;
limit: number limit: number;
} }
export interface UserTransactionItem { export interface UserTransactionItem {
id: number id: number;
type: string type: string;
amount_kopeks: number amount_kopeks: number;
amount_rubles: number amount_rubles: number;
description: string | null description: string | null;
payment_method: string | null payment_method: string | null;
is_completed: boolean is_completed: boolean;
created_at: string created_at: string;
} }
export interface UserReferralInfo { export interface UserReferralInfo {
referral_code: string referral_code: string;
referrals_count: number referrals_count: number;
total_earnings_kopeks: number total_earnings_kopeks: number;
commission_percent: number | null commission_percent: number | null;
referred_by_id: number | null referred_by_id: number | null;
referred_by_username: string | null referred_by_username: string | null;
} }
export interface UserDetailResponse { export interface UserDetailResponse {
id: number id: number;
telegram_id: number telegram_id: number;
username: string | null username: string | null;
first_name: string | null first_name: string | null;
last_name: string | null last_name: string | null;
full_name: string full_name: string;
status: string status: string;
language: string language: string;
balance_kopeks: number balance_kopeks: number;
balance_rubles: number balance_rubles: number;
email: string | null email: string | null;
email_verified: boolean email_verified: boolean;
created_at: string created_at: string;
updated_at: string | null updated_at: string | null;
last_activity: string | null last_activity: string | null;
cabinet_last_login: string | null cabinet_last_login: string | null;
subscription: UserSubscriptionInfo | null subscription: UserSubscriptionInfo | null;
promo_group: UserPromoGroupInfo | null promo_group: UserPromoGroupInfo | null;
referral: UserReferralInfo referral: UserReferralInfo;
total_spent_kopeks: number total_spent_kopeks: number;
purchase_count: number purchase_count: number;
used_promocodes: number used_promocodes: number;
has_had_paid_subscription: boolean has_had_paid_subscription: boolean;
lifetime_used_traffic_bytes: number lifetime_used_traffic_bytes: number;
restriction_topup: boolean restriction_topup: boolean;
restriction_subscription: boolean restriction_subscription: boolean;
restriction_reason: string | null restriction_reason: string | null;
promo_offer_discount_percent: number promo_offer_discount_percent: number;
promo_offer_discount_source: string | null promo_offer_discount_source: string | null;
promo_offer_discount_expires_at: string | null promo_offer_discount_expires_at: string | null;
recent_transactions: UserTransactionItem[] recent_transactions: UserTransactionItem[];
remnawave_uuid: string | null remnawave_uuid: string | null;
} }
export interface UsersStatsResponse { export interface UsersStatsResponse {
total_users: number total_users: number;
active_users: number active_users: number;
blocked_users: number blocked_users: number;
deleted_users: number deleted_users: number;
new_today: number new_today: number;
new_week: number new_week: number;
new_month: number new_month: number;
users_with_subscription: number users_with_subscription: number;
users_with_active_subscription: number users_with_active_subscription: number;
users_with_trial: number users_with_trial: number;
users_with_expired_subscription: number users_with_expired_subscription: number;
total_balance_kopeks: number total_balance_kopeks: number;
total_balance_rubles: number total_balance_rubles: number;
avg_balance_kopeks: number avg_balance_kopeks: number;
active_today: number active_today: number;
active_week: number active_week: number;
active_month: number active_month: number;
} }
// Available tariffs // Available tariffs
export interface PeriodPriceInfo { export interface PeriodPriceInfo {
days: number days: number;
price_kopeks: number price_kopeks: number;
price_rubles: number price_rubles: number;
} }
export interface UserAvailableTariff { export interface UserAvailableTariff {
id: number id: number;
name: string name: string;
description: string | null description: string | null;
is_active: boolean is_active: boolean;
is_trial_available: boolean is_trial_available: boolean;
traffic_limit_gb: number traffic_limit_gb: number;
device_limit: number device_limit: number;
tier_level: number tier_level: number;
display_order: number display_order: number;
period_prices: PeriodPriceInfo[] period_prices: PeriodPriceInfo[];
is_daily: boolean is_daily: boolean;
daily_price_kopeks: number daily_price_kopeks: number;
custom_days_enabled: boolean custom_days_enabled: boolean;
price_per_day_kopeks: number price_per_day_kopeks: number;
min_days: number min_days: number;
max_days: number max_days: number;
is_available: boolean is_available: boolean;
requires_promo_group: boolean requires_promo_group: boolean;
} }
export interface UserAvailableTariffsResponse { export interface UserAvailableTariffsResponse {
user_id: number user_id: number;
promo_group_id: number | null promo_group_id: number | null;
promo_group_name: string | null promo_group_name: string | null;
tariffs: UserAvailableTariff[] tariffs: UserAvailableTariff[];
total: number total: number;
current_tariff_id: number | null current_tariff_id: number | null;
current_tariff_name: string | null current_tariff_name: string | null;
} }
// Sync types // Sync types
export interface PanelUserInfo { export interface PanelUserInfo {
uuid: string | null uuid: string | null;
short_uuid: string | null short_uuid: string | null;
username: string | null username: string | null;
status: string | null status: string | null;
expire_at: string | null expire_at: string | null;
traffic_limit_gb: number traffic_limit_gb: number;
traffic_used_gb: number traffic_used_gb: number;
device_limit: number device_limit: number;
subscription_url: string | null subscription_url: string | null;
active_squads: string[] active_squads: string[];
} }
export interface SyncFromPanelResponse { export interface SyncFromPanelResponse {
success: boolean success: boolean;
message: string message: string;
panel_user: PanelUserInfo | null panel_user: PanelUserInfo | null;
changes: Record<string, unknown> changes: Record<string, unknown>;
errors: string[] errors: string[];
} }
export interface SyncToPanelResponse { export interface SyncToPanelResponse {
success: boolean success: boolean;
message: string message: string;
action: string action: string;
panel_uuid: string | null panel_uuid: string | null;
changes: Record<string, unknown> changes: Record<string, unknown>;
errors: string[] errors: string[];
} }
export interface PanelSyncStatusResponse { export interface PanelSyncStatusResponse {
user_id: number user_id: number;
telegram_id: number telegram_id: number;
remnawave_uuid: string | null remnawave_uuid: string | null;
last_sync: string | null last_sync: string | null;
bot_subscription_status: string | null bot_subscription_status: string | null;
bot_subscription_end_date: string | null bot_subscription_end_date: string | null;
bot_traffic_limit_gb: number bot_traffic_limit_gb: number;
bot_traffic_used_gb: number bot_traffic_used_gb: number;
bot_device_limit: number bot_device_limit: number;
bot_squads: string[] bot_squads: string[];
panel_found: boolean panel_found: boolean;
panel_status: string | null panel_status: string | null;
panel_expire_at: string | null panel_expire_at: string | null;
panel_traffic_limit_gb: number panel_traffic_limit_gb: number;
panel_traffic_used_gb: number panel_traffic_used_gb: number;
panel_device_limit: number panel_device_limit: number;
panel_squads: string[] panel_squads: string[];
has_differences: boolean has_differences: boolean;
differences: string[] differences: string[];
} }
// Update types // Update types
export interface UpdateBalanceRequest { export interface UpdateBalanceRequest {
amount_kopeks: number amount_kopeks: number;
description?: string description?: string;
create_transaction?: boolean create_transaction?: boolean;
} }
export interface UpdateBalanceResponse { export interface UpdateBalanceResponse {
success: boolean success: boolean;
old_balance_kopeks: number old_balance_kopeks: number;
new_balance_kopeks: number new_balance_kopeks: number;
message: string message: string;
} }
export interface UpdateSubscriptionRequest { export interface UpdateSubscriptionRequest {
action: 'extend' | 'set_end_date' | 'change_tariff' | 'set_traffic' | 'toggle_autopay' | 'cancel' | 'activate' | 'create' action:
days?: number | 'extend'
end_date?: string | 'set_end_date'
tariff_id?: number | 'change_tariff'
traffic_limit_gb?: number | 'set_traffic'
traffic_used_gb?: number | 'toggle_autopay'
autopay_enabled?: boolean | 'cancel'
is_trial?: boolean | 'activate'
device_limit?: number | 'create';
days?: number;
end_date?: string;
tariff_id?: number;
traffic_limit_gb?: number;
traffic_used_gb?: number;
autopay_enabled?: boolean;
is_trial?: boolean;
device_limit?: number;
} }
export interface UpdateSubscriptionResponse { export interface UpdateSubscriptionResponse {
success: boolean success: boolean;
message: string message: string;
subscription: UserSubscriptionInfo | null subscription: UserSubscriptionInfo | null;
} }
export interface UpdateUserStatusResponse { export interface UpdateUserStatusResponse {
success: boolean success: boolean;
old_status: string old_status: string;
new_status: string new_status: string;
message: string message: string;
} }
export interface UpdateRestrictionsRequest { export interface UpdateRestrictionsRequest {
restriction_topup?: boolean restriction_topup?: boolean;
restriction_subscription?: boolean restriction_subscription?: boolean;
restriction_reason?: string restriction_reason?: string;
} }
export interface UpdateRestrictionsResponse { export interface UpdateRestrictionsResponse {
success: boolean success: boolean;
restriction_topup: boolean restriction_topup: boolean;
restriction_subscription: boolean restriction_subscription: boolean;
restriction_reason: string | null restriction_reason: string | null;
message: string message: string;
} }
export interface SyncFromPanelRequest { export interface SyncFromPanelRequest {
update_subscription?: boolean update_subscription?: boolean;
update_traffic?: boolean update_traffic?: boolean;
create_if_missing?: boolean create_if_missing?: boolean;
} }
export interface SyncToPanelRequest { export interface SyncToPanelRequest {
create_if_missing?: boolean create_if_missing?: boolean;
update_status?: boolean update_status?: boolean;
update_traffic_limit?: boolean update_traffic_limit?: boolean;
update_expire_date?: boolean update_expire_date?: boolean;
update_squads?: boolean update_squads?: boolean;
} }
// ============ API ============ // ============ API ============
export const adminUsersApi = { export const adminUsersApi = {
// List users // List users
getUsers: async (params: { getUsers: async (
offset?: number params: {
limit?: number offset?: number;
search?: string limit?: number;
status?: 'active' | 'blocked' | 'deleted' search?: string;
sort_by?: 'created_at' | 'balance' | 'traffic' | 'last_activity' | 'total_spent' | 'purchase_count' status?: 'active' | 'blocked' | 'deleted';
} = {}): Promise<UsersListResponse> => { sort_by?:
const response = await apiClient.get('/cabinet/admin/users', { params }) | 'created_at'
return response.data | 'balance'
| 'traffic'
| 'last_activity'
| 'total_spent'
| 'purchase_count';
} = {},
): Promise<UsersListResponse> => {
const response = await apiClient.get('/cabinet/admin/users', { params });
return response.data;
}, },
// Get users stats // Get users stats
getStats: async (): Promise<UsersStatsResponse> => { getStats: async (): Promise<UsersStatsResponse> => {
const response = await apiClient.get('/cabinet/admin/users/stats') const response = await apiClient.get('/cabinet/admin/users/stats');
return response.data return response.data;
}, },
// Get user detail // Get user detail
getUser: async (userId: number): Promise<UserDetailResponse> => { getUser: async (userId: number): Promise<UserDetailResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}`) const response = await apiClient.get(`/cabinet/admin/users/${userId}`);
return response.data return response.data;
}, },
// Get user by telegram ID // Get user by telegram ID
getUserByTelegram: async (telegramId: number): Promise<UserDetailResponse> => { getUserByTelegram: async (telegramId: number): Promise<UserDetailResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/by-telegram/${telegramId}`) const response = await apiClient.get(`/cabinet/admin/users/by-telegram/${telegramId}`);
return response.data return response.data;
}, },
// Get available tariffs for user // Get available tariffs for user
getAvailableTariffs: async (userId: number, includeInactive = false): Promise<UserAvailableTariffsResponse> => { getAvailableTariffs: async (
userId: number,
includeInactive = false,
): Promise<UserAvailableTariffsResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/available-tariffs`, { const response = await apiClient.get(`/cabinet/admin/users/${userId}/available-tariffs`, {
params: { include_inactive: includeInactive } params: { include_inactive: includeInactive },
}) });
return response.data return response.data;
}, },
// Update balance // Update balance
updateBalance: async (userId: number, data: UpdateBalanceRequest): Promise<UpdateBalanceResponse> => { updateBalance: async (
const response = await apiClient.post(`/cabinet/admin/users/${userId}/balance`, data) userId: number,
return response.data data: UpdateBalanceRequest,
): Promise<UpdateBalanceResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/balance`, data);
return response.data;
}, },
// Update subscription // Update subscription
updateSubscription: async (userId: number, data: UpdateSubscriptionRequest): Promise<UpdateSubscriptionResponse> => { updateSubscription: async (
const response = await apiClient.post(`/cabinet/admin/users/${userId}/subscription`, data) userId: number,
return response.data data: UpdateSubscriptionRequest,
): Promise<UpdateSubscriptionResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/subscription`, data);
return response.data;
}, },
// Update status // Update status
updateStatus: async (userId: number, status: 'active' | 'blocked' | 'deleted', reason?: string): Promise<UpdateUserStatusResponse> => { updateStatus: async (
const response = await apiClient.post(`/cabinet/admin/users/${userId}/status`, { status, reason }) userId: number,
return response.data status: 'active' | 'blocked' | 'deleted',
reason?: string,
): Promise<UpdateUserStatusResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/status`, {
status,
reason,
});
return response.data;
}, },
// Block user // Block user
blockUser: async (userId: number, reason?: string): Promise<UpdateUserStatusResponse> => { blockUser: async (userId: number, reason?: string): Promise<UpdateUserStatusResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/block`, null, { params: { reason } }) const response = await apiClient.post(`/cabinet/admin/users/${userId}/block`, null, {
return response.data params: { reason },
});
return response.data;
}, },
// Unblock user // Unblock user
unblockUser: async (userId: number): Promise<UpdateUserStatusResponse> => { unblockUser: async (userId: number): Promise<UpdateUserStatusResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/unblock`) const response = await apiClient.post(`/cabinet/admin/users/${userId}/unblock`);
return response.data return response.data;
}, },
// Update restrictions // Update restrictions
updateRestrictions: async (userId: number, data: UpdateRestrictionsRequest): Promise<UpdateRestrictionsResponse> => { updateRestrictions: async (
const response = await apiClient.post(`/cabinet/admin/users/${userId}/restrictions`, data) userId: number,
return response.data data: UpdateRestrictionsRequest,
): Promise<UpdateRestrictionsResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/restrictions`, data);
return response.data;
}, },
// Update promo group // Update promo group
updatePromoGroup: async (userId: number, promoGroupId: number | null): Promise<{ success: boolean; message: string }> => { updatePromoGroup: async (
const response = await apiClient.post(`/cabinet/admin/users/${userId}/promo-group`, { promo_group_id: promoGroupId }) userId: number,
return response.data promoGroupId: number | null,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/promo-group`, {
promo_group_id: promoGroupId,
});
return response.data;
}, },
// Delete user // Delete user
deleteUser: async (userId: number, softDelete = true, reason?: string): Promise<{ success: boolean; message: string }> => { deleteUser: async (
userId: number,
softDelete = true,
reason?: string,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.delete(`/cabinet/admin/users/${userId}`, { const response = await apiClient.delete(`/cabinet/admin/users/${userId}`, {
data: { soft_delete: softDelete, reason } data: { soft_delete: softDelete, reason },
}) });
return response.data return response.data;
}, },
// Get referrals // Get referrals
getReferrals: async (userId: number, offset = 0, limit = 50): Promise<UsersListResponse> => { getReferrals: async (userId: number, offset = 0, limit = 50): Promise<UsersListResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, { const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, {
params: { offset, limit } params: { offset, limit },
}) });
return response.data return response.data;
}, },
// Get transactions // Get transactions
getTransactions: async (userId: number, params: { getTransactions: async (
offset?: number userId: number,
limit?: number params: {
transaction_type?: string offset?: number;
} = {}): Promise<{ transactions: UserTransactionItem[]; total: number; offset: number; limit: number }> => { limit?: number;
const response = await apiClient.get(`/cabinet/admin/users/${userId}/transactions`, { params }) transaction_type?: string;
return response.data } = {},
): Promise<{
transactions: UserTransactionItem[];
total: number;
offset: number;
limit: number;
}> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/transactions`, { params });
return response.data;
}, },
// Sync status // Sync status
getSyncStatus: async (userId: number): Promise<PanelSyncStatusResponse> => { getSyncStatus: async (userId: number): Promise<PanelSyncStatusResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/sync/status`) const response = await apiClient.get(`/cabinet/admin/users/${userId}/sync/status`);
return response.data return response.data;
}, },
// Sync from panel // Sync from panel
syncFromPanel: async (userId: number, data: SyncFromPanelRequest = {}): Promise<SyncFromPanelResponse> => { syncFromPanel: async (
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/from-panel`, data) userId: number,
return response.data data: SyncFromPanelRequest = {},
): Promise<SyncFromPanelResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/from-panel`, data);
return response.data;
}, },
// Sync to panel // Sync to panel
syncToPanel: async (userId: number, data: SyncToPanelRequest = {}): Promise<SyncToPanelResponse> => { syncToPanel: async (
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/to-panel`, data) userId: number,
return response.data data: SyncToPanelRequest = {},
): Promise<SyncToPanelResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/to-panel`, data);
return response.data;
}, },
} };

View File

@@ -1,27 +1,27 @@
import apiClient from './client' import apiClient from './client';
import type { AuthResponse, RegisterResponse, TokenResponse, User } from '../types' import type { AuthResponse, RegisterResponse, TokenResponse, User } from '../types';
export const authApi = { export const authApi = {
// Telegram WebApp authentication // Telegram WebApp authentication
loginTelegram: async (initData: string): Promise<AuthResponse> => { loginTelegram: async (initData: string): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram', { const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram', {
init_data: initData, init_data: initData,
}) });
return response.data return response.data;
}, },
// Telegram Login Widget authentication // Telegram Login Widget authentication
loginTelegramWidget: async (data: { loginTelegramWidget: async (data: {
id: number id: number;
first_name: string first_name: string;
last_name?: string last_name?: string;
username?: string username?: string;
photo_url?: string photo_url?: string;
auth_date: number auth_date: number;
hash: string hash: string;
}): Promise<AuthResponse> => { }): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', data) const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', data);
return response.data return response.data;
}, },
// Email login // Email login
@@ -29,63 +29,69 @@ export const authApi = {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/login', { const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/login', {
email, email,
password, password,
}) });
return response.data return response.data;
}, },
// Register email (link to existing Telegram account) // Register email (link to existing Telegram account)
registerEmail: async (email: string, password: string): Promise<{ message: string; email: string }> => { registerEmail: async (
email: string,
password: string,
): Promise<{ message: string; email: string }> => {
const response = await apiClient.post('/cabinet/auth/email/register', { const response = await apiClient.post('/cabinet/auth/email/register', {
email, email,
password, password,
}) });
return response.data return response.data;
}, },
// Register standalone email account (no Telegram required) // Register standalone email account (no Telegram required)
// Returns message - user must verify email before login // Returns message - user must verify email before login
registerEmailStandalone: async (data: { registerEmailStandalone: async (data: {
email: string email: string;
password: string password: string;
first_name?: string first_name?: string;
language?: string language?: string;
referral_code?: string referral_code?: string;
}): Promise<RegisterResponse> => { }): Promise<RegisterResponse> => {
const response = await apiClient.post<RegisterResponse>('/cabinet/auth/email/register/standalone', data) const response = await apiClient.post<RegisterResponse>(
return response.data '/cabinet/auth/email/register/standalone',
data,
);
return response.data;
}, },
// Verify email and get auth tokens // Verify email and get auth tokens
verifyEmail: async (token: string): Promise<AuthResponse> => { verifyEmail: async (token: string): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/verify', { token }) const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/verify', { token });
return response.data return response.data;
}, },
// Resend verification email // Resend verification email
resendVerification: async (): Promise<{ message: string }> => { resendVerification: async (): Promise<{ message: string }> => {
const response = await apiClient.post('/cabinet/auth/email/resend') const response = await apiClient.post('/cabinet/auth/email/resend');
return response.data return response.data;
}, },
// Refresh token // Refresh token
refreshToken: async (refreshToken: string): Promise<TokenResponse> => { refreshToken: async (refreshToken: string): Promise<TokenResponse> => {
const response = await apiClient.post<TokenResponse>('/cabinet/auth/refresh', { const response = await apiClient.post<TokenResponse>('/cabinet/auth/refresh', {
refresh_token: refreshToken, refresh_token: refreshToken,
}) });
return response.data return response.data;
}, },
// Logout // Logout
logout: async (refreshToken: string): Promise<void> => { logout: async (refreshToken: string): Promise<void> => {
await apiClient.post('/cabinet/auth/logout', { await apiClient.post('/cabinet/auth/logout', {
refresh_token: refreshToken, refresh_token: refreshToken,
}) });
}, },
// Forgot password // Forgot password
forgotPassword: async (email: string): Promise<{ message: string }> => { forgotPassword: async (email: string): Promise<{ message: string }> => {
const response = await apiClient.post('/cabinet/auth/password/forgot', { email }) const response = await apiClient.post('/cabinet/auth/password/forgot', { email });
return response.data return response.data;
}, },
// Reset password // Reset password
@@ -93,13 +99,13 @@ export const authApi = {
const response = await apiClient.post('/cabinet/auth/password/reset', { const response = await apiClient.post('/cabinet/auth/password/reset', {
token, token,
password, password,
}) });
return response.data return response.data;
}, },
// Get current user // Get current user
getMe: async (): Promise<User> => { getMe: async (): Promise<User> => {
const response = await apiClient.get<User>('/cabinet/auth/me') const response = await apiClient.get<User>('/cabinet/auth/me');
return response.data return response.data;
}, },
} };

View File

@@ -1,100 +1,124 @@
import apiClient from './client' import apiClient from './client';
import type { Balance, Transaction, PaymentMethod, PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types' import type {
Balance,
Transaction,
PaymentMethod,
PaginatedResponse,
PendingPayment,
ManualCheckResponse,
} from '../types';
export const balanceApi = { export const balanceApi = {
// Get current balance // Get current balance
getBalance: async (): Promise<Balance> => { getBalance: async (): Promise<Balance> => {
const response = await apiClient.get<Balance>('/cabinet/balance') const response = await apiClient.get<Balance>('/cabinet/balance');
return response.data return response.data;
}, },
// Get transaction history // Get transaction history
getTransactions: async (params?: { getTransactions: async (params?: {
page?: number page?: number;
per_page?: number per_page?: number;
type?: string type?: string;
}): Promise<PaginatedResponse<Transaction>> => { }): Promise<PaginatedResponse<Transaction>> => {
const response = await apiClient.get<PaginatedResponse<Transaction>>('/cabinet/balance/transactions', { const response = await apiClient.get<PaginatedResponse<Transaction>>(
'/cabinet/balance/transactions',
{
params, params,
}) },
return response.data );
return response.data;
}, },
// Get available payment methods // Get available payment methods
getPaymentMethods: async (): Promise<PaymentMethod[]> => { getPaymentMethods: async (): Promise<PaymentMethod[]> => {
const response = await apiClient.get<PaymentMethod[]>('/cabinet/balance/payment-methods') const response = await apiClient.get<PaymentMethod[]>('/cabinet/balance/payment-methods');
return response.data return response.data;
}, },
// Create top-up payment // Create top-up payment
createTopUp: async (amountKopeks: number, paymentMethod: string, paymentOption?: string): Promise<{ createTopUp: async (
payment_id: string amountKopeks: number,
payment_url: string paymentMethod: string,
amount_kopeks: number paymentOption?: string,
amount_rubles: number ): Promise<{
status: string payment_id: string;
expires_at: string | null payment_url: string;
amount_kopeks: number;
amount_rubles: number;
status: string;
expires_at: string | null;
}> => { }> => {
const payload: { const payload: {
amount_kopeks: number amount_kopeks: number;
payment_method: string payment_method: string;
payment_option?: string payment_option?: string;
} = { } = {
amount_kopeks: amountKopeks, amount_kopeks: amountKopeks,
payment_method: paymentMethod, payment_method: paymentMethod,
} };
if (paymentOption) { if (paymentOption) {
payload.payment_option = paymentOption payload.payment_option = paymentOption;
} }
const response = await apiClient.post('/cabinet/balance/topup', payload) const response = await apiClient.post('/cabinet/balance/topup', payload);
return response.data return response.data;
}, },
// Activate promo code // Activate promo code
activatePromocode: async (code: string): Promise<{ activatePromocode: async (
success: boolean code: string,
message: string ): Promise<{
balance_before: number success: boolean;
balance_after: number message: string;
bonus_description: string | null balance_before: number;
balance_after: number;
bonus_description: string | null;
}> => { }> => {
const response = await apiClient.post('/cabinet/promocode/activate', { code }) const response = await apiClient.post('/cabinet/promocode/activate', { code });
return response.data return response.data;
}, },
// Create Telegram Stars invoice for Mini App balance top-up // Create Telegram Stars invoice for Mini App balance top-up
createStarsInvoice: async (amountKopeks: number): Promise<{ createStarsInvoice: async (
invoice_url: string amountKopeks: number,
stars_amount?: number ): Promise<{
amount_kopeks?: number invoice_url: string;
stars_amount?: number;
amount_kopeks?: number;
}> => { }> => {
const response = await apiClient.post('/cabinet/balance/stars-invoice', { const response = await apiClient.post('/cabinet/balance/stars-invoice', {
amount_kopeks: amountKopeks, amount_kopeks: amountKopeks,
}) });
return response.data return response.data;
}, },
// Get pending payments for manual verification // Get pending payments for manual verification
getPendingPayments: async (params?: { getPendingPayments: async (params?: {
page?: number page?: number;
per_page?: number per_page?: number;
}): Promise<PaginatedResponse<PendingPayment>> => { }): Promise<PaginatedResponse<PendingPayment>> => {
const response = await apiClient.get<PaginatedResponse<PendingPayment>>('/cabinet/balance/pending-payments', { const response = await apiClient.get<PaginatedResponse<PendingPayment>>(
'/cabinet/balance/pending-payments',
{
params, params,
}) },
return response.data );
return response.data;
}, },
// Get specific pending payment details // Get specific pending payment details
getPendingPayment: async (method: string, paymentId: number): Promise<PendingPayment> => { getPendingPayment: async (method: string, paymentId: number): Promise<PendingPayment> => {
const response = await apiClient.get<PendingPayment>(`/cabinet/balance/pending-payments/${method}/${paymentId}`) const response = await apiClient.get<PendingPayment>(
return response.data `/cabinet/balance/pending-payments/${method}/${paymentId}`,
);
return response.data;
}, },
// Manually check payment status // Manually check payment status
checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => { checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => {
const response = await apiClient.post<ManualCheckResponse>(`/cabinet/balance/pending-payments/${method}/${paymentId}/check`) const response = await apiClient.post<ManualCheckResponse>(
return response.data `/cabinet/balance/pending-payments/${method}/${paymentId}/check`,
);
return response.data;
}, },
} };

View File

@@ -1,270 +1,270 @@
import apiClient from './client' import apiClient from './client';
// === Types === // === Types ===
export interface BanSystemStatus { export interface BanSystemStatus {
enabled: boolean enabled: boolean;
configured: boolean configured: boolean;
} }
export interface BanSystemStats { export interface BanSystemStats {
total_users: number total_users: number;
active_users: number active_users: number;
users_over_limit: number users_over_limit: number;
total_requests: number total_requests: number;
total_punishments: number total_punishments: number;
active_punishments: number active_punishments: number;
nodes_online: number nodes_online: number;
nodes_total: number nodes_total: number;
agents_online: number agents_online: number;
agents_total: number agents_total: number;
panel_connected: boolean panel_connected: boolean;
uptime_seconds: number | null uptime_seconds: number | null;
} }
export interface BanUserIPInfo { export interface BanUserIPInfo {
ip: string ip: string;
first_seen: string | null first_seen: string | null;
last_seen: string | null last_seen: string | null;
node: string | null node: string | null;
request_count: number request_count: number;
country_code: string | null country_code: string | null;
country_name: string | null country_name: string | null;
city: string | null city: string | null;
} }
export interface BanUserRequestLog { export interface BanUserRequestLog {
timestamp: string timestamp: string;
source_ip: string source_ip: string;
destination: string | null destination: string | null;
dest_port: number | null dest_port: number | null;
protocol: string | null protocol: string | null;
action: string | null action: string | null;
node: string | null node: string | null;
} }
export interface BanUserListItem { export interface BanUserListItem {
email: string email: string;
unique_ip_count: number unique_ip_count: number;
total_requests: number total_requests: number;
limit: number | null limit: number | null;
is_over_limit: boolean is_over_limit: boolean;
blocked_count: number blocked_count: number;
last_seen: string | null last_seen: string | null;
} }
export interface BanUsersListResponse { export interface BanUsersListResponse {
users: BanUserListItem[] users: BanUserListItem[];
total: number total: number;
offset: number offset: number;
limit: number limit: number;
} }
export interface BanUserDetailResponse { export interface BanUserDetailResponse {
email: string email: string;
unique_ip_count: number unique_ip_count: number;
total_requests: number total_requests: number;
limit: number | null limit: number | null;
is_over_limit: boolean is_over_limit: boolean;
blocked_count: number blocked_count: number;
ips: BanUserIPInfo[] ips: BanUserIPInfo[];
recent_requests: BanUserRequestLog[] recent_requests: BanUserRequestLog[];
network_type: string | null network_type: string | null;
} }
export interface BanPunishmentItem { export interface BanPunishmentItem {
id: number | null id: number | null;
user_id: string user_id: string;
uuid: string | null uuid: string | null;
username: string username: string;
reason: string | null reason: string | null;
punished_at: string punished_at: string;
enable_at: string | null enable_at: string | null;
ip_count: number ip_count: number;
limit: number limit: number;
enabled: boolean enabled: boolean;
enabled_at: string | null enabled_at: string | null;
node_name: string | null node_name: string | null;
} }
export interface BanPunishmentsListResponse { export interface BanPunishmentsListResponse {
punishments: BanPunishmentItem[] punishments: BanPunishmentItem[];
total: number total: number;
} }
export interface BanHistoryResponse { export interface BanHistoryResponse {
items: BanPunishmentItem[] items: BanPunishmentItem[];
total: number total: number;
} }
export interface BanUserRequest { export interface BanUserRequest {
username: string username: string;
minutes: number minutes: number;
reason?: string reason?: string;
} }
export interface UnbanResponse { export interface UnbanResponse {
success: boolean success: boolean;
message: string message: string;
} }
export interface BanNodeItem { export interface BanNodeItem {
name: string name: string;
address: string | null address: string | null;
is_connected: boolean is_connected: boolean;
last_seen: string | null last_seen: string | null;
users_count: number users_count: number;
agent_stats: Record<string, unknown> | null agent_stats: Record<string, unknown> | null;
} }
export interface BanNodesListResponse { export interface BanNodesListResponse {
nodes: BanNodeItem[] nodes: BanNodeItem[];
total: number total: number;
online: number online: number;
} }
export interface BanAgentItem { export interface BanAgentItem {
node_name: string node_name: string;
sent_total: number sent_total: number;
dropped_total: number dropped_total: number;
batches_total: number batches_total: number;
reconnects: number reconnects: number;
failures: number failures: number;
queue_size: number queue_size: number;
queue_max: number queue_max: number;
dedup_checked: number dedup_checked: number;
dedup_skipped: number dedup_skipped: number;
filter_checked: number filter_checked: number;
filter_filtered: number filter_filtered: number;
health: string health: string;
is_online: boolean is_online: boolean;
last_report: string | null last_report: string | null;
} }
export interface BanAgentsSummary { export interface BanAgentsSummary {
total_agents: number total_agents: number;
online_agents: number online_agents: number;
total_sent: number total_sent: number;
total_dropped: number total_dropped: number;
avg_queue_size: number avg_queue_size: number;
healthy_count: number healthy_count: number;
warning_count: number warning_count: number;
critical_count: number critical_count: number;
} }
export interface BanAgentsListResponse { export interface BanAgentsListResponse {
agents: BanAgentItem[] agents: BanAgentItem[];
summary: BanAgentsSummary | null summary: BanAgentsSummary | null;
total: number total: number;
online: number online: number;
} }
export interface BanTrafficViolationItem { export interface BanTrafficViolationItem {
id: number | null id: number | null;
username: string username: string;
email: string | null email: string | null;
violation_type: string violation_type: string;
description: string | null description: string | null;
bytes_used: number bytes_used: number;
bytes_limit: number bytes_limit: number;
detected_at: string detected_at: string;
resolved: boolean resolved: boolean;
} }
export interface BanTrafficViolationsResponse { export interface BanTrafficViolationsResponse {
violations: BanTrafficViolationItem[] violations: BanTrafficViolationItem[];
total: number total: number;
} }
export interface BanTrafficTopItem { export interface BanTrafficTopItem {
username: string username: string;
bytes_total: number bytes_total: number;
bytes_limit: number | null bytes_limit: number | null;
over_limit: boolean over_limit: boolean;
} }
export interface BanTrafficResponse { export interface BanTrafficResponse {
enabled: boolean enabled: boolean;
stats: Record<string, unknown> | null stats: Record<string, unknown> | null;
top_users: BanTrafficTopItem[] top_users: BanTrafficTopItem[];
recent_violations: BanTrafficViolationItem[] recent_violations: BanTrafficViolationItem[];
} }
// === Settings Types === // === Settings Types ===
export interface BanSettingDefinition { export interface BanSettingDefinition {
key: string key: string;
value: unknown value: unknown;
type: string type: string;
min_value: number | null min_value: number | null;
max_value: number | null max_value: number | null;
editable: boolean editable: boolean;
description: string | null description: string | null;
category: string | null category: string | null;
} }
export interface BanSettingsResponse { export interface BanSettingsResponse {
settings: BanSettingDefinition[] settings: BanSettingDefinition[];
} }
export interface BanWhitelistRequest { export interface BanWhitelistRequest {
username: string username: string;
} }
// === Report Types === // === Report Types ===
export interface BanReportTopViolator { export interface BanReportTopViolator {
username: string username: string;
count: number count: number;
} }
export interface BanReportResponse { export interface BanReportResponse {
period_hours: number period_hours: number;
current_users: number current_users: number;
current_ips: number current_ips: number;
punishment_stats: Record<string, unknown> | null punishment_stats: Record<string, unknown> | null;
top_violators: BanReportTopViolator[] top_violators: BanReportTopViolator[];
} }
// === Health Types === // === Health Types ===
export interface BanHealthComponent { export interface BanHealthComponent {
name: string name: string;
status: string status: string;
message: string | null message: string | null;
details: Record<string, unknown> | null details: Record<string, unknown> | null;
} }
export interface BanHealthResponse { export interface BanHealthResponse {
status: string status: string;
uptime: number | null uptime: number | null;
components: BanHealthComponent[] components: BanHealthComponent[];
} }
export interface BanHealthDetailedResponse { export interface BanHealthDetailedResponse {
status: string status: string;
uptime: number | null uptime: number | null;
components: Record<string, unknown> components: Record<string, unknown>;
} }
// === Agent History Types === // === Agent History Types ===
export interface BanAgentHistoryItem { export interface BanAgentHistoryItem {
timestamp: string timestamp: string;
sent_total: number sent_total: number;
dropped_total: number dropped_total: number;
queue_size: number queue_size: number;
batches_total: number batches_total: number;
} }
export interface BanAgentHistoryResponse { export interface BanAgentHistoryResponse {
node: string node: string;
hours: number hours: number;
records: number records: number;
delta: Record<string, unknown> | null delta: Record<string, unknown> | null;
first: Record<string, unknown> | null first: Record<string, unknown> | null;
last: Record<string, unknown> | null last: Record<string, unknown> | null;
history: BanAgentHistoryItem[] history: BanAgentHistoryItem[];
} }
// === API === // === API ===
@@ -272,174 +272,198 @@ export interface BanAgentHistoryResponse {
export const banSystemApi = { export const banSystemApi = {
// Status // Status
getStatus: async (): Promise<BanSystemStatus> => { getStatus: async (): Promise<BanSystemStatus> => {
const response = await apiClient.get('/cabinet/admin/ban-system/status') const response = await apiClient.get('/cabinet/admin/ban-system/status');
return response.data return response.data;
}, },
// Stats // Stats
getStats: async (): Promise<BanSystemStats> => { getStats: async (): Promise<BanSystemStats> => {
const response = await apiClient.get('/cabinet/admin/ban-system/stats') const response = await apiClient.get('/cabinet/admin/ban-system/stats');
return response.data return response.data;
}, },
// Users // Users
getUsers: async (params: { getUsers: async (
offset?: number params: {
limit?: number offset?: number;
status?: string limit?: number;
} = {}): Promise<BanUsersListResponse> => { status?: string;
const response = await apiClient.get('/cabinet/admin/ban-system/users', { params }) } = {},
return response.data ): Promise<BanUsersListResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/users', { params });
return response.data;
}, },
getUsersOverLimit: async (limit: number = 50): Promise<BanUsersListResponse> => { getUsersOverLimit: async (limit: number = 50): Promise<BanUsersListResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/users/over-limit', { const response = await apiClient.get('/cabinet/admin/ban-system/users/over-limit', {
params: { limit } params: { limit },
}) });
return response.data return response.data;
}, },
searchUsers: async (query: string): Promise<BanUsersListResponse> => { searchUsers: async (query: string): Promise<BanUsersListResponse> => {
const response = await apiClient.get(`/cabinet/admin/ban-system/users/search/${encodeURIComponent(query)}`) const response = await apiClient.get(
return response.data `/cabinet/admin/ban-system/users/search/${encodeURIComponent(query)}`,
);
return response.data;
}, },
getUser: async (email: string): Promise<BanUserDetailResponse> => { getUser: async (email: string): Promise<BanUserDetailResponse> => {
const response = await apiClient.get(`/cabinet/admin/ban-system/users/${encodeURIComponent(email)}`) const response = await apiClient.get(
return response.data `/cabinet/admin/ban-system/users/${encodeURIComponent(email)}`,
);
return response.data;
}, },
// Punishments // Punishments
getPunishments: async (): Promise<BanPunishmentsListResponse> => { getPunishments: async (): Promise<BanPunishmentsListResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/punishments') const response = await apiClient.get('/cabinet/admin/ban-system/punishments');
return response.data return response.data;
}, },
unbanUser: async (userId: string): Promise<UnbanResponse> => { unbanUser: async (userId: string): Promise<UnbanResponse> => {
const response = await apiClient.post(`/cabinet/admin/ban-system/punishments/${userId}/unban`) const response = await apiClient.post(`/cabinet/admin/ban-system/punishments/${userId}/unban`);
return response.data return response.data;
}, },
banUser: async (data: BanUserRequest): Promise<UnbanResponse> => { banUser: async (data: BanUserRequest): Promise<UnbanResponse> => {
const response = await apiClient.post('/cabinet/admin/ban-system/ban', data) const response = await apiClient.post('/cabinet/admin/ban-system/ban', data);
return response.data return response.data;
}, },
getPunishmentHistory: async (query: string, limit: number = 20): Promise<BanHistoryResponse> => { getPunishmentHistory: async (query: string, limit: number = 20): Promise<BanHistoryResponse> => {
const response = await apiClient.get(`/cabinet/admin/ban-system/history/${encodeURIComponent(query)}`, { const response = await apiClient.get(
params: { limit } `/cabinet/admin/ban-system/history/${encodeURIComponent(query)}`,
}) {
return response.data params: { limit },
},
);
return response.data;
}, },
// Nodes // Nodes
getNodes: async (): Promise<BanNodesListResponse> => { getNodes: async (): Promise<BanNodesListResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/nodes') const response = await apiClient.get('/cabinet/admin/ban-system/nodes');
return response.data return response.data;
}, },
// Agents // Agents
getAgents: async (params: { getAgents: async (
search?: string params: {
health?: string search?: string;
status?: string health?: string;
} = {}): Promise<BanAgentsListResponse> => { status?: string;
const response = await apiClient.get('/cabinet/admin/ban-system/agents', { params }) } = {},
return response.data ): Promise<BanAgentsListResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/agents', { params });
return response.data;
}, },
getAgentsSummary: async (): Promise<BanAgentsSummary> => { getAgentsSummary: async (): Promise<BanAgentsSummary> => {
const response = await apiClient.get('/cabinet/admin/ban-system/agents/summary') const response = await apiClient.get('/cabinet/admin/ban-system/agents/summary');
return response.data return response.data;
}, },
// Traffic violations // Traffic violations
getTrafficViolations: async (limit: number = 50): Promise<BanTrafficViolationsResponse> => { getTrafficViolations: async (limit: number = 50): Promise<BanTrafficViolationsResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/traffic/violations', { const response = await apiClient.get('/cabinet/admin/ban-system/traffic/violations', {
params: { limit } params: { limit },
}) });
return response.data return response.data;
}, },
// Full Traffic // Full Traffic
getTraffic: async (): Promise<BanTrafficResponse> => { getTraffic: async (): Promise<BanTrafficResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/traffic') const response = await apiClient.get('/cabinet/admin/ban-system/traffic');
return response.data return response.data;
}, },
getTrafficTop: async (limit: number = 20): Promise<BanTrafficTopItem[]> => { getTrafficTop: async (limit: number = 20): Promise<BanTrafficTopItem[]> => {
const response = await apiClient.get('/cabinet/admin/ban-system/traffic/top', { const response = await apiClient.get('/cabinet/admin/ban-system/traffic/top', {
params: { limit } params: { limit },
}) });
return response.data return response.data;
}, },
// Settings // Settings
getSettings: async (): Promise<BanSettingsResponse> => { getSettings: async (): Promise<BanSettingsResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/settings') const response = await apiClient.get('/cabinet/admin/ban-system/settings');
return response.data return response.data;
}, },
getSetting: async (key: string): Promise<BanSettingDefinition> => { getSetting: async (key: string): Promise<BanSettingDefinition> => {
const response = await apiClient.get(`/cabinet/admin/ban-system/settings/${key}`) const response = await apiClient.get(`/cabinet/admin/ban-system/settings/${key}`);
return response.data return response.data;
}, },
setSetting: async (key: string, value: string): Promise<BanSettingDefinition> => { setSetting: async (key: string, value: string): Promise<BanSettingDefinition> => {
const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}`, null, { const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}`, null, {
params: { value } params: { value },
}) });
return response.data return response.data;
}, },
toggleSetting: async (key: string): Promise<BanSettingDefinition> => { toggleSetting: async (key: string): Promise<BanSettingDefinition> => {
const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}/toggle`) const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}/toggle`);
return response.data return response.data;
}, },
// Whitelist // Whitelist
whitelistAdd: async (username: string): Promise<UnbanResponse> => { whitelistAdd: async (username: string): Promise<UnbanResponse> => {
const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/add', { username }) const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/add', {
return response.data username,
});
return response.data;
}, },
whitelistRemove: async (username: string): Promise<UnbanResponse> => { whitelistRemove: async (username: string): Promise<UnbanResponse> => {
const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/remove', { username }) const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/remove', {
return response.data username,
});
return response.data;
}, },
// Reports // Reports
getReport: async (hours: number = 24): Promise<BanReportResponse> => { getReport: async (hours: number = 24): Promise<BanReportResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/report', { const response = await apiClient.get('/cabinet/admin/ban-system/report', {
params: { hours } params: { hours },
}) });
return response.data return response.data;
}, },
// Health // Health
getHealth: async (): Promise<BanHealthResponse> => { getHealth: async (): Promise<BanHealthResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/health') const response = await apiClient.get('/cabinet/admin/ban-system/health');
return response.data return response.data;
}, },
getHealthDetailed: async (): Promise<BanHealthDetailedResponse> => { getHealthDetailed: async (): Promise<BanHealthDetailedResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/health/detailed') const response = await apiClient.get('/cabinet/admin/ban-system/health/detailed');
return response.data return response.data;
}, },
// Agent History // Agent History
getAgentHistory: async (nodeName: string, hours: number = 24): Promise<BanAgentHistoryResponse> => { getAgentHistory: async (
const response = await apiClient.get(`/cabinet/admin/ban-system/agents/${encodeURIComponent(nodeName)}/history`, { nodeName: string,
params: { hours } hours: number = 24,
}) ): Promise<BanAgentHistoryResponse> => {
return response.data const response = await apiClient.get(
`/cabinet/admin/ban-system/agents/${encodeURIComponent(nodeName)}/history`,
{
params: { hours },
},
);
return response.data;
}, },
// User Punishment History // User Punishment History
getUserHistory: async (email: string, limit: number = 20): Promise<BanHistoryResponse> => { getUserHistory: async (email: string, limit: number = 20): Promise<BanHistoryResponse> => {
const response = await apiClient.get(`/cabinet/admin/ban-system/users/${encodeURIComponent(email)}/history`, { const response = await apiClient.get(
params: { limit } `/cabinet/admin/ban-system/users/${encodeURIComponent(email)}/history`,
}) {
return response.data params: { limit },
}, },
} );
return response.data;
},
};

View File

@@ -1,203 +1,209 @@
import apiClient from './client' import apiClient from './client';
export interface BrandingInfo { export interface BrandingInfo {
name: string name: string;
logo_url: string | null logo_url: string | null;
logo_letter: string logo_letter: string;
has_custom_logo: boolean has_custom_logo: boolean;
} }
export interface AnimationEnabled { export interface AnimationEnabled {
enabled: boolean enabled: boolean;
} }
export interface FullscreenEnabled { export interface FullscreenEnabled {
enabled: boolean enabled: boolean;
} }
export interface EmailAuthEnabled { export interface EmailAuthEnabled {
enabled: boolean enabled: boolean;
} }
export interface AnalyticsCounters { export interface AnalyticsCounters {
yandex_metrika_id: string yandex_metrika_id: string;
google_ads_id: string google_ads_id: string;
google_ads_label: string google_ads_label: string;
} }
const BRANDING_CACHE_KEY = 'cabinet_branding' const BRANDING_CACHE_KEY = 'cabinet_branding';
const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded' const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded';
// Check if logo was already preloaded in this session // Check if logo was already preloaded in this session
export const isLogoPreloaded = (): boolean => { export const isLogoPreloaded = (): boolean => {
try { try {
const cached = getCachedBranding() const cached = getCachedBranding();
if (!cached?.has_custom_logo || !cached?.logo_url) { if (!cached?.has_custom_logo || !cached?.logo_url) {
return false return false;
} }
const logoUrl = `${import.meta.env.VITE_API_URL || ''}${cached.logo_url}` const logoUrl = `${import.meta.env.VITE_API_URL || ''}${cached.logo_url}`;
const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY) const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY);
return preloaded === logoUrl return preloaded === logoUrl;
} catch { } catch {
return false return false;
} }
} };
// Get cached branding from localStorage // Get cached branding from localStorage
export const getCachedBranding = (): BrandingInfo | null => { export const getCachedBranding = (): BrandingInfo | null => {
try { try {
const cached = localStorage.getItem(BRANDING_CACHE_KEY) const cached = localStorage.getItem(BRANDING_CACHE_KEY);
if (cached) { if (cached) {
return JSON.parse(cached) return JSON.parse(cached);
} }
} catch { } catch {
// localStorage not available or invalid JSON // localStorage not available or invalid JSON
} }
return null return null;
} };
// Update branding cache in localStorage // Update branding cache in localStorage
export const setCachedBranding = (branding: BrandingInfo) => { export const setCachedBranding = (branding: BrandingInfo) => {
try { try {
localStorage.setItem(BRANDING_CACHE_KEY, JSON.stringify(branding)) localStorage.setItem(BRANDING_CACHE_KEY, JSON.stringify(branding));
} catch { } catch {
// localStorage not available // localStorage not available
} }
} };
// Preload logo image for instant display // Preload logo image for instant display
export const preloadLogo = (branding: BrandingInfo): Promise<void> => { export const preloadLogo = (branding: BrandingInfo): Promise<void> => {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!branding.has_custom_logo || !branding.logo_url) { if (!branding.has_custom_logo || !branding.logo_url) {
resolve() resolve();
return return;
} }
const logoUrl = `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}` const logoUrl = `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}`;
// Check if already preloaded in this session // Check if already preloaded in this session
const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY) const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY);
if (preloaded === logoUrl) { if (preloaded === logoUrl) {
resolve() resolve();
return return;
} }
const img = new Image() const img = new Image();
img.onload = () => { img.onload = () => {
sessionStorage.setItem(LOGO_PRELOADED_KEY, logoUrl) sessionStorage.setItem(LOGO_PRELOADED_KEY, logoUrl);
resolve() resolve();
} };
img.onerror = () => resolve() img.onerror = () => resolve();
img.src = logoUrl img.src = logoUrl;
}) });
} };
// Initialize logo preload from cache on page load // Initialize logo preload from cache on page load
export const initLogoPreload = () => { export const initLogoPreload = () => {
const cached = getCachedBranding() const cached = getCachedBranding();
if (cached) { if (cached) {
preloadLogo(cached) preloadLogo(cached);
} }
} };
export const brandingApi = { export const brandingApi = {
// Get current branding (public, no auth required) // Get current branding (public, no auth required)
getBranding: async (): Promise<BrandingInfo> => { getBranding: async (): Promise<BrandingInfo> => {
const response = await apiClient.get<BrandingInfo>('/cabinet/branding') const response = await apiClient.get<BrandingInfo>('/cabinet/branding');
return response.data return response.data;
}, },
// Update project name (admin only) // Update project name (admin only)
updateName: async (name: string): Promise<BrandingInfo> => { updateName: async (name: string): Promise<BrandingInfo> => {
const response = await apiClient.put<BrandingInfo>('/cabinet/branding/name', { name }) const response = await apiClient.put<BrandingInfo>('/cabinet/branding/name', { name });
return response.data return response.data;
}, },
// Upload custom logo (admin only) // Upload custom logo (admin only)
uploadLogo: async (file: File): Promise<BrandingInfo> => { uploadLogo: async (file: File): Promise<BrandingInfo> => {
const formData = new FormData() const formData = new FormData();
formData.append('file', file) formData.append('file', file);
const response = await apiClient.post<BrandingInfo>('/cabinet/branding/logo', formData, { const response = await apiClient.post<BrandingInfo>('/cabinet/branding/logo', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
}) });
return response.data return response.data;
}, },
// Delete custom logo (admin only) // Delete custom logo (admin only)
deleteLogo: async (): Promise<BrandingInfo> => { deleteLogo: async (): Promise<BrandingInfo> => {
const response = await apiClient.delete<BrandingInfo>('/cabinet/branding/logo') const response = await apiClient.delete<BrandingInfo>('/cabinet/branding/logo');
return response.data return response.data;
}, },
// Get logo URL (without cache busting - server handles caching via Cache-Control headers) // Get logo URL (without cache busting - server handles caching via Cache-Control headers)
getLogoUrl: (branding: BrandingInfo): string | null => { getLogoUrl: (branding: BrandingInfo): string | null => {
if (!branding.has_custom_logo || !branding.logo_url) { if (!branding.has_custom_logo || !branding.logo_url) {
return null return null;
} }
return `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}` return `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}`;
}, },
// Get animation enabled (public, no auth required) // Get animation enabled (public, no auth required)
getAnimationEnabled: async (): Promise<AnimationEnabled> => { getAnimationEnabled: async (): Promise<AnimationEnabled> => {
const response = await apiClient.get<AnimationEnabled>('/cabinet/branding/animation') const response = await apiClient.get<AnimationEnabled>('/cabinet/branding/animation');
return response.data return response.data;
}, },
// Update animation enabled (admin only) // Update animation enabled (admin only)
updateAnimationEnabled: async (enabled: boolean): Promise<AnimationEnabled> => { updateAnimationEnabled: async (enabled: boolean): Promise<AnimationEnabled> => {
const response = await apiClient.patch<AnimationEnabled>('/cabinet/branding/animation', { enabled }) const response = await apiClient.patch<AnimationEnabled>('/cabinet/branding/animation', {
return response.data enabled,
});
return response.data;
}, },
// Get fullscreen enabled (public, no auth required) // Get fullscreen enabled (public, no auth required)
getFullscreenEnabled: async (): Promise<FullscreenEnabled> => { getFullscreenEnabled: async (): Promise<FullscreenEnabled> => {
try { try {
const response = await apiClient.get<FullscreenEnabled>('/cabinet/branding/fullscreen') const response = await apiClient.get<FullscreenEnabled>('/cabinet/branding/fullscreen');
return response.data return response.data;
} catch { } catch {
// If endpoint doesn't exist, default to disabled // If endpoint doesn't exist, default to disabled
return { enabled: false } return { enabled: false };
} }
}, },
// Update fullscreen enabled (admin only) // Update fullscreen enabled (admin only)
updateFullscreenEnabled: async (enabled: boolean): Promise<FullscreenEnabled> => { updateFullscreenEnabled: async (enabled: boolean): Promise<FullscreenEnabled> => {
const response = await apiClient.patch<FullscreenEnabled>('/cabinet/branding/fullscreen', { enabled }) const response = await apiClient.patch<FullscreenEnabled>('/cabinet/branding/fullscreen', {
return response.data enabled,
});
return response.data;
}, },
// Get email auth enabled (public, no auth required) // Get email auth enabled (public, no auth required)
getEmailAuthEnabled: async (): Promise<EmailAuthEnabled> => { getEmailAuthEnabled: async (): Promise<EmailAuthEnabled> => {
try { try {
const response = await apiClient.get<EmailAuthEnabled>('/cabinet/branding/email-auth') const response = await apiClient.get<EmailAuthEnabled>('/cabinet/branding/email-auth');
return response.data return response.data;
} catch { } catch {
// If endpoint doesn't exist, default to enabled // If endpoint doesn't exist, default to enabled
return { enabled: true } return { enabled: true };
} }
}, },
// Update email auth enabled (admin only) // Update email auth enabled (admin only)
updateEmailAuthEnabled: async (enabled: boolean): Promise<EmailAuthEnabled> => { updateEmailAuthEnabled: async (enabled: boolean): Promise<EmailAuthEnabled> => {
const response = await apiClient.patch<EmailAuthEnabled>('/cabinet/branding/email-auth', { enabled }) const response = await apiClient.patch<EmailAuthEnabled>('/cabinet/branding/email-auth', {
return response.data enabled,
});
return response.data;
}, },
// Get analytics counters (public, no auth required) // Get analytics counters (public, no auth required)
getAnalyticsCounters: async (): Promise<AnalyticsCounters> => { getAnalyticsCounters: async (): Promise<AnalyticsCounters> => {
try { try {
const response = await apiClient.get<AnalyticsCounters>('/cabinet/branding/analytics') const response = await apiClient.get<AnalyticsCounters>('/cabinet/branding/analytics');
return response.data return response.data;
} catch { } catch {
return { yandex_metrika_id: '', google_ads_id: '', google_ads_label: '' } return { yandex_metrika_id: '', google_ads_id: '', google_ads_label: '' };
} }
}, },
// Update analytics counters (admin only) // Update analytics counters (admin only)
updateAnalyticsCounters: async (data: Partial<AnalyticsCounters>): Promise<AnalyticsCounters> => { updateAnalyticsCounters: async (data: Partial<AnalyticsCounters>): Promise<AnalyticsCounters> => {
const response = await apiClient.patch<AnalyticsCounters>('/cabinet/branding/analytics', data) const response = await apiClient.patch<AnalyticsCounters>('/cabinet/branding/analytics', data);
return response.data return response.data;
}, },
} };

View File

@@ -1,230 +1,241 @@
import apiClient from './client' import apiClient from './client';
// Types // Types
export type CampaignBonusType = 'balance' | 'subscription' | 'none' | 'tariff' export type CampaignBonusType = 'balance' | 'subscription' | 'none' | 'tariff';
export interface TariffInfo { export interface TariffInfo {
id: number id: number;
name: string name: string;
} }
export interface CampaignListItem { export interface CampaignListItem {
id: number id: number;
name: string name: string;
start_parameter: string start_parameter: string;
bonus_type: CampaignBonusType bonus_type: CampaignBonusType;
is_active: boolean is_active: boolean;
registrations_count: number registrations_count: number;
total_revenue_kopeks: number total_revenue_kopeks: number;
conversion_rate: number conversion_rate: number;
created_at: string created_at: string;
} }
export interface CampaignListResponse { export interface CampaignListResponse {
campaigns: CampaignListItem[] campaigns: CampaignListItem[];
total: number total: number;
} }
export interface CampaignDetail { export interface CampaignDetail {
id: number id: number;
name: string name: string;
start_parameter: string start_parameter: string;
bonus_type: CampaignBonusType bonus_type: CampaignBonusType;
is_active: boolean is_active: boolean;
balance_bonus_kopeks: number balance_bonus_kopeks: number;
balance_bonus_rubles: number balance_bonus_rubles: number;
subscription_duration_days: number | null subscription_duration_days: number | null;
subscription_traffic_gb: number | null subscription_traffic_gb: number | null;
subscription_device_limit: number | null subscription_device_limit: number | null;
subscription_squads: string[] subscription_squads: string[];
tariff_id: number | null tariff_id: number | null;
tariff_duration_days: number | null tariff_duration_days: number | null;
tariff: TariffInfo | null tariff: TariffInfo | null;
created_by: number | null created_by: number | null;
created_at: string created_at: string;
updated_at: string | null updated_at: string | null;
deep_link: string | null deep_link: string | null;
} }
export interface CampaignCreateRequest { export interface CampaignCreateRequest {
name: string name: string;
start_parameter: string start_parameter: string;
bonus_type: CampaignBonusType bonus_type: CampaignBonusType;
is_active?: boolean is_active?: boolean;
balance_bonus_kopeks?: number balance_bonus_kopeks?: number;
subscription_duration_days?: number subscription_duration_days?: number;
subscription_traffic_gb?: number subscription_traffic_gb?: number;
subscription_device_limit?: number subscription_device_limit?: number;
subscription_squads?: string[] subscription_squads?: string[];
tariff_id?: number tariff_id?: number;
tariff_duration_days?: number tariff_duration_days?: number;
} }
export interface CampaignUpdateRequest { export interface CampaignUpdateRequest {
name?: string name?: string;
start_parameter?: string start_parameter?: string;
bonus_type?: CampaignBonusType bonus_type?: CampaignBonusType;
is_active?: boolean is_active?: boolean;
balance_bonus_kopeks?: number balance_bonus_kopeks?: number;
subscription_duration_days?: number subscription_duration_days?: number;
subscription_traffic_gb?: number subscription_traffic_gb?: number;
subscription_device_limit?: number subscription_device_limit?: number;
subscription_squads?: string[] subscription_squads?: string[];
tariff_id?: number tariff_id?: number;
tariff_duration_days?: number tariff_duration_days?: number;
} }
export interface CampaignToggleResponse { export interface CampaignToggleResponse {
id: number id: number;
is_active: boolean is_active: boolean;
message: string message: string;
} }
export interface CampaignStatistics { export interface CampaignStatistics {
id: number id: number;
name: string name: string;
start_parameter: string start_parameter: string;
bonus_type: CampaignBonusType bonus_type: CampaignBonusType;
is_active: boolean is_active: boolean;
registrations: number registrations: number;
balance_issued_kopeks: number balance_issued_kopeks: number;
balance_issued_rubles: number balance_issued_rubles: number;
subscription_issued: number subscription_issued: number;
last_registration: string | null last_registration: string | null;
total_revenue_kopeks: number total_revenue_kopeks: number;
total_revenue_rubles: number total_revenue_rubles: number;
avg_revenue_per_user_kopeks: number avg_revenue_per_user_kopeks: number;
avg_revenue_per_user_rubles: number avg_revenue_per_user_rubles: number;
avg_first_payment_kopeks: number avg_first_payment_kopeks: number;
avg_first_payment_rubles: number avg_first_payment_rubles: number;
trial_users_count: number trial_users_count: number;
active_trials_count: number active_trials_count: number;
conversion_count: number conversion_count: number;
paid_users_count: number paid_users_count: number;
conversion_rate: number conversion_rate: number;
trial_conversion_rate: number trial_conversion_rate: number;
deep_link: string | null deep_link: string | null;
} }
export interface CampaignRegistrationItem { export interface CampaignRegistrationItem {
id: number id: number;
user_id: number user_id: number;
telegram_id: number telegram_id: number;
username: string | null username: string | null;
first_name: string | null first_name: string | null;
bonus_type: string bonus_type: string;
balance_bonus_kopeks: number balance_bonus_kopeks: number;
subscription_duration_days: number | null subscription_duration_days: number | null;
tariff_id: number | null tariff_id: number | null;
tariff_duration_days: number | null tariff_duration_days: number | null;
created_at: string created_at: string;
user_balance_kopeks: number user_balance_kopeks: number;
has_subscription: boolean has_subscription: boolean;
has_paid: boolean has_paid: boolean;
} }
export interface CampaignRegistrationsResponse { export interface CampaignRegistrationsResponse {
registrations: CampaignRegistrationItem[] registrations: CampaignRegistrationItem[];
total: number total: number;
page: number page: number;
per_page: number per_page: number;
} }
export interface CampaignsOverview { export interface CampaignsOverview {
total: number total: number;
active: number active: number;
inactive: number inactive: number;
total_registrations: number total_registrations: number;
total_balance_issued_kopeks: number total_balance_issued_kopeks: number;
total_balance_issued_rubles: number total_balance_issued_rubles: number;
total_subscription_issued: number total_subscription_issued: number;
total_tariff_issued: number total_tariff_issued: number;
} }
export interface ServerSquadInfo { export interface ServerSquadInfo {
id: number id: number;
squad_uuid: string squad_uuid: string;
display_name: string display_name: string;
country_code: string | null country_code: string | null;
} }
export interface TariffListItem { export interface TariffListItem {
id: number id: number;
name: string name: string;
description: string | null description: string | null;
is_active: boolean is_active: boolean;
traffic_limit_gb: number traffic_limit_gb: number;
device_limit: number device_limit: number;
} }
export const campaignsApi = { export const campaignsApi = {
// Get campaigns overview // Get campaigns overview
getOverview: async (): Promise<CampaignsOverview> => { getOverview: async (): Promise<CampaignsOverview> => {
const response = await apiClient.get('/cabinet/admin/campaigns/overview') const response = await apiClient.get('/cabinet/admin/campaigns/overview');
return response.data return response.data;
}, },
// Get all campaigns // Get all campaigns
getCampaigns: async (includeInactive = true, offset = 0, limit = 50): Promise<CampaignListResponse> => { getCampaigns: async (
includeInactive = true,
offset = 0,
limit = 50,
): Promise<CampaignListResponse> => {
const response = await apiClient.get('/cabinet/admin/campaigns', { const response = await apiClient.get('/cabinet/admin/campaigns', {
params: { include_inactive: includeInactive, offset, limit } params: { include_inactive: includeInactive, offset, limit },
}) });
return response.data return response.data;
}, },
// Get single campaign // Get single campaign
getCampaign: async (campaignId: number): Promise<CampaignDetail> => { getCampaign: async (campaignId: number): Promise<CampaignDetail> => {
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}`) const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}`);
return response.data return response.data;
}, },
// Get campaign statistics // Get campaign statistics
getCampaignStats: async (campaignId: number): Promise<CampaignStatistics> => { getCampaignStats: async (campaignId: number): Promise<CampaignStatistics> => {
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/stats`) const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/stats`);
return response.data return response.data;
}, },
// Get campaign registrations // Get campaign registrations
getCampaignRegistrations: async (campaignId: number, page = 1, perPage = 50): Promise<CampaignRegistrationsResponse> => { getCampaignRegistrations: async (
campaignId: number,
page = 1,
perPage = 50,
): Promise<CampaignRegistrationsResponse> => {
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/registrations`, { const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/registrations`, {
params: { page, per_page: perPage } params: { page, per_page: perPage },
}) });
return response.data return response.data;
}, },
// Create campaign // Create campaign
createCampaign: async (data: CampaignCreateRequest): Promise<CampaignDetail> => { createCampaign: async (data: CampaignCreateRequest): Promise<CampaignDetail> => {
const response = await apiClient.post('/cabinet/admin/campaigns', data) const response = await apiClient.post('/cabinet/admin/campaigns', data);
return response.data return response.data;
}, },
// Update campaign // Update campaign
updateCampaign: async (campaignId: number, data: CampaignUpdateRequest): Promise<CampaignDetail> => { updateCampaign: async (
const response = await apiClient.put(`/cabinet/admin/campaigns/${campaignId}`, data) campaignId: number,
return response.data data: CampaignUpdateRequest,
): Promise<CampaignDetail> => {
const response = await apiClient.put(`/cabinet/admin/campaigns/${campaignId}`, data);
return response.data;
}, },
// Delete campaign // Delete campaign
deleteCampaign: async (campaignId: number): Promise<{ message: string }> => { deleteCampaign: async (campaignId: number): Promise<{ message: string }> => {
const response = await apiClient.delete(`/cabinet/admin/campaigns/${campaignId}`) const response = await apiClient.delete(`/cabinet/admin/campaigns/${campaignId}`);
return response.data return response.data;
}, },
// Toggle campaign active status // Toggle campaign active status
toggleCampaign: async (campaignId: number): Promise<CampaignToggleResponse> => { toggleCampaign: async (campaignId: number): Promise<CampaignToggleResponse> => {
const response = await apiClient.post(`/cabinet/admin/campaigns/${campaignId}/toggle`) const response = await apiClient.post(`/cabinet/admin/campaigns/${campaignId}/toggle`);
return response.data return response.data;
}, },
// Get available servers for subscription bonus // Get available servers for subscription bonus
getAvailableServers: async (): Promise<ServerSquadInfo[]> => { getAvailableServers: async (): Promise<ServerSquadInfo[]> => {
const response = await apiClient.get('/cabinet/admin/campaigns/available-servers') const response = await apiClient.get('/cabinet/admin/campaigns/available-servers');
return response.data return response.data;
}, },
// Get available tariffs for tariff bonus // Get available tariffs for tariff bonus
getAvailableTariffs: async (): Promise<TariffListItem[]> => { getAvailableTariffs: async (): Promise<TariffListItem[]> => {
const response = await apiClient.get('/cabinet/admin/campaigns/available-tariffs') const response = await apiClient.get('/cabinet/admin/campaigns/available-tariffs');
return response.data return response.data;
}, },
} };

View File

@@ -1,163 +1,175 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios' import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { tokenStorage, isTokenExpired, tokenRefreshManager, safeRedirectToLogin } from '../utils/token' import {
import { useBlockingStore } from '../store/blocking' tokenStorage,
isTokenExpired,
tokenRefreshManager,
safeRedirectToLogin,
} from '../utils/token';
import { useBlockingStore } from '../store/blocking';
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api' const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
// Настраиваем endpoint для refresh // Настраиваем endpoint для refresh
tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`) tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`);
// CSRF token management // CSRF token management
const CSRF_COOKIE_NAME = 'csrf_token' const CSRF_COOKIE_NAME = 'csrf_token';
const CSRF_HEADER_NAME = 'X-CSRF-Token' const CSRF_HEADER_NAME = 'X-CSRF-Token';
function getCsrfToken(): string | null { function getCsrfToken(): string | null {
if (typeof document === 'undefined') return null if (typeof document === 'undefined') return null;
const match = document.cookie.match(new RegExp(`(^| )${CSRF_COOKIE_NAME}=([^;]+)`)) const match = document.cookie.match(new RegExp(`(^| )${CSRF_COOKIE_NAME}=([^;]+)`));
return match ? match[2] : null return match ? match[2] : null;
} }
function generateCsrfToken(): string { function generateCsrfToken(): string {
const array = new Uint8Array(32) const array = new Uint8Array(32);
crypto.getRandomValues(array) crypto.getRandomValues(array);
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('') return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
} }
function ensureCsrfToken(): string { function ensureCsrfToken(): string {
let token = getCsrfToken() let token = getCsrfToken();
if (!token) { if (!token) {
token = generateCsrfToken() token = generateCsrfToken();
// Set cookie with SameSite=Strict for CSRF protection // Set cookie with SameSite=Strict for CSRF protection
document.cookie = `${CSRF_COOKIE_NAME}=${token}; path=/; SameSite=Strict; Secure` document.cookie = `${CSRF_COOKIE_NAME}=${token}; path=/; SameSite=Strict; Secure`;
} }
return token return token;
} }
const getTelegramInitData = (): string | null => { const getTelegramInitData = (): string | null => {
if (typeof window === 'undefined') return null if (typeof window === 'undefined') return null;
const initData = window.Telegram?.WebApp?.initData const initData = window.Telegram?.WebApp?.initData;
if (initData) { if (initData) {
tokenStorage.setTelegramInitData(initData) tokenStorage.setTelegramInitData(initData);
return initData return initData;
} }
return tokenStorage.getTelegramInitData() return tokenStorage.getTelegramInitData();
} };
export const apiClient = axios.create({ export const apiClient = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}) });
// Request interceptor - add auth token with expiration check // Request interceptor - add auth token with expiration check
apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => { apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
let token = tokenStorage.getAccessToken() let token = tokenStorage.getAccessToken();
// Проверяем срок действия токена перед запросом // Проверяем срок действия токена перед запросом
if (token && isTokenExpired(token)) { if (token && isTokenExpired(token)) {
// Используем централизованный менеджер для refresh // Используем централизованный менеджер для refresh
const newToken = await tokenRefreshManager.refreshAccessToken() const newToken = await tokenRefreshManager.refreshAccessToken();
if (newToken) { if (newToken) {
token = newToken token = newToken;
} else { } else {
// Refresh не удался - редирект на логин // Refresh не удался - редирект на логин
tokenStorage.clearTokens() tokenStorage.clearTokens();
safeRedirectToLogin() safeRedirectToLogin();
return config return config;
} }
} }
if (token && config.headers) { if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`;
} }
const telegramInitData = getTelegramInitData() const telegramInitData = getTelegramInitData();
if (telegramInitData && config.headers) { if (telegramInitData && config.headers) {
config.headers['X-Telegram-Init-Data'] = telegramInitData config.headers['X-Telegram-Init-Data'] = telegramInitData;
} }
// Add CSRF token for state-changing methods // Add CSRF token for state-changing methods
const method = config.method?.toUpperCase() const method = config.method?.toUpperCase();
if (method && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && config.headers) { if (method && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && config.headers) {
config.headers[CSRF_HEADER_NAME] = ensureCsrfToken() config.headers[CSRF_HEADER_NAME] = ensureCsrfToken();
} }
return config return config;
}) });
// Custom error types for special handling // Custom error types for special handling
export interface MaintenanceError { export interface MaintenanceError {
code: 'maintenance' code: 'maintenance';
message: string message: string;
reason?: string reason?: string;
} }
export interface ChannelSubscriptionError { export interface ChannelSubscriptionError {
code: 'channel_subscription_required' code: 'channel_subscription_required';
message: string message: string;
channel_link?: string channel_link?: string;
} }
export function isMaintenanceError(error: unknown): error is { response: { status: 503, data: { detail: MaintenanceError } } } { export function isMaintenanceError(
if (!error || typeof error !== 'object') return false error: unknown,
const err = error as AxiosError<{ detail: MaintenanceError }> ): error is { response: { status: 503; data: { detail: MaintenanceError } } } {
return err.response?.status === 503 && err.response?.data?.detail?.code === 'maintenance' if (!error || typeof error !== 'object') return false;
const err = error as AxiosError<{ detail: MaintenanceError }>;
return err.response?.status === 503 && err.response?.data?.detail?.code === 'maintenance';
} }
export function isChannelSubscriptionError(error: unknown): error is { response: { status: 403, data: { detail: ChannelSubscriptionError } } } { export function isChannelSubscriptionError(
if (!error || typeof error !== 'object') return false error: unknown,
const err = error as AxiosError<{ detail: ChannelSubscriptionError }> ): error is { response: { status: 403; data: { detail: ChannelSubscriptionError } } } {
return err.response?.status === 403 && err.response?.data?.detail?.code === 'channel_subscription_required' if (!error || typeof error !== 'object') return false;
const err = error as AxiosError<{ detail: ChannelSubscriptionError }>;
return (
err.response?.status === 403 &&
err.response?.data?.detail?.code === 'channel_subscription_required'
);
} }
// Response interceptor - handle 401, 503 (maintenance), 403 (channel subscription) // Response interceptor - handle 401, 503 (maintenance), 403 (channel subscription)
apiClient.interceptors.response.use( apiClient.interceptors.response.use(
(response) => response, (response) => response,
async (error: AxiosError) => { async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean } const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Handle maintenance mode (503) // Handle maintenance mode (503)
if (isMaintenanceError(error)) { if (isMaintenanceError(error)) {
const detail = (error.response?.data as { detail: MaintenanceError }).detail const detail = (error.response?.data as { detail: MaintenanceError }).detail;
useBlockingStore.getState().setMaintenance({ useBlockingStore.getState().setMaintenance({
message: detail.message, message: detail.message,
reason: detail.reason, reason: detail.reason,
}) });
return Promise.reject(error) return Promise.reject(error);
} }
// Handle channel subscription required (403) // Handle channel subscription required (403)
if (isChannelSubscriptionError(error)) { if (isChannelSubscriptionError(error)) {
const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail;
useBlockingStore.getState().setChannelSubscription({ useBlockingStore.getState().setChannelSubscription({
message: detail.message, message: detail.message,
channel_link: detail.channel_link, channel_link: detail.channel_link,
}) });
return Promise.reject(error) return Promise.reject(error);
} }
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала) // Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true originalRequest._retry = true;
const newToken = await tokenRefreshManager.refreshAccessToken() const newToken = await tokenRefreshManager.refreshAccessToken();
if (newToken) { if (newToken) {
if (originalRequest.headers) { if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}` originalRequest.headers.Authorization = `Bearer ${newToken}`;
} }
return apiClient(originalRequest) return apiClient(originalRequest);
} else { } else {
// Refresh не удался // Refresh не удался
tokenStorage.clearTokens() tokenStorage.clearTokens();
safeRedirectToLogin() safeRedirectToLogin();
} }
} }
return Promise.reject(error) return Promise.reject(error);
} },
) );
export default apiClient export default apiClient;

View File

@@ -1,49 +1,50 @@
import apiClient from './client' import apiClient from './client';
export interface ContestInfo { export interface ContestInfo {
id: number id: number;
slug: string slug: string;
name: string name: string;
description: string | null description: string | null;
prize_days: number prize_days: number;
is_available: boolean is_available: boolean;
already_played: boolean already_played: boolean;
} }
export interface ContestGameData { export interface ContestGameData {
round_id: number round_id: number;
game_type: string game_type: string;
game_data: Record<string, any> // eslint-disable-next-line @typescript-eslint/no-explicit-any
instructions: string game_data: Record<string, any>;
instructions: string;
} }
export interface ContestResult { export interface ContestResult {
is_winner: boolean is_winner: boolean;
message: string message: string;
prize_days?: number prize_days?: number;
} }
export interface ContestsCountResponse { export interface ContestsCountResponse {
count: number count: number;
} }
export const contestsApi = { export const contestsApi = {
// Get count of available contests // Get count of available contests
getCount: async (): Promise<ContestsCountResponse> => { getCount: async (): Promise<ContestsCountResponse> => {
const response = await apiClient.get<ContestsCountResponse>('/cabinet/contests/count') const response = await apiClient.get<ContestsCountResponse>('/cabinet/contests/count');
return response.data return response.data;
}, },
// Get list of available contests // Get list of available contests
getContests: async (): Promise<ContestInfo[]> => { getContests: async (): Promise<ContestInfo[]> => {
const response = await apiClient.get<ContestInfo[]>('/cabinet/contests') const response = await apiClient.get<ContestInfo[]>('/cabinet/contests');
return response.data return response.data;
}, },
// Get game data for a specific contest // Get game data for a specific contest
getContestGame: async (roundId: number): Promise<ContestGameData> => { getContestGame: async (roundId: number): Promise<ContestGameData> => {
const response = await apiClient.get<ContestGameData>(`/cabinet/contests/${roundId}`) const response = await apiClient.get<ContestGameData>(`/cabinet/contests/${roundId}`);
return response.data return response.data;
}, },
// Submit answer for a contest // Submit answer for a contest
@@ -51,7 +52,7 @@ export const contestsApi = {
const response = await apiClient.post<ContestResult>(`/cabinet/contests/${roundId}/answer`, { const response = await apiClient.post<ContestResult>(`/cabinet/contests/${roundId}/answer`, {
round_id: roundId, round_id: roundId,
answer, answer,
}) });
return response.data return response.data;
}, },
} };

View File

@@ -1,46 +1,46 @@
// Currency exchange rate API // Currency exchange rate API
// Uses free exchangerate.host API // Uses free exchangerate.host API
import axios from 'axios' import axios from 'axios';
interface ExchangeRates { interface ExchangeRates {
USD: number USD: number;
CNY: number CNY: number;
IRR: number IRR: number;
} }
interface CachedRates { interface CachedRates {
rates: ExchangeRates rates: ExchangeRates;
timestamp: number timestamp: number;
} }
const CACHE_DURATION = 60 * 60 * 1000 // 1 hour in milliseconds const CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds
// Fallback rates if API fails (approximate) // Fallback rates if API fails (approximate)
const FALLBACK_RATES: ExchangeRates = { const FALLBACK_RATES: ExchangeRates = {
USD: 100, // 1 USD = 100 RUB USD: 100, // 1 USD = 100 RUB
CNY: 14, // 1 CNY = 14 RUB CNY: 14, // 1 CNY = 14 RUB
IRR: 0.0024, // 1 IRR = 0.0024 RUB (or 1 RUB = ~420 IRR) IRR: 0.0024, // 1 IRR = 0.0024 RUB (or 1 RUB = ~420 IRR)
} };
let cachedRates: CachedRates | null = null let cachedRates: CachedRates | null = null;
export const currencyApi = { export const currencyApi = {
// Get all exchange rates (RUB to other currencies) // Get all exchange rates (RUB to other currencies)
getExchangeRates: async (): Promise<ExchangeRates> => { getExchangeRates: async (): Promise<ExchangeRates> => {
// Check cache first // Check cache first
if (cachedRates && Date.now() - cachedRates.timestamp < CACHE_DURATION) { if (cachedRates && Date.now() - cachedRates.timestamp < CACHE_DURATION) {
return cachedRates.rates return cachedRates.rates;
} }
try { try {
// Try primary API (exchangerate.host) - get RUB based rates // Try primary API (exchangerate.host) - get RUB based rates
const response = await axios.get<{ const response = await axios.get<{
success?: boolean success?: boolean;
rates?: { USD?: number; CNY?: number; IRR?: number } rates?: { USD?: number; CNY?: number; IRR?: number };
}>('https://api.exchangerate.host/latest', { }>('https://api.exchangerate.host/latest', {
params: { base: 'RUB', symbols: 'USD,CNY,IRR' }, params: { base: 'RUB', symbols: 'USD,CNY,IRR' },
}) });
if (response.data.success && response.data.rates) { if (response.data.success && response.data.rates) {
// API returns how much of each currency equals 1 RUB // API returns how much of each currency equals 1 RUB
@@ -49,51 +49,65 @@ export const currencyApi = {
USD: response.data.rates.USD ? 1 / response.data.rates.USD : FALLBACK_RATES.USD, USD: response.data.rates.USD ? 1 / response.data.rates.USD : FALLBACK_RATES.USD,
CNY: response.data.rates.CNY ? 1 / response.data.rates.CNY : FALLBACK_RATES.CNY, CNY: response.data.rates.CNY ? 1 / response.data.rates.CNY : FALLBACK_RATES.CNY,
IRR: response.data.rates.IRR ? 1 / response.data.rates.IRR : FALLBACK_RATES.IRR, IRR: response.data.rates.IRR ? 1 / response.data.rates.IRR : FALLBACK_RATES.IRR,
} };
cachedRates = { rates, timestamp: Date.now() } cachedRates = { rates, timestamp: Date.now() };
return rates return rates;
} }
// Try backup API (open.er-api.com) // Try backup API (open.er-api.com)
const backupResponse = await axios.get<{ const backupResponse = await axios.get<{
rates?: { USD?: number; CNY?: number; IRR?: number } rates?: { USD?: number; CNY?: number; IRR?: number };
}>('https://open.er-api.com/v6/latest/RUB') }>('https://open.er-api.com/v6/latest/RUB');
if (backupResponse.data.rates) { if (backupResponse.data.rates) {
const rates: ExchangeRates = { const rates: ExchangeRates = {
USD: backupResponse.data.rates.USD ? 1 / backupResponse.data.rates.USD : FALLBACK_RATES.USD, USD: backupResponse.data.rates.USD
CNY: backupResponse.data.rates.CNY ? 1 / backupResponse.data.rates.CNY : FALLBACK_RATES.CNY, ? 1 / backupResponse.data.rates.USD
IRR: backupResponse.data.rates.IRR ? 1 / backupResponse.data.rates.IRR : FALLBACK_RATES.IRR, : FALLBACK_RATES.USD,
} CNY: backupResponse.data.rates.CNY
cachedRates = { rates, timestamp: Date.now() } ? 1 / backupResponse.data.rates.CNY
return rates : FALLBACK_RATES.CNY,
IRR: backupResponse.data.rates.IRR
? 1 / backupResponse.data.rates.IRR
: FALLBACK_RATES.IRR,
};
cachedRates = { rates, timestamp: Date.now() };
return rates;
} }
// Return fallback rates if both APIs fail // Return fallback rates if both APIs fail
return FALLBACK_RATES return FALLBACK_RATES;
} catch (error) { } catch (error) {
console.warn('Failed to fetch exchange rates, using fallback:', error) console.warn('Failed to fetch exchange rates, using fallback:', error);
return FALLBACK_RATES return FALLBACK_RATES;
} }
}, },
// Convert RUB to target currency // Convert RUB to target currency
convertFromRub: (rubAmount: number, targetCurrency: keyof ExchangeRates, rates: ExchangeRates): number => { convertFromRub: (
const rate = rates[targetCurrency] rubAmount: number,
targetCurrency: keyof ExchangeRates,
rates: ExchangeRates,
): number => {
const rate = rates[targetCurrency];
if (!rate || rate <= 0) { if (!rate || rate <= 0) {
return rubAmount / FALLBACK_RATES[targetCurrency] return rubAmount / FALLBACK_RATES[targetCurrency];
} }
return rubAmount / rate return rubAmount / rate;
}, },
// Convert from target currency to RUB // Convert from target currency to RUB
convertToRub: (amount: number, sourceCurrency: keyof ExchangeRates, rates: ExchangeRates): number => { convertToRub: (
const rate = rates[sourceCurrency] amount: number,
sourceCurrency: keyof ExchangeRates,
rates: ExchangeRates,
): number => {
const rate = rates[sourceCurrency];
if (!rate || rate <= 0) { if (!rate || rate <= 0) {
return amount * FALLBACK_RATES[sourceCurrency] return amount * FALLBACK_RATES[sourceCurrency];
} }
return amount * rate return amount * rate;
}, },
} };
export type { ExchangeRates } export type { ExchangeRates };

View File

@@ -1,12 +1,12 @@
export { apiClient } from './client' export { apiClient } from './client';
export { authApi } from './auth' export { authApi } from './auth';
export { subscriptionApi } from './subscription' export { subscriptionApi } from './subscription';
export { balanceApi } from './balance' export { balanceApi } from './balance';
export { referralApi } from './referral' export { referralApi } from './referral';
export { ticketsApi } from './tickets' export { ticketsApi } from './tickets';
export { contestsApi } from './contests' export { contestsApi } from './contests';
export { pollsApi } from './polls' export { pollsApi } from './polls';
export { promoApi } from './promo' export { promoApi } from './promo';
export { notificationsApi } from './notifications' export { notificationsApi } from './notifications';
export { infoApi } from './info' export { infoApi } from './info';
export { adminSettingsApi } from './adminSettings' export { adminSettingsApi } from './adminSettings';

View File

@@ -1,102 +1,102 @@
import apiClient from './client' import apiClient from './client';
import type { SupportConfig } from '../types' import type { SupportConfig } from '../types';
export interface FaqPage { export interface FaqPage {
id: number id: number;
title: string title: string;
content: string content: string;
order: number order: number;
} }
export interface RulesResponse { export interface RulesResponse {
content: string content: string;
updated_at: string | null updated_at: string | null;
} }
export interface PrivacyPolicyResponse { export interface PrivacyPolicyResponse {
content: string content: string;
updated_at: string | null updated_at: string | null;
} }
export interface PublicOfferResponse { export interface PublicOfferResponse {
content: string content: string;
updated_at: string | null updated_at: string | null;
} }
export interface ServiceInfo { export interface ServiceInfo {
name: string name: string;
description: string | null description: string | null;
support_email: string | null support_email: string | null;
support_telegram: string | null support_telegram: string | null;
website: string | null website: string | null;
} }
export interface LanguageInfo { export interface LanguageInfo {
code: string code: string;
name: string name: string;
flag: string flag: string;
} }
export const infoApi = { export const infoApi = {
// Get FAQ pages list // Get FAQ pages list
getFaqPages: async (): Promise<FaqPage[]> => { getFaqPages: async (): Promise<FaqPage[]> => {
const response = await apiClient.get<FaqPage[]>('/cabinet/info/faq') const response = await apiClient.get<FaqPage[]>('/cabinet/info/faq');
return response.data return response.data;
}, },
// Get specific FAQ page // Get specific FAQ page
getFaqPage: async (pageId: number): Promise<FaqPage> => { getFaqPage: async (pageId: number): Promise<FaqPage> => {
const response = await apiClient.get<FaqPage>(`/cabinet/info/faq/${pageId}`) const response = await apiClient.get<FaqPage>(`/cabinet/info/faq/${pageId}`);
return response.data return response.data;
}, },
// Get service rules // Get service rules
getRules: async (): Promise<RulesResponse> => { getRules: async (): Promise<RulesResponse> => {
const response = await apiClient.get<RulesResponse>('/cabinet/info/rules') const response = await apiClient.get<RulesResponse>('/cabinet/info/rules');
return response.data return response.data;
}, },
// Get privacy policy // Get privacy policy
getPrivacyPolicy: async (): Promise<PrivacyPolicyResponse> => { getPrivacyPolicy: async (): Promise<PrivacyPolicyResponse> => {
const response = await apiClient.get<PrivacyPolicyResponse>('/cabinet/info/privacy-policy') const response = await apiClient.get<PrivacyPolicyResponse>('/cabinet/info/privacy-policy');
return response.data return response.data;
}, },
// Get public offer // Get public offer
getPublicOffer: async (): Promise<PublicOfferResponse> => { getPublicOffer: async (): Promise<PublicOfferResponse> => {
const response = await apiClient.get<PublicOfferResponse>('/cabinet/info/public-offer') const response = await apiClient.get<PublicOfferResponse>('/cabinet/info/public-offer');
return response.data return response.data;
}, },
// Get service info // Get service info
getServiceInfo: async (): Promise<ServiceInfo> => { getServiceInfo: async (): Promise<ServiceInfo> => {
const response = await apiClient.get<ServiceInfo>('/cabinet/info/service') const response = await apiClient.get<ServiceInfo>('/cabinet/info/service');
return response.data return response.data;
}, },
// Get available languages // Get available languages
getLanguages: async (): Promise<{ languages: LanguageInfo[]; default: string }> => { getLanguages: async (): Promise<{ languages: LanguageInfo[]; default: string }> => {
const response = await apiClient.get('/cabinet/info/languages') const response = await apiClient.get('/cabinet/info/languages');
return response.data return response.data;
}, },
// Get user language // Get user language
getUserLanguage: async (): Promise<{ language: string }> => { getUserLanguage: async (): Promise<{ language: string }> => {
const response = await apiClient.get<{ language: string }>('/cabinet/info/user/language') const response = await apiClient.get<{ language: string }>('/cabinet/info/user/language');
return response.data return response.data;
}, },
// Update user language // Update user language
updateUserLanguage: async (language: string): Promise<{ language: string }> => { updateUserLanguage: async (language: string): Promise<{ language: string }> => {
const response = await apiClient.patch<{ language: string }>('/cabinet/info/user/language', { const response = await apiClient.patch<{ language: string }>('/cabinet/info/user/language', {
language, language,
}) });
return response.data return response.data;
}, },
// Get support configuration // Get support configuration
getSupportConfig: async (): Promise<SupportConfig> => { getSupportConfig: async (): Promise<SupportConfig> => {
const response = await apiClient.get<SupportConfig>('/cabinet/info/support-config') const response = await apiClient.get<SupportConfig>('/cabinet/info/support-config');
return response.data return response.data;
}, },
} };

View File

@@ -1,22 +1,22 @@
import axios, { AxiosError } from 'axios' import axios, { AxiosError } from 'axios';
export interface MiniappCreatePaymentPayload { export interface MiniappCreatePaymentPayload {
initData: string initData: string;
method: string method: string;
amountKopeks?: number | null amountKopeks?: number | null;
option?: string | null option?: string | null;
} }
export interface MiniappCreatePaymentResponse { export interface MiniappCreatePaymentResponse {
payment_url: string payment_url: string;
amount_kopeks?: number amount_kopeks?: number;
extra?: Record<string, unknown> extra?: Record<string, unknown>;
} }
export const miniappApi = { export const miniappApi = {
// Create payment inside Telegram Mini App (same flow as miniapp/index.html) // Create payment inside Telegram Mini App (same flow as miniapp/index.html)
createPayment: async ( createPayment: async (
payload: MiniappCreatePaymentPayload payload: MiniappCreatePaymentPayload,
): Promise<MiniappCreatePaymentResponse> => { ): Promise<MiniappCreatePaymentResponse> => {
try { try {
const response = await axios.post<MiniappCreatePaymentResponse>( const response = await axios.post<MiniappCreatePaymentResponse>(
@@ -29,15 +29,16 @@ export const miniappApi = {
}, },
{ {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
} },
) );
return response.data return response.data;
} catch (error) { } catch (error) {
const axiosError = error as AxiosError<{ detail?: string; message?: string }> const axiosError = error as AxiosError<{ detail?: string; message?: string }>;
const message = axiosError.response?.data?.detail const message =
|| axiosError.response?.data?.message axiosError.response?.data?.detail ||
|| 'Failed to create payment' axiosError.response?.data?.message ||
throw new Error(message) 'Failed to create payment';
throw new Error(message);
} }
}, },
} };

View File

@@ -1,58 +1,64 @@
import apiClient from './client' import apiClient from './client';
export interface NotificationSettings { export interface NotificationSettings {
subscription_expiry_enabled: boolean subscription_expiry_enabled: boolean;
subscription_expiry_days: number subscription_expiry_days: number;
traffic_warning_enabled: boolean traffic_warning_enabled: boolean;
traffic_warning_percent: number traffic_warning_percent: number;
balance_low_enabled: boolean balance_low_enabled: boolean;
balance_low_threshold: number balance_low_threshold: number;
news_enabled: boolean news_enabled: boolean;
promo_offers_enabled: boolean promo_offers_enabled: boolean;
} }
export interface NotificationSettingsUpdate { export interface NotificationSettingsUpdate {
subscription_expiry_enabled?: boolean subscription_expiry_enabled?: boolean;
subscription_expiry_days?: number subscription_expiry_days?: number;
traffic_warning_enabled?: boolean traffic_warning_enabled?: boolean;
traffic_warning_percent?: number traffic_warning_percent?: number;
balance_low_enabled?: boolean balance_low_enabled?: boolean;
balance_low_threshold?: number balance_low_threshold?: number;
news_enabled?: boolean news_enabled?: boolean;
promo_offers_enabled?: boolean promo_offers_enabled?: boolean;
} }
export const notificationsApi = { export const notificationsApi = {
// Get notification settings // Get notification settings
getSettings: async (): Promise<NotificationSettings> => { getSettings: async (): Promise<NotificationSettings> => {
const response = await apiClient.get<NotificationSettings>('/cabinet/notifications') const response = await apiClient.get<NotificationSettings>('/cabinet/notifications');
return response.data return response.data;
}, },
// Update notification settings // Update notification settings
updateSettings: async (settings: NotificationSettingsUpdate): Promise<NotificationSettings> => { updateSettings: async (settings: NotificationSettingsUpdate): Promise<NotificationSettings> => {
const response = await apiClient.patch<NotificationSettings>('/cabinet/notifications', settings) const response = await apiClient.patch<NotificationSettings>(
return response.data '/cabinet/notifications',
settings,
);
return response.data;
}, },
// Send test notification // Send test notification
sendTestNotification: async (): Promise<{ success: boolean; message: string }> => { sendTestNotification: async (): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post<{ success: boolean; message: string }>( const response = await apiClient.post<{ success: boolean; message: string }>(
'/cabinet/notifications/test' '/cabinet/notifications/test',
) );
return response.data return response.data;
}, },
// Get notification history // Get notification history
getHistory: async (limit = 20, offset = 0): Promise<{ getHistory: async (
notifications: any[] limit = 20,
total: number offset = 0,
limit: number ): Promise<{
offset: number notifications: unknown[];
total: number;
limit: number;
offset: number;
}> => { }> => {
const response = await apiClient.get('/cabinet/notifications/history', { const response = await apiClient.get('/cabinet/notifications/history', {
params: { limit, offset }, params: { limit, offset },
}) });
return response.data return response.data;
}, },
} };

View File

@@ -1,85 +1,85 @@
import apiClient from './client' import apiClient from './client';
export interface PollOption { export interface PollOption {
id: number id: number;
text: string text: string;
order: number order: number;
} }
export interface PollQuestion { export interface PollQuestion {
id: number id: number;
text: string text: string;
order: number order: number;
options: PollOption[] options: PollOption[];
} }
export interface PollInfo { export interface PollInfo {
id: number id: number;
response_id: number response_id: number;
title: string title: string;
description: string | null description: string | null;
total_questions: number total_questions: number;
answered_questions: number answered_questions: number;
is_completed: boolean is_completed: boolean;
reward_amount: number | null reward_amount: number | null;
} }
export interface PollStartResponse { export interface PollStartResponse {
response_id: number response_id: number;
current_question_index: number current_question_index: number;
total_questions: number total_questions: number;
question: PollQuestion question: PollQuestion;
} }
export interface PollAnswerResponse { export interface PollAnswerResponse {
success: boolean success: boolean;
is_completed: boolean is_completed: boolean;
next_question: PollQuestion | null next_question: PollQuestion | null;
current_question_index: number | null current_question_index: number | null;
total_questions: number total_questions: number;
reward_granted: number | null reward_granted: number | null;
message: string | null message: string | null;
} }
export interface PollsCountResponse { export interface PollsCountResponse {
count: number count: number;
} }
export const pollsApi = { export const pollsApi = {
// Get count of available polls // Get count of available polls
getCount: async (): Promise<PollsCountResponse> => { getCount: async (): Promise<PollsCountResponse> => {
const response = await apiClient.get<PollsCountResponse>('/cabinet/polls/count') const response = await apiClient.get<PollsCountResponse>('/cabinet/polls/count');
return response.data return response.data;
}, },
// Get available polls // Get available polls
getPolls: async (): Promise<PollInfo[]> => { getPolls: async (): Promise<PollInfo[]> => {
const response = await apiClient.get<PollInfo[]>('/cabinet/polls') const response = await apiClient.get<PollInfo[]>('/cabinet/polls');
return response.data return response.data;
}, },
// Get poll details // Get poll details
getPollDetails: async (responseId: number): Promise<PollInfo> => { getPollDetails: async (responseId: number): Promise<PollInfo> => {
const response = await apiClient.get<PollInfo>(`/cabinet/polls/${responseId}`) const response = await apiClient.get<PollInfo>(`/cabinet/polls/${responseId}`);
return response.data return response.data;
}, },
// Start or continue poll // Start or continue poll
startPoll: async (responseId: number): Promise<PollStartResponse> => { startPoll: async (responseId: number): Promise<PollStartResponse> => {
const response = await apiClient.post<PollStartResponse>(`/cabinet/polls/${responseId}/start`) const response = await apiClient.post<PollStartResponse>(`/cabinet/polls/${responseId}/start`);
return response.data return response.data;
}, },
// Answer a question // Answer a question
answerQuestion: async ( answerQuestion: async (
responseId: number, responseId: number,
questionId: number, questionId: number,
optionId: number optionId: number,
): Promise<PollAnswerResponse> => { ): Promise<PollAnswerResponse> => {
const response = await apiClient.post<PollAnswerResponse>( const response = await apiClient.post<PollAnswerResponse>(
`/cabinet/polls/${responseId}/questions/${questionId}/answer`, `/cabinet/polls/${responseId}/questions/${questionId}/answer`,
{ option_id: optionId } { option_id: optionId },
) );
return response.data return response.data;
}, },
} };

View File

@@ -1,96 +1,97 @@
import apiClient from './client' import apiClient from './client';
export interface PromoOffer { export interface PromoOffer {
id: number id: number;
notification_type: string notification_type: string;
discount_percent: number | null discount_percent: number | null;
effect_type: string effect_type: string;
expires_at: string expires_at: string;
is_active: boolean is_active: boolean;
is_claimed: boolean is_claimed: boolean;
claimed_at: string | null claimed_at: string | null;
extra_data: Record<string, any> | null // eslint-disable-next-line @typescript-eslint/no-explicit-any
extra_data: Record<string, any> | null;
} }
export interface ActiveDiscount { export interface ActiveDiscount {
discount_percent: number discount_percent: number;
source: string | null source: string | null;
expires_at: string | null expires_at: string | null;
is_active: boolean is_active: boolean;
} }
export interface PromoGroupDiscounts { export interface PromoGroupDiscounts {
group_name: string | null group_name: string | null;
server_discount_percent: number server_discount_percent: number;
traffic_discount_percent: number traffic_discount_percent: number;
device_discount_percent: number device_discount_percent: number;
period_discounts: Record<string, number> period_discounts: Record<string, number>;
} }
export interface ClaimOfferResponse { export interface ClaimOfferResponse {
success: boolean success: boolean;
message: string message: string;
discount_percent: number | null discount_percent: number | null;
expires_at: string | null expires_at: string | null;
} }
export interface LoyaltyTierInfo { export interface LoyaltyTierInfo {
id: number id: number;
name: string name: string;
threshold_rubles: number threshold_rubles: number;
server_discount_percent: number server_discount_percent: number;
traffic_discount_percent: number traffic_discount_percent: number;
device_discount_percent: number device_discount_percent: number;
period_discounts: Record<string, number> period_discounts: Record<string, number>;
is_current: boolean is_current: boolean;
is_achieved: boolean is_achieved: boolean;
} }
export interface LoyaltyTiersResponse { export interface LoyaltyTiersResponse {
tiers: LoyaltyTierInfo[] tiers: LoyaltyTierInfo[];
current_spent_rubles: number current_spent_rubles: number;
current_tier_name: string | null current_tier_name: string | null;
next_tier_name: string | null next_tier_name: string | null;
next_tier_threshold_rubles: number | null next_tier_threshold_rubles: number | null;
progress_percent: number progress_percent: number;
} }
export const promoApi = { export const promoApi = {
// Get available promo offers // Get available promo offers
getOffers: async (): Promise<PromoOffer[]> => { getOffers: async (): Promise<PromoOffer[]> => {
const response = await apiClient.get<PromoOffer[]>('/cabinet/promo/offers') const response = await apiClient.get<PromoOffer[]>('/cabinet/promo/offers');
return response.data return response.data;
}, },
// Get active discount // Get active discount
getActiveDiscount: async (): Promise<ActiveDiscount> => { getActiveDiscount: async (): Promise<ActiveDiscount> => {
const response = await apiClient.get<ActiveDiscount>('/cabinet/promo/active-discount') const response = await apiClient.get<ActiveDiscount>('/cabinet/promo/active-discount');
return response.data return response.data;
}, },
// Get promo group discounts // Get promo group discounts
getGroupDiscounts: async (): Promise<PromoGroupDiscounts> => { getGroupDiscounts: async (): Promise<PromoGroupDiscounts> => {
const response = await apiClient.get<PromoGroupDiscounts>('/cabinet/promo/group-discounts') const response = await apiClient.get<PromoGroupDiscounts>('/cabinet/promo/group-discounts');
return response.data return response.data;
}, },
// Get loyalty tiers (promo groups with spending thresholds) // Get loyalty tiers (promo groups with spending thresholds)
getLoyaltyTiers: async (): Promise<LoyaltyTiersResponse> => { getLoyaltyTiers: async (): Promise<LoyaltyTiersResponse> => {
const response = await apiClient.get<LoyaltyTiersResponse>('/cabinet/promo/loyalty-tiers') const response = await apiClient.get<LoyaltyTiersResponse>('/cabinet/promo/loyalty-tiers');
return response.data return response.data;
}, },
// Claim a promo offer // Claim a promo offer
claimOffer: async (offerId: number): Promise<ClaimOfferResponse> => { claimOffer: async (offerId: number): Promise<ClaimOfferResponse> => {
const response = await apiClient.post<ClaimOfferResponse>('/cabinet/promo/claim', { const response = await apiClient.post<ClaimOfferResponse>('/cabinet/promo/claim', {
offer_id: offerId, offer_id: offerId,
}) });
return response.data return response.data;
}, },
// Clear active discount // Clear active discount
clearActiveDiscount: async (): Promise<{ message: string }> => { clearActiveDiscount: async (): Promise<{ message: string }> => {
const response = await apiClient.delete<{ message: string }>('/cabinet/promo/active-discount') const response = await apiClient.delete<{ message: string }>('/cabinet/promo/active-discount');
return response.data return response.data;
}, },
} };

View File

@@ -1,139 +1,140 @@
import apiClient from './client' import apiClient from './client';
// ============== Types ============== // ============== Types ==============
export interface PromoOfferUserInfo { export interface PromoOfferUserInfo {
id: number id: number;
telegram_id: number telegram_id: number;
username: string | null username: string | null;
first_name: string | null first_name: string | null;
last_name: string | null last_name: string | null;
full_name: string | null full_name: string | null;
} }
export interface PromoOfferSubscriptionInfo { export interface PromoOfferSubscriptionInfo {
id: number id: number;
status: string status: string;
is_trial: boolean is_trial: boolean;
start_date: string start_date: string;
end_date: string end_date: string;
autopay_enabled: boolean autopay_enabled: boolean;
} }
export interface PromoOffer { export interface PromoOffer {
id: number id: number;
user_id: number user_id: number;
subscription_id: number | null subscription_id: number | null;
notification_type: string notification_type: string;
discount_percent: number discount_percent: number;
bonus_amount_kopeks: number bonus_amount_kopeks: number;
expires_at: string expires_at: string;
claimed_at: string | null claimed_at: string | null;
is_active: boolean is_active: boolean;
effect_type: string effect_type: string;
extra_data: Record<string, any> // eslint-disable-next-line @typescript-eslint/no-explicit-any
created_at: string extra_data: Record<string, any>;
updated_at: string created_at: string;
user: PromoOfferUserInfo | null updated_at: string;
subscription: PromoOfferSubscriptionInfo | null user: PromoOfferUserInfo | null;
subscription: PromoOfferSubscriptionInfo | null;
} }
export interface PromoOfferListResponse { export interface PromoOfferListResponse {
items: PromoOffer[] items: PromoOffer[];
total: number total: number;
limit: number limit: number;
offset: number offset: number;
} }
export interface PromoOfferBroadcastRequest { export interface PromoOfferBroadcastRequest {
notification_type: string notification_type: string;
valid_hours: number valid_hours: number;
discount_percent?: number discount_percent?: number;
bonus_amount_kopeks?: number bonus_amount_kopeks?: number;
effect_type?: string effect_type?: string;
extra_data?: Record<string, any> extra_data?: Record<string, unknown>;
target?: string target?: string;
user_id?: number user_id?: number;
telegram_id?: number telegram_id?: number;
// Telegram notification options // Telegram notification options
send_notification?: boolean send_notification?: boolean;
message_text?: string message_text?: string;
button_text?: string button_text?: string;
} }
export interface PromoOfferBroadcastResponse { export interface PromoOfferBroadcastResponse {
created_offers: number created_offers: number;
user_ids: number[] user_ids: number[];
target: string | null target: string | null;
notifications_sent: number notifications_sent: number;
notifications_failed: number notifications_failed: number;
} }
export interface PromoOfferTemplate { export interface PromoOfferTemplate {
id: number id: number;
name: string name: string;
offer_type: string offer_type: string;
message_text: string message_text: string;
button_text: string button_text: string;
valid_hours: number valid_hours: number;
discount_percent: number discount_percent: number;
bonus_amount_kopeks: number bonus_amount_kopeks: number;
active_discount_hours: number | null active_discount_hours: number | null;
test_duration_hours: number | null test_duration_hours: number | null;
test_squad_uuids: string[] test_squad_uuids: string[];
is_active: boolean is_active: boolean;
created_by: number | null created_by: number | null;
created_at: string created_at: string;
updated_at: string updated_at: string;
} }
export interface PromoOfferTemplateListResponse { export interface PromoOfferTemplateListResponse {
items: PromoOfferTemplate[] items: PromoOfferTemplate[];
} }
export interface PromoOfferTemplateUpdateRequest { export interface PromoOfferTemplateUpdateRequest {
name?: string name?: string;
message_text?: string message_text?: string;
button_text?: string button_text?: string;
valid_hours?: number valid_hours?: number;
discount_percent?: number discount_percent?: number;
bonus_amount_kopeks?: number bonus_amount_kopeks?: number;
active_discount_hours?: number active_discount_hours?: number;
test_duration_hours?: number test_duration_hours?: number;
test_squad_uuids?: string[] test_squad_uuids?: string[];
is_active?: boolean is_active?: boolean;
} }
export interface PromoOfferLogOfferInfo { export interface PromoOfferLogOfferInfo {
id: number id: number;
notification_type: string | null notification_type: string | null;
discount_percent: number | null discount_percent: number | null;
bonus_amount_kopeks: number | null bonus_amount_kopeks: number | null;
effect_type: string | null effect_type: string | null;
expires_at: string | null expires_at: string | null;
claimed_at: string | null claimed_at: string | null;
is_active: boolean | null is_active: boolean | null;
} }
export interface PromoOfferLog { export interface PromoOfferLog {
id: number id: number;
user_id: number | null user_id: number | null;
offer_id: number | null offer_id: number | null;
action: string action: string;
source: string | null source: string | null;
percent: number | null percent: number | null;
effect_type: string | null effect_type: string | null;
details: Record<string, any> details: Record<string, unknown>;
created_at: string created_at: string;
user: PromoOfferUserInfo | null user: PromoOfferUserInfo | null;
offer: PromoOfferLogOfferInfo | null offer: PromoOfferLogOfferInfo | null;
} }
export interface PromoOfferLogListResponse { export interface PromoOfferLogListResponse {
items: PromoOfferLog[] items: PromoOfferLog[];
total: number total: number;
limit: number limit: number;
offset: number offset: number;
} }
// Target segments for broadcast // Target segments for broadcast
@@ -154,9 +155,9 @@ export const TARGET_SEGMENTS = {
custom_week: 'Зарегистрированы за неделю', custom_week: 'Зарегистрированы за неделю',
custom_month: 'Зарегистрированы за месяц', custom_month: 'Зарегистрированы за месяц',
custom_active_today: 'Активны сегодня', custom_active_today: 'Активны сегодня',
} as const } as const;
export type TargetSegment = keyof typeof TARGET_SEGMENTS export type TargetSegment = keyof typeof TARGET_SEGMENTS;
// Offer type configurations // Offer type configurations
export const OFFER_TYPE_CONFIG = { export const OFFER_TYPE_CONFIG = {
@@ -178,56 +179,61 @@ export const OFFER_TYPE_CONFIG = {
effect: 'percent_discount', effect: 'percent_discount',
description: 'Скидка для новых пользователей', description: 'Скидка для новых пользователей',
}, },
} as const } as const;
export type OfferType = keyof typeof OFFER_TYPE_CONFIG export type OfferType = keyof typeof OFFER_TYPE_CONFIG;
// ============== API ============== // ============== API ==============
export const promoOffersApi = { export const promoOffersApi = {
// Get list of promo offers // Get list of promo offers
getOffers: async (params?: { getOffers: async (params?: {
limit?: number limit?: number;
offset?: number offset?: number;
user_id?: number user_id?: number;
is_active?: boolean is_active?: boolean;
}): Promise<PromoOfferListResponse> => { }): Promise<PromoOfferListResponse> => {
const response = await apiClient.get('/cabinet/admin/promo-offers', { params }) const response = await apiClient.get('/cabinet/admin/promo-offers', { params });
return response.data return response.data;
}, },
// Broadcast offer to multiple users // Broadcast offer to multiple users
broadcastOffer: async (data: PromoOfferBroadcastRequest): Promise<PromoOfferBroadcastResponse> => { broadcastOffer: async (
const response = await apiClient.post('/cabinet/admin/promo-offers/broadcast', data) data: PromoOfferBroadcastRequest,
return response.data ): Promise<PromoOfferBroadcastResponse> => {
const response = await apiClient.post('/cabinet/admin/promo-offers/broadcast', data);
return response.data;
}, },
// Get promo offer logs // Get promo offer logs
getLogs: async (params?: { getLogs: async (params?: {
limit?: number limit?: number;
offset?: number offset?: number;
user_id?: number user_id?: number;
action?: string action?: string;
}): Promise<PromoOfferLogListResponse> => { }): Promise<PromoOfferLogListResponse> => {
const response = await apiClient.get('/cabinet/admin/promo-offers/logs', { params }) const response = await apiClient.get('/cabinet/admin/promo-offers/logs', { params });
return response.data return response.data;
}, },
// Get all templates // Get all templates
getTemplates: async (): Promise<PromoOfferTemplateListResponse> => { getTemplates: async (): Promise<PromoOfferTemplateListResponse> => {
const response = await apiClient.get('/cabinet/admin/promo-offers/templates') const response = await apiClient.get('/cabinet/admin/promo-offers/templates');
return response.data return response.data;
}, },
// Get single template // Get single template
getTemplate: async (id: number): Promise<PromoOfferTemplate> => { getTemplate: async (id: number): Promise<PromoOfferTemplate> => {
const response = await apiClient.get(`/cabinet/admin/promo-offers/templates/${id}`) const response = await apiClient.get(`/cabinet/admin/promo-offers/templates/${id}`);
return response.data return response.data;
}, },
// Update template // Update template
updateTemplate: async (id: number, data: PromoOfferTemplateUpdateRequest): Promise<PromoOfferTemplate> => { updateTemplate: async (
const response = await apiClient.patch(`/cabinet/admin/promo-offers/templates/${id}`, data) id: number,
return response.data data: PromoOfferTemplateUpdateRequest,
): Promise<PromoOfferTemplate> => {
const response = await apiClient.patch(`/cabinet/admin/promo-offers/templates/${id}`, data);
return response.data;
}, },
} };

View File

@@ -1,122 +1,127 @@
import apiClient from './client' import apiClient from './client';
// ============== Types ============== // ============== Types ==============
export type PromoCodeType = 'balance' | 'subscription_days' | 'trial_subscription' | 'promo_group' | 'discount' export type PromoCodeType =
| 'balance'
| 'subscription_days'
| 'trial_subscription'
| 'promo_group'
| 'discount';
export interface PromoCode { export interface PromoCode {
id: number id: number;
code: string code: string;
type: PromoCodeType type: PromoCodeType;
balance_bonus_kopeks: number balance_bonus_kopeks: number;
balance_bonus_rubles: number balance_bonus_rubles: number;
subscription_days: number subscription_days: number;
max_uses: number max_uses: number;
current_uses: number current_uses: number;
uses_left: number uses_left: number;
is_active: boolean is_active: boolean;
is_valid: boolean is_valid: boolean;
first_purchase_only: boolean first_purchase_only: boolean;
valid_from: string valid_from: string;
valid_until: string | null valid_until: string | null;
promo_group_id: number | null promo_group_id: number | null;
created_by: number | null created_by: number | null;
created_at: string created_at: string;
updated_at: string updated_at: string;
} }
export interface PromoCodeRecentUse { export interface PromoCodeRecentUse {
id: number id: number;
user_id: number user_id: number;
user_username: string | null user_username: string | null;
user_full_name: string | null user_full_name: string | null;
user_telegram_id: number | null user_telegram_id: number | null;
used_at: string used_at: string;
} }
export interface PromoCodeDetail extends PromoCode { export interface PromoCodeDetail extends PromoCode {
total_uses: number total_uses: number;
today_uses: number today_uses: number;
recent_uses: PromoCodeRecentUse[] recent_uses: PromoCodeRecentUse[];
} }
export interface PromoCodeListResponse { export interface PromoCodeListResponse {
items: PromoCode[] items: PromoCode[];
total: number total: number;
limit: number limit: number;
offset: number offset: number;
} }
export interface PromoCodeCreateRequest { export interface PromoCodeCreateRequest {
code: string code: string;
type: PromoCodeType type: PromoCodeType;
balance_bonus_kopeks?: number balance_bonus_kopeks?: number;
subscription_days?: number subscription_days?: number;
max_uses?: number max_uses?: number;
valid_from?: string valid_from?: string;
valid_until?: string | null valid_until?: string | null;
is_active?: boolean is_active?: boolean;
first_purchase_only?: boolean first_purchase_only?: boolean;
promo_group_id?: number | null promo_group_id?: number | null;
} }
export interface PromoCodeUpdateRequest { export interface PromoCodeUpdateRequest {
code?: string code?: string;
type?: PromoCodeType type?: PromoCodeType;
balance_bonus_kopeks?: number balance_bonus_kopeks?: number;
subscription_days?: number subscription_days?: number;
max_uses?: number max_uses?: number;
valid_from?: string valid_from?: string;
valid_until?: string | null valid_until?: string | null;
is_active?: boolean is_active?: boolean;
first_purchase_only?: boolean first_purchase_only?: boolean;
promo_group_id?: number | null promo_group_id?: number | null;
} }
// ============== PromoGroup Types ============== // ============== PromoGroup Types ==============
export interface PromoGroup { export interface PromoGroup {
id: number id: number;
name: string name: string;
server_discount_percent: number server_discount_percent: number;
traffic_discount_percent: number traffic_discount_percent: number;
device_discount_percent: number device_discount_percent: number;
period_discounts: Record<number, number> period_discounts: Record<number, number>;
auto_assign_total_spent_kopeks: number | null auto_assign_total_spent_kopeks: number | null;
apply_discounts_to_addons: boolean apply_discounts_to_addons: boolean;
is_default: boolean is_default: boolean;
members_count: number members_count: number;
created_at: string | null created_at: string | null;
updated_at: string | null updated_at: string | null;
} }
export interface PromoGroupListResponse { export interface PromoGroupListResponse {
items: PromoGroup[] items: PromoGroup[];
total: number total: number;
limit: number limit: number;
offset: number offset: number;
} }
export interface PromoGroupCreateRequest { export interface PromoGroupCreateRequest {
name: string name: string;
server_discount_percent?: number server_discount_percent?: number;
traffic_discount_percent?: number traffic_discount_percent?: number;
device_discount_percent?: number device_discount_percent?: number;
period_discounts?: Record<number, number> period_discounts?: Record<number, number>;
auto_assign_total_spent_kopeks?: number | null auto_assign_total_spent_kopeks?: number | null;
apply_discounts_to_addons?: boolean apply_discounts_to_addons?: boolean;
is_default?: boolean is_default?: boolean;
} }
export interface PromoGroupUpdateRequest { export interface PromoGroupUpdateRequest {
name?: string name?: string;
server_discount_percent?: number server_discount_percent?: number;
traffic_discount_percent?: number traffic_discount_percent?: number;
device_discount_percent?: number device_discount_percent?: number;
period_discounts?: Record<number, number> period_discounts?: Record<number, number>;
auto_assign_total_spent_kopeks?: number | null auto_assign_total_spent_kopeks?: number | null;
apply_discounts_to_addons?: boolean apply_discounts_to_addons?: boolean;
is_default?: boolean is_default?: boolean;
} }
// ============== API ============== // ============== API ==============
@@ -124,58 +129,58 @@ export interface PromoGroupUpdateRequest {
export const promocodesApi = { export const promocodesApi = {
// Promocodes // Promocodes
getPromocodes: async (params?: { getPromocodes: async (params?: {
limit?: number limit?: number;
offset?: number offset?: number;
is_active?: boolean is_active?: boolean;
}): Promise<PromoCodeListResponse> => { }): Promise<PromoCodeListResponse> => {
const response = await apiClient.get('/cabinet/admin/promocodes', { params }) const response = await apiClient.get('/cabinet/admin/promocodes', { params });
return response.data return response.data;
}, },
getPromocode: async (id: number): Promise<PromoCodeDetail> => { getPromocode: async (id: number): Promise<PromoCodeDetail> => {
const response = await apiClient.get(`/cabinet/admin/promocodes/${id}`) const response = await apiClient.get(`/cabinet/admin/promocodes/${id}`);
return response.data return response.data;
}, },
createPromocode: async (data: PromoCodeCreateRequest): Promise<PromoCode> => { createPromocode: async (data: PromoCodeCreateRequest): Promise<PromoCode> => {
const response = await apiClient.post('/cabinet/admin/promocodes', data) const response = await apiClient.post('/cabinet/admin/promocodes', data);
return response.data return response.data;
}, },
updatePromocode: async (id: number, data: PromoCodeUpdateRequest): Promise<PromoCode> => { updatePromocode: async (id: number, data: PromoCodeUpdateRequest): Promise<PromoCode> => {
const response = await apiClient.patch(`/cabinet/admin/promocodes/${id}`, data) const response = await apiClient.patch(`/cabinet/admin/promocodes/${id}`, data);
return response.data return response.data;
}, },
deletePromocode: async (id: number): Promise<void> => { deletePromocode: async (id: number): Promise<void> => {
await apiClient.delete(`/cabinet/admin/promocodes/${id}`) await apiClient.delete(`/cabinet/admin/promocodes/${id}`);
}, },
// Promo Groups // Promo Groups
getPromoGroups: async (params?: { getPromoGroups: async (params?: {
limit?: number limit?: number;
offset?: number offset?: number;
}): Promise<PromoGroupListResponse> => { }): Promise<PromoGroupListResponse> => {
const response = await apiClient.get('/cabinet/admin/promo-groups', { params }) const response = await apiClient.get('/cabinet/admin/promo-groups', { params });
return response.data return response.data;
}, },
getPromoGroup: async (id: number): Promise<PromoGroup> => { getPromoGroup: async (id: number): Promise<PromoGroup> => {
const response = await apiClient.get(`/cabinet/admin/promo-groups/${id}`) const response = await apiClient.get(`/cabinet/admin/promo-groups/${id}`);
return response.data return response.data;
}, },
createPromoGroup: async (data: PromoGroupCreateRequest): Promise<PromoGroup> => { createPromoGroup: async (data: PromoGroupCreateRequest): Promise<PromoGroup> => {
const response = await apiClient.post('/cabinet/admin/promo-groups', data) const response = await apiClient.post('/cabinet/admin/promo-groups', data);
return response.data return response.data;
}, },
updatePromoGroup: async (id: number, data: PromoGroupUpdateRequest): Promise<PromoGroup> => { updatePromoGroup: async (id: number, data: PromoGroupUpdateRequest): Promise<PromoGroup> => {
const response = await apiClient.patch(`/cabinet/admin/promo-groups/${id}`, data) const response = await apiClient.patch(`/cabinet/admin/promo-groups/${id}`, data);
return response.data return response.data;
}, },
deletePromoGroup: async (id: number): Promise<void> => { deletePromoGroup: async (id: number): Promise<void> => {
await apiClient.delete(`/cabinet/admin/promo-groups/${id}`) await apiClient.delete(`/cabinet/admin/promo-groups/${id}`);
}, },
} };

View File

@@ -1,62 +1,65 @@
import apiClient from './client' import apiClient from './client';
import type { ReferralInfo, ReferralTerms, PaginatedResponse } from '../types' import type { ReferralInfo, ReferralTerms, PaginatedResponse } from '../types';
interface ReferralItem { interface ReferralItem {
id: number id: number;
username: string | null username: string | null;
first_name: string | null first_name: string | null;
created_at: string created_at: string;
has_subscription: boolean has_subscription: boolean;
has_paid: boolean has_paid: boolean;
} }
interface ReferralEarning { interface ReferralEarning {
id: number id: number;
amount_kopeks: number amount_kopeks: number;
amount_rubles: number amount_rubles: number;
reason: string reason: string;
referral_username: string | null referral_username: string | null;
referral_first_name: string | null referral_first_name: string | null;
created_at: string created_at: string;
} }
interface ReferralEarningsList extends PaginatedResponse<ReferralEarning> { interface ReferralEarningsList extends PaginatedResponse<ReferralEarning> {
total_amount_kopeks: number total_amount_kopeks: number;
total_amount_rubles: number total_amount_rubles: number;
} }
export const referralApi = { export const referralApi = {
// Get referral info // Get referral info
getReferralInfo: async (): Promise<ReferralInfo> => { getReferralInfo: async (): Promise<ReferralInfo> => {
const response = await apiClient.get<ReferralInfo>('/cabinet/referral') const response = await apiClient.get<ReferralInfo>('/cabinet/referral');
return response.data return response.data;
}, },
// Get referral list // Get referral list
getReferralList: async (params?: { getReferralList: async (params?: {
page?: number page?: number;
per_page?: number per_page?: number;
}): Promise<PaginatedResponse<ReferralItem>> => { }): Promise<PaginatedResponse<ReferralItem>> => {
const response = await apiClient.get<PaginatedResponse<ReferralItem>>('/cabinet/referral/list', { const response = await apiClient.get<PaginatedResponse<ReferralItem>>(
'/cabinet/referral/list',
{
params, params,
}) },
return response.data );
return response.data;
}, },
// Get referral earnings // Get referral earnings
getReferralEarnings: async (params?: { getReferralEarnings: async (params?: {
page?: number page?: number;
per_page?: number per_page?: number;
}): Promise<ReferralEarningsList> => { }): Promise<ReferralEarningsList> => {
const response = await apiClient.get<ReferralEarningsList>('/cabinet/referral/earnings', { const response = await apiClient.get<ReferralEarningsList>('/cabinet/referral/earnings', {
params, params,
}) });
return response.data return response.data;
}, },
// Get referral terms // Get referral terms
getReferralTerms: async (): Promise<ReferralTerms> => { getReferralTerms: async (): Promise<ReferralTerms> => {
const response = await apiClient.get<ReferralTerms>('/cabinet/referral/terms') const response = await apiClient.get<ReferralTerms>('/cabinet/referral/terms');
return response.data return response.data;
}, },
} };

View File

@@ -1,142 +1,142 @@
import apiClient from './client' import apiClient from './client';
// Types // Types
export interface PromoGroupInfo { export interface PromoGroupInfo {
id: number id: number;
name: string name: string;
is_selected: boolean is_selected: boolean;
} }
export interface ServerListItem { export interface ServerListItem {
id: number id: number;
squad_uuid: string squad_uuid: string;
display_name: string display_name: string;
original_name: string | null original_name: string | null;
country_code: string | null country_code: string | null;
is_available: boolean is_available: boolean;
is_trial_eligible: boolean is_trial_eligible: boolean;
price_kopeks: number price_kopeks: number;
price_rubles: number price_rubles: number;
max_users: number | null max_users: number | null;
current_users: number current_users: number;
sort_order: number sort_order: number;
is_full: boolean is_full: boolean;
availability_status: string availability_status: string;
created_at: string created_at: string;
} }
export interface ServerListResponse { export interface ServerListResponse {
servers: ServerListItem[] servers: ServerListItem[];
total: number total: number;
} }
export interface ServerDetail { export interface ServerDetail {
id: number id: number;
squad_uuid: string squad_uuid: string;
display_name: string display_name: string;
original_name: string | null original_name: string | null;
country_code: string | null country_code: string | null;
description: string | null description: string | null;
is_available: boolean is_available: boolean;
is_trial_eligible: boolean is_trial_eligible: boolean;
price_kopeks: number price_kopeks: number;
price_rubles: number price_rubles: number;
max_users: number | null max_users: number | null;
current_users: number current_users: number;
sort_order: number sort_order: number;
is_full: boolean is_full: boolean;
availability_status: string availability_status: string;
promo_groups: PromoGroupInfo[] promo_groups: PromoGroupInfo[];
active_subscriptions: number active_subscriptions: number;
tariffs_using: string[] tariffs_using: string[];
created_at: string created_at: string;
updated_at: string | null updated_at: string | null;
} }
export interface ServerUpdateRequest { export interface ServerUpdateRequest {
display_name?: string display_name?: string;
description?: string description?: string;
country_code?: string country_code?: string;
is_available?: boolean is_available?: boolean;
is_trial_eligible?: boolean is_trial_eligible?: boolean;
price_kopeks?: number price_kopeks?: number;
max_users?: number max_users?: number;
sort_order?: number sort_order?: number;
promo_group_ids?: number[] promo_group_ids?: number[];
} }
export interface ServerToggleResponse { export interface ServerToggleResponse {
id: number id: number;
is_available: boolean is_available: boolean;
message: string message: string;
} }
export interface ServerTrialToggleResponse { export interface ServerTrialToggleResponse {
id: number id: number;
is_trial_eligible: boolean is_trial_eligible: boolean;
message: string message: string;
} }
export interface ServerStats { export interface ServerStats {
id: number id: number;
display_name: string display_name: string;
squad_uuid: string squad_uuid: string;
current_users: number current_users: number;
max_users: number | null max_users: number | null;
active_subscriptions: number active_subscriptions: number;
trial_subscriptions: number trial_subscriptions: number;
usage_percent: number | null usage_percent: number | null;
} }
export interface ServerSyncResponse { export interface ServerSyncResponse {
created: number created: number;
updated: number updated: number;
removed: number removed: number;
message: string message: string;
} }
export const serversApi = { export const serversApi = {
// Get all servers // Get all servers
getServers: async (includeUnavailable = true): Promise<ServerListResponse> => { getServers: async (includeUnavailable = true): Promise<ServerListResponse> => {
const response = await apiClient.get('/cabinet/admin/servers', { const response = await apiClient.get('/cabinet/admin/servers', {
params: { include_unavailable: includeUnavailable } params: { include_unavailable: includeUnavailable },
}) });
return response.data return response.data;
}, },
// Get single server // Get single server
getServer: async (serverId: number): Promise<ServerDetail> => { getServer: async (serverId: number): Promise<ServerDetail> => {
const response = await apiClient.get(`/cabinet/admin/servers/${serverId}`) const response = await apiClient.get(`/cabinet/admin/servers/${serverId}`);
return response.data return response.data;
}, },
// Update server // Update server
updateServer: async (serverId: number, data: ServerUpdateRequest): Promise<ServerDetail> => { updateServer: async (serverId: number, data: ServerUpdateRequest): Promise<ServerDetail> => {
const response = await apiClient.put(`/cabinet/admin/servers/${serverId}`, data) const response = await apiClient.put(`/cabinet/admin/servers/${serverId}`, data);
return response.data return response.data;
}, },
// Toggle server availability // Toggle server availability
toggleServer: async (serverId: number): Promise<ServerToggleResponse> => { toggleServer: async (serverId: number): Promise<ServerToggleResponse> => {
const response = await apiClient.post(`/cabinet/admin/servers/${serverId}/toggle`) const response = await apiClient.post(`/cabinet/admin/servers/${serverId}/toggle`);
return response.data return response.data;
}, },
// Toggle trial eligibility // Toggle trial eligibility
toggleTrial: async (serverId: number): Promise<ServerTrialToggleResponse> => { toggleTrial: async (serverId: number): Promise<ServerTrialToggleResponse> => {
const response = await apiClient.post(`/cabinet/admin/servers/${serverId}/trial`) const response = await apiClient.post(`/cabinet/admin/servers/${serverId}/trial`);
return response.data return response.data;
}, },
// Get server stats // Get server stats
getServerStats: async (serverId: number): Promise<ServerStats> => { getServerStats: async (serverId: number): Promise<ServerStats> => {
const response = await apiClient.get(`/cabinet/admin/servers/${serverId}/stats`) const response = await apiClient.get(`/cabinet/admin/servers/${serverId}/stats`);
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

@@ -1,218 +1,256 @@
import apiClient from './client' import apiClient from './client';
import type { Subscription, RenewalOption, TrafficPackage, TrialInfo, PurchaseOptions, PurchaseSelection, PurchasePreview, AppConfig } from '../types' import type {
Subscription,
RenewalOption,
TrafficPackage,
TrialInfo,
PurchaseOptions,
PurchaseSelection,
PurchasePreview,
AppConfig,
} from '../types';
export const subscriptionApi = { export const subscriptionApi = {
// Get current subscription // Get current subscription
getSubscription: async (): Promise<Subscription> => { getSubscription: async (): Promise<Subscription> => {
const response = await apiClient.get<Subscription>('/cabinet/subscription') const response = await apiClient.get<Subscription>('/cabinet/subscription');
return response.data return response.data;
}, },
// Get renewal options // Get renewal options
getRenewalOptions: async (): Promise<RenewalOption[]> => { getRenewalOptions: async (): Promise<RenewalOption[]> => {
const response = await apiClient.get<RenewalOption[]>('/cabinet/subscription/renewal-options') const response = await apiClient.get<RenewalOption[]>('/cabinet/subscription/renewal-options');
return response.data return response.data;
}, },
// Renew subscription // Renew subscription
renewSubscription: async (periodDays: number): Promise<{ renewSubscription: async (
message: string periodDays: number,
new_end_date: string ): Promise<{
amount_paid_kopeks: number message: string;
new_end_date: string;
amount_paid_kopeks: number;
}> => { }> => {
const response = await apiClient.post('/cabinet/subscription/renew', { const response = await apiClient.post('/cabinet/subscription/renew', {
period_days: periodDays, period_days: periodDays,
}) });
return response.data return response.data;
}, },
// Get traffic packages // Get traffic packages
getTrafficPackages: async (): Promise<TrafficPackage[]> => { getTrafficPackages: async (): Promise<TrafficPackage[]> => {
const response = await apiClient.get<TrafficPackage[]>('/cabinet/subscription/traffic-packages') const response = await apiClient.get<TrafficPackage[]>(
return response.data '/cabinet/subscription/traffic-packages',
);
return response.data;
}, },
// Purchase traffic // Purchase traffic
purchaseTraffic: async (gb: number): Promise<{ purchaseTraffic: async (
message: string gb: number,
gb_added: number ): Promise<{
amount_paid_kopeks: number message: string;
gb_added: number;
amount_paid_kopeks: number;
}> => { }> => {
const response = await apiClient.post('/cabinet/subscription/traffic', { gb }) const response = await apiClient.post('/cabinet/subscription/traffic', { gb });
return response.data return response.data;
}, },
// Purchase devices // Purchase devices
purchaseDevices: async (devices: number): Promise<{ purchaseDevices: async (
success: boolean devices: number,
message: string ): Promise<{
devices_added: number success: boolean;
new_device_limit: number message: string;
price_kopeks: number devices_added: number;
price_label: string new_device_limit: number;
balance_kopeks: number price_kopeks: number;
balance_label: string price_label: string;
balance_kopeks: number;
balance_label: string;
}> => { }> => {
const response = await apiClient.post('/cabinet/subscription/devices/purchase', { devices }) const response = await apiClient.post('/cabinet/subscription/devices/purchase', { devices });
return response.data return response.data;
}, },
// Get device purchase price // Get device purchase price
getDevicePrice: async (devices: number = 1): Promise<{ getDevicePrice: async (
available: boolean devices: number = 1,
reason?: string ): Promise<{
devices?: number available: boolean;
price_per_device_kopeks?: number reason?: string;
price_per_device_label?: string devices?: number;
total_price_kopeks?: number price_per_device_kopeks?: number;
total_price_label?: string price_per_device_label?: string;
current_device_limit?: number total_price_kopeks?: number;
days_left?: number total_price_label?: string;
base_device_price_kopeks?: number current_device_limit?: number;
days_left?: number;
base_device_price_kopeks?: number;
}> => { }> => {
const response = await apiClient.get('/cabinet/subscription/devices/price', { params: { devices } }) const response = await apiClient.get('/cabinet/subscription/devices/price', {
return response.data params: { devices },
});
return response.data;
}, },
// Update autopay settings // Update autopay settings
updateAutopay: async (enabled: boolean, daysBefore?: number): Promise<{ updateAutopay: async (
message: string enabled: boolean,
autopay_enabled: boolean daysBefore?: number,
autopay_days_before: number ): Promise<{
message: string;
autopay_enabled: boolean;
autopay_days_before: number;
}> => { }> => {
const response = await apiClient.patch('/cabinet/subscription/autopay', { const response = await apiClient.patch('/cabinet/subscription/autopay', {
enabled, enabled,
days_before: daysBefore, days_before: daysBefore,
}) });
return response.data return response.data;
}, },
// Get trial info // Get trial info
getTrialInfo: async (): Promise<TrialInfo> => { getTrialInfo: async (): Promise<TrialInfo> => {
const response = await apiClient.get<TrialInfo>('/cabinet/subscription/trial') const response = await apiClient.get<TrialInfo>('/cabinet/subscription/trial');
return response.data return response.data;
}, },
// Activate trial // Activate trial
activateTrial: async (): Promise<Subscription> => { activateTrial: async (): Promise<Subscription> => {
const response = await apiClient.post<Subscription>('/cabinet/subscription/trial') const response = await apiClient.post<Subscription>('/cabinet/subscription/trial');
return response.data return response.data;
}, },
// Get purchase options (periods, servers, traffic, devices) // Get purchase options (periods, servers, traffic, devices)
getPurchaseOptions: async (): Promise<PurchaseOptions> => { getPurchaseOptions: async (): Promise<PurchaseOptions> => {
const response = await apiClient.get<PurchaseOptions>('/cabinet/subscription/purchase-options') const response = await apiClient.get<PurchaseOptions>('/cabinet/subscription/purchase-options');
return response.data return response.data;
}, },
// Preview purchase price // Preview purchase price
previewPurchase: async (selection: PurchaseSelection): Promise<PurchasePreview> => { previewPurchase: async (selection: PurchaseSelection): Promise<PurchasePreview> => {
const response = await apiClient.post<PurchasePreview>('/cabinet/subscription/purchase-preview', { const response = await apiClient.post<PurchasePreview>(
'/cabinet/subscription/purchase-preview',
{
selection, selection,
}) },
return response.data );
return response.data;
}, },
// Submit purchase // Submit purchase
submitPurchase: async (selection: PurchaseSelection): Promise<{ submitPurchase: async (
success: boolean selection: PurchaseSelection,
message: string ): Promise<{
subscription: Subscription success: boolean;
was_trial_conversion: boolean message: string;
subscription: Subscription;
was_trial_conversion: boolean;
}> => { }> => {
const response = await apiClient.post('/cabinet/subscription/purchase', { const response = await apiClient.post('/cabinet/subscription/purchase', {
selection, selection,
}) });
return response.data return response.data;
}, },
// Purchase tariff (for tariffs mode) // Purchase tariff (for tariffs mode)
purchaseTariff: async (tariffId: number, periodDays: number, trafficGb?: number): Promise<{ purchaseTariff: async (
success: boolean tariffId: number,
message: string periodDays: number,
subscription: Subscription trafficGb?: number,
tariff_id: number ): Promise<{
tariff_name: string success: boolean;
balance_kopeks: number message: string;
balance_label: string subscription: Subscription;
tariff_id: number;
tariff_name: string;
balance_kopeks: number;
balance_label: string;
}> => { }> => {
const response = await apiClient.post('/cabinet/subscription/purchase-tariff', { const response = await apiClient.post('/cabinet/subscription/purchase-tariff', {
tariff_id: tariffId, tariff_id: tariffId,
period_days: periodDays, period_days: periodDays,
traffic_gb: trafficGb, traffic_gb: trafficGb,
}) });
return response.data return response.data;
}, },
// Get app config for connection // Get app config for connection
getAppConfig: async (): Promise<AppConfig> => { getAppConfig: async (): Promise<AppConfig> => {
const response = await apiClient.get<AppConfig>('/cabinet/subscription/app-config') const response = await apiClient.get<AppConfig>('/cabinet/subscription/app-config');
return response.data return response.data;
}, },
// Get available countries/servers // Get available countries/servers
getCountries: async (): Promise<{ getCountries: async (): Promise<{
countries: Array<{ countries: Array<{
uuid: string uuid: string;
name: string name: string;
country_code: string | null country_code: string | null;
base_price_kopeks: number base_price_kopeks: number;
price_kopeks: number price_kopeks: number;
price_per_month_kopeks: number price_per_month_kopeks: number;
price_rubles: number price_rubles: number;
is_available: boolean is_available: boolean;
is_connected: boolean is_connected: boolean;
has_discount: boolean has_discount: boolean;
discount_percent: number discount_percent: number;
}> }>;
connected_count: number connected_count: number;
has_subscription: boolean has_subscription: boolean;
days_left: number days_left: number;
discount_percent: number discount_percent: number;
}> => { }> => {
const response = await apiClient.get('/cabinet/subscription/countries') const response = await apiClient.get('/cabinet/subscription/countries');
return response.data return response.data;
}, },
// Update countries/servers // Update countries/servers
updateCountries: async (countries: string[]): Promise<{ updateCountries: async (
message: string countries: string[],
added: string[] ): Promise<{
removed: string[] message: string;
amount_paid_kopeks: number added: string[];
connected_squads: string[] removed: string[];
amount_paid_kopeks: number;
connected_squads: string[];
}> => { }> => {
const response = await apiClient.post('/cabinet/subscription/countries', { countries }) const response = await apiClient.post('/cabinet/subscription/countries', { countries });
return response.data return response.data;
}, },
// Get connection link and instructions // Get connection link and instructions
getConnectionLink: async (): Promise<{ getConnectionLink: async (): Promise<{
subscription_url: string | null subscription_url: string | null;
display_link: string | null display_link: string | null;
happ_redirect_link: string | null happ_redirect_link: string | null;
happ_scheme_link: string | null happ_scheme_link: string | null;
connect_mode: string connect_mode: string;
hide_link: boolean hide_link: boolean;
instructions: { instructions: {
steps: string[] steps: string[];
} };
}> => { }> => {
const response = await apiClient.get('/cabinet/subscription/connection-link') const response = await apiClient.get('/cabinet/subscription/connection-link');
return response.data return response.data;
}, },
// Get hApp download links // Get hApp download links
getHappDownloads: async (): Promise<{ getHappDownloads: async (): Promise<{
platforms: Record<string, { platforms: Record<
name: string string,
icon: string {
link: string name: string;
}> icon: string;
happ_enabled: boolean link: string;
}
>;
happ_enabled: boolean;
}> => { }> => {
const response = await apiClient.get('/cabinet/subscription/happ-downloads') const response = await apiClient.get('/cabinet/subscription/happ-downloads');
return response.data return response.data;
}, },
// ============ Device Management ============ // ============ Device Management ============
@@ -220,130 +258,140 @@ export const subscriptionApi = {
// Get connected devices // Get connected devices
getDevices: async (): Promise<{ getDevices: async (): Promise<{
devices: Array<{ devices: Array<{
hwid: string hwid: string;
platform: string platform: string;
device_model: string device_model: string;
created_at: string | null created_at: string | null;
}> }>;
total: number total: number;
device_limit: number device_limit: number;
}> => { }> => {
const response = await apiClient.get('/cabinet/subscription/devices') const response = await apiClient.get('/cabinet/subscription/devices');
return response.data return response.data;
}, },
// Delete a specific device // Delete a specific device
deleteDevice: async (hwid: string): Promise<{ deleteDevice: async (
success: boolean hwid: string,
message: string ): Promise<{
deleted_hwid: string success: boolean;
message: string;
deleted_hwid: string;
}> => { }> => {
const response = await apiClient.delete(`/cabinet/subscription/devices/${encodeURIComponent(hwid)}`) const response = await apiClient.delete(
return response.data `/cabinet/subscription/devices/${encodeURIComponent(hwid)}`,
);
return response.data;
}, },
// Delete all devices // Delete all devices
deleteAllDevices: async (): Promise<{ deleteAllDevices: async (): Promise<{
success: boolean success: boolean;
message: string message: string;
deleted_count: number deleted_count: number;
}> => { }> => {
const response = await apiClient.delete('/cabinet/subscription/devices') const response = await apiClient.delete('/cabinet/subscription/devices');
return response.data return response.data;
}, },
// ============ Tariff Switch ============ // ============ Tariff Switch ============
// Preview tariff switch cost // Preview tariff switch cost
previewTariffSwitch: async (tariffId: number): Promise<{ previewTariffSwitch: async (
can_switch: boolean tariffId: number,
current_tariff_id: number | null ): Promise<{
current_tariff_name: string | null can_switch: boolean;
new_tariff_id: number current_tariff_id: number | null;
new_tariff_name: string current_tariff_name: string | null;
remaining_days: number new_tariff_id: number;
upgrade_cost_kopeks: number new_tariff_name: string;
upgrade_cost_label: string remaining_days: number;
balance_kopeks: number upgrade_cost_kopeks: number;
balance_label: string upgrade_cost_label: string;
has_enough_balance: boolean balance_kopeks: number;
missing_amount_kopeks: number balance_label: string;
missing_amount_label: string has_enough_balance: boolean;
is_upgrade: boolean missing_amount_kopeks: number;
missing_amount_label: string;
is_upgrade: boolean;
}> => { }> => {
const response = await apiClient.post('/cabinet/subscription/tariff/switch/preview', { const response = await apiClient.post('/cabinet/subscription/tariff/switch/preview', {
tariff_id: tariffId, tariff_id: tariffId,
period_days: 30, // Default period for switch period_days: 30, // Default period for switch
}) });
return response.data return response.data;
}, },
// Switch to a different tariff // Switch to a different tariff
switchTariff: async (tariffId: number): Promise<{ switchTariff: async (
success: boolean tariffId: number,
message: string ): Promise<{
subscription: Subscription success: boolean;
old_tariff_name: string message: string;
new_tariff_id: number subscription: Subscription;
new_tariff_name: string old_tariff_name: string;
charged_kopeks: number new_tariff_id: number;
balance_kopeks: number new_tariff_name: string;
balance_label: string charged_kopeks: number;
balance_kopeks: number;
balance_label: string;
}> => { }> => {
const response = await apiClient.post('/cabinet/subscription/tariff/switch', { const response = await apiClient.post('/cabinet/subscription/tariff/switch', {
tariff_id: tariffId, tariff_id: tariffId,
period_days: 30, period_days: 30,
}) });
return response.data return response.data;
}, },
// ============ Subscription Pause (Daily Tariffs) ============ // ============ Subscription Pause (Daily Tariffs) ============
// Toggle pause/resume for daily subscription // Toggle pause/resume for daily subscription
togglePause: async (): Promise<{ togglePause: async (): Promise<{
success: boolean success: boolean;
message: string message: string;
is_paused: boolean is_paused: boolean;
balance_kopeks: number balance_kopeks: number;
balance_label: string balance_label: string;
}> => { }> => {
const response = await apiClient.post('/cabinet/subscription/pause') const response = await apiClient.post('/cabinet/subscription/pause');
return response.data return response.data;
}, },
// ============ Traffic Switch ============ // ============ Traffic Switch ============
// Switch to a different traffic package // Switch to a different traffic package
switchTraffic: async (gb: number): Promise<{ switchTraffic: async (
success: boolean gb: number,
message: string ): Promise<{
old_traffic_gb: number success: boolean;
new_traffic_gb: number message: string;
charged_kopeks: number old_traffic_gb: number;
balance_kopeks: number new_traffic_gb: number;
balance_label: string charged_kopeks: number;
balance_kopeks: number;
balance_label: string;
}> => { }> => {
const response = await apiClient.put('/cabinet/subscription/traffic', { gb }) const response = await apiClient.put('/cabinet/subscription/traffic', { gb });
return response.data return response.data;
}, },
// Refresh traffic usage from RemnaWave (rate limited: 1 per 60 seconds) // Refresh traffic usage from RemnaWave (rate limited: 1 per 60 seconds)
refreshTraffic: async (): Promise<{ refreshTraffic: async (): Promise<{
success: boolean success: boolean;
cached: boolean cached: boolean;
rate_limited?: boolean rate_limited?: boolean;
retry_after_seconds?: number retry_after_seconds?: number;
source?: string source?: string;
traffic_used_bytes: number traffic_used_bytes: number;
traffic_used_gb: number traffic_used_gb: number;
traffic_limit_bytes: number traffic_limit_bytes: number;
traffic_limit_gb: number traffic_limit_gb: number;
traffic_used_percent: number traffic_used_percent: number;
is_unlimited: boolean is_unlimited: boolean;
lifetime_used_bytes?: number lifetime_used_bytes?: number;
lifetime_used_gb?: number lifetime_used_gb?: number;
}> => { }> => {
const response = await apiClient.post('/cabinet/subscription/refresh-traffic') const response = await apiClient.post('/cabinet/subscription/refresh-traffic');
return response.data return response.data;
}, },
} };

View File

@@ -1,239 +1,239 @@
import apiClient from './client' import apiClient from './client';
// Types // Types
export interface PeriodPrice { export interface PeriodPrice {
days: number days: number;
price_kopeks: number price_kopeks: number;
price_rubles?: number price_rubles?: number;
} }
export interface ServerTrafficLimit { export interface ServerTrafficLimit {
traffic_limit_gb: number traffic_limit_gb: number;
} }
export interface ServerInfo { export interface ServerInfo {
id: number id: number;
squad_uuid: string squad_uuid: string;
display_name: string display_name: string;
country_code: string | null country_code: string | null;
is_selected: boolean is_selected: boolean;
traffic_limit_gb?: number | null traffic_limit_gb?: number | null;
} }
export interface PromoGroupInfo { export interface PromoGroupInfo {
id: number id: number;
name: string name: string;
is_selected: boolean is_selected: boolean;
} }
export interface TariffListItem { export interface TariffListItem {
id: number id: number;
name: string name: string;
description: string | null description: string | null;
is_active: boolean is_active: boolean;
is_trial_available: boolean is_trial_available: boolean;
is_daily: boolean is_daily: boolean;
daily_price_kopeks: number daily_price_kopeks: number;
traffic_limit_gb: number traffic_limit_gb: number;
device_limit: number device_limit: number;
tier_level: number tier_level: number;
display_order: number display_order: number;
servers_count: number servers_count: number;
subscriptions_count: number subscriptions_count: number;
created_at: string created_at: string;
} }
export interface TariffListResponse { export interface TariffListResponse {
tariffs: TariffListItem[] tariffs: TariffListItem[];
total: number total: number;
} }
export interface TariffDetail { export interface TariffDetail {
id: number id: number;
name: string name: string;
description: string | null description: string | null;
is_active: boolean is_active: boolean;
is_trial_available: boolean is_trial_available: boolean;
traffic_limit_gb: number traffic_limit_gb: number;
device_limit: number device_limit: number;
device_price_kopeks: number | null device_price_kopeks: number | null;
max_device_limit: number | null max_device_limit: number | null;
tier_level: number tier_level: number;
display_order: number display_order: number;
period_prices: PeriodPrice[] period_prices: PeriodPrice[];
allowed_squads: string[] allowed_squads: string[];
server_traffic_limits: Record<string, ServerTrafficLimit> server_traffic_limits: Record<string, ServerTrafficLimit>;
servers: ServerInfo[] servers: ServerInfo[];
promo_groups: PromoGroupInfo[] promo_groups: PromoGroupInfo[];
subscriptions_count: number subscriptions_count: number;
// Произвольное количество дней // Произвольное количество дней
custom_days_enabled: boolean custom_days_enabled: boolean;
price_per_day_kopeks: number price_per_day_kopeks: number;
min_days: number min_days: number;
max_days: number max_days: number;
// Произвольный трафик при покупке // Произвольный трафик при покупке
custom_traffic_enabled: boolean custom_traffic_enabled: boolean;
traffic_price_per_gb_kopeks: number traffic_price_per_gb_kopeks: number;
min_traffic_gb: number min_traffic_gb: number;
max_traffic_gb: number max_traffic_gb: number;
// Докупка трафика // Докупка трафика
traffic_topup_enabled: boolean traffic_topup_enabled: boolean;
traffic_topup_packages: Record<string, number> traffic_topup_packages: Record<string, number>;
max_topup_traffic_gb: number max_topup_traffic_gb: number;
// Дневной тариф // Дневной тариф
is_daily: boolean is_daily: boolean;
daily_price_kopeks: number daily_price_kopeks: number;
// Режим сброса трафика // Режим сброса трафика
traffic_reset_mode: string | null // 'DAY', 'WEEK', 'MONTH', 'NO_RESET', null = глобальная настройка traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'NO_RESET', null = глобальная настройка
created_at: string created_at: string;
updated_at: string | null updated_at: string | null;
} }
export interface TariffCreateRequest { export interface TariffCreateRequest {
name: string name: string;
description?: string description?: string;
is_active?: boolean is_active?: boolean;
traffic_limit_gb?: number traffic_limit_gb?: number;
device_limit?: number device_limit?: number;
device_price_kopeks?: number device_price_kopeks?: number;
max_device_limit?: number max_device_limit?: number;
tier_level?: number tier_level?: number;
period_prices?: PeriodPrice[] period_prices?: PeriodPrice[];
allowed_squads?: string[] allowed_squads?: string[];
server_traffic_limits?: Record<string, ServerTrafficLimit> server_traffic_limits?: Record<string, ServerTrafficLimit>;
promo_group_ids?: number[] promo_group_ids?: number[];
// Произвольное количество дней // Произвольное количество дней
custom_days_enabled?: boolean custom_days_enabled?: boolean;
price_per_day_kopeks?: number price_per_day_kopeks?: number;
min_days?: number min_days?: number;
max_days?: number max_days?: number;
// Произвольный трафик при покупке // Произвольный трафик при покупке
custom_traffic_enabled?: boolean custom_traffic_enabled?: boolean;
traffic_price_per_gb_kopeks?: number traffic_price_per_gb_kopeks?: number;
min_traffic_gb?: number min_traffic_gb?: number;
max_traffic_gb?: number max_traffic_gb?: number;
// Докупка трафика // Докупка трафика
traffic_topup_enabled?: boolean traffic_topup_enabled?: boolean;
traffic_topup_packages?: Record<string, number> traffic_topup_packages?: Record<string, number>;
max_topup_traffic_gb?: number max_topup_traffic_gb?: number;
// Дневной тариф // Дневной тариф
is_daily?: boolean is_daily?: boolean;
daily_price_kopeks?: number daily_price_kopeks?: number;
// Режим сброса трафика // Режим сброса трафика
traffic_reset_mode?: string | null traffic_reset_mode?: string | null;
} }
export interface TariffUpdateRequest { export interface TariffUpdateRequest {
name?: string name?: string;
description?: string description?: string;
is_active?: boolean is_active?: boolean;
traffic_limit_gb?: number traffic_limit_gb?: number;
device_limit?: number device_limit?: number;
device_price_kopeks?: number device_price_kopeks?: number;
max_device_limit?: number max_device_limit?: number;
tier_level?: number tier_level?: number;
display_order?: number display_order?: number;
period_prices?: PeriodPrice[] period_prices?: PeriodPrice[];
allowed_squads?: string[] allowed_squads?: string[];
server_traffic_limits?: Record<string, ServerTrafficLimit> server_traffic_limits?: Record<string, ServerTrafficLimit>;
promo_group_ids?: number[] promo_group_ids?: number[];
// Произвольное количество дней // Произвольное количество дней
custom_days_enabled?: boolean custom_days_enabled?: boolean;
price_per_day_kopeks?: number price_per_day_kopeks?: number;
min_days?: number min_days?: number;
max_days?: number max_days?: number;
// Произвольный трафик при покупке // Произвольный трафик при покупке
custom_traffic_enabled?: boolean custom_traffic_enabled?: boolean;
traffic_price_per_gb_kopeks?: number traffic_price_per_gb_kopeks?: number;
min_traffic_gb?: number min_traffic_gb?: number;
max_traffic_gb?: number max_traffic_gb?: number;
// Докупка трафика // Докупка трафика
traffic_topup_enabled?: boolean traffic_topup_enabled?: boolean;
traffic_topup_packages?: Record<string, number> traffic_topup_packages?: Record<string, number>;
max_topup_traffic_gb?: number max_topup_traffic_gb?: number;
// Дневной тариф // Дневной тариф
is_daily?: boolean is_daily?: boolean;
daily_price_kopeks?: number daily_price_kopeks?: number;
// Режим сброса трафика // Режим сброса трафика
traffic_reset_mode?: string | null traffic_reset_mode?: string | null;
} }
export interface TariffToggleResponse { export interface TariffToggleResponse {
id: number id: number;
is_active: boolean is_active: boolean;
message: string message: string;
} }
export interface TariffTrialResponse { export interface TariffTrialResponse {
id: number id: number;
is_trial_available: boolean is_trial_available: boolean;
message: string message: string;
} }
export interface TariffStats { export interface TariffStats {
id: number id: number;
name: string name: string;
subscriptions_count: number subscriptions_count: number;
active_subscriptions: number active_subscriptions: number;
trial_subscriptions: number trial_subscriptions: number;
revenue_kopeks: number revenue_kopeks: number;
revenue_rubles: number revenue_rubles: number;
} }
export const tariffsApi = { export const tariffsApi = {
// Get all tariffs // Get all tariffs
getTariffs: async (includeInactive = true): Promise<TariffListResponse> => { getTariffs: async (includeInactive = true): Promise<TariffListResponse> => {
const response = await apiClient.get('/cabinet/admin/tariffs', { const response = await apiClient.get('/cabinet/admin/tariffs', {
params: { include_inactive: includeInactive } params: { include_inactive: includeInactive },
}) });
return response.data return response.data;
}, },
// Get single tariff // Get single tariff
getTariff: async (tariffId: number): Promise<TariffDetail> => { getTariff: async (tariffId: number): Promise<TariffDetail> => {
const response = await apiClient.get(`/cabinet/admin/tariffs/${tariffId}`) const response = await apiClient.get(`/cabinet/admin/tariffs/${tariffId}`);
return response.data return response.data;
}, },
// Create new tariff // Create new tariff
createTariff: async (data: TariffCreateRequest): Promise<TariffDetail> => { createTariff: async (data: TariffCreateRequest): Promise<TariffDetail> => {
const response = await apiClient.post('/cabinet/admin/tariffs', data) const response = await apiClient.post('/cabinet/admin/tariffs', data);
return response.data return response.data;
}, },
// Update tariff // Update tariff
updateTariff: async (tariffId: number, data: TariffUpdateRequest): Promise<TariffDetail> => { updateTariff: async (tariffId: number, data: TariffUpdateRequest): Promise<TariffDetail> => {
const response = await apiClient.put(`/cabinet/admin/tariffs/${tariffId}`, data) const response = await apiClient.put(`/cabinet/admin/tariffs/${tariffId}`, data);
return response.data return response.data;
}, },
// Delete tariff // Delete tariff
deleteTariff: async (tariffId: number): Promise<{ message: string }> => { deleteTariff: async (tariffId: number): Promise<{ message: string }> => {
const response = await apiClient.delete(`/cabinet/admin/tariffs/${tariffId}`) const response = await apiClient.delete(`/cabinet/admin/tariffs/${tariffId}`);
return response.data return response.data;
}, },
// Toggle tariff active status // Toggle tariff active status
toggleTariff: async (tariffId: number): Promise<TariffToggleResponse> => { toggleTariff: async (tariffId: number): Promise<TariffToggleResponse> => {
const response = await apiClient.post(`/cabinet/admin/tariffs/${tariffId}/toggle`) const response = await apiClient.post(`/cabinet/admin/tariffs/${tariffId}/toggle`);
return response.data return response.data;
}, },
// Toggle trial status // Toggle trial status
toggleTrial: async (tariffId: number): Promise<TariffTrialResponse> => { toggleTrial: async (tariffId: number): Promise<TariffTrialResponse> => {
const response = await apiClient.post(`/cabinet/admin/tariffs/${tariffId}/trial`) const response = await apiClient.post(`/cabinet/admin/tariffs/${tariffId}/trial`);
return response.data return response.data;
}, },
// Get tariff stats // Get tariff stats
getTariffStats: async (tariffId: number): Promise<TariffStats> => { getTariffStats: async (tariffId: number): Promise<TariffStats> => {
const response = await apiClient.get(`/cabinet/admin/tariffs/${tariffId}/stats`) const response = await apiClient.get(`/cabinet/admin/tariffs/${tariffId}/stats`);
return response.data return response.data;
}, },
// Get available servers for selection // Get available servers for selection
getAvailableServers: async (): Promise<ServerInfo[]> => { getAvailableServers: async (): Promise<ServerInfo[]> => {
const response = await apiClient.get('/cabinet/admin/tariffs/available-servers') const response = await apiClient.get('/cabinet/admin/tariffs/available-servers');
return response.data return response.data;
}, },
} };

View File

@@ -1,43 +1,48 @@
import apiClient from './client' import apiClient from './client';
import { ThemeSettings, DEFAULT_THEME_COLORS, EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme' import {
ThemeSettings,
DEFAULT_THEME_COLORS,
EnabledThemes,
DEFAULT_ENABLED_THEMES,
} from '../types/theme';
export const themeColorsApi = { export const themeColorsApi = {
// Get current theme colors (public, no auth required) // Get current theme colors (public, no auth required)
getColors: async (): Promise<ThemeSettings> => { getColors: async (): Promise<ThemeSettings> => {
try { try {
const response = await apiClient.get<ThemeSettings>('/cabinet/branding/colors') const response = await apiClient.get<ThemeSettings>('/cabinet/branding/colors');
return response.data return response.data;
} catch { } catch {
// Return default colors if endpoint not available // Return default colors if endpoint not available
return DEFAULT_THEME_COLORS return DEFAULT_THEME_COLORS;
} }
}, },
// Update theme colors (admin only) // Update theme colors (admin only)
updateColors: async (colors: Partial<ThemeSettings>): Promise<ThemeSettings> => { updateColors: async (colors: Partial<ThemeSettings>): Promise<ThemeSettings> => {
const response = await apiClient.patch<ThemeSettings>('/cabinet/branding/colors', colors) const response = await apiClient.patch<ThemeSettings>('/cabinet/branding/colors', colors);
return response.data return response.data;
}, },
// Reset to default colors (admin only) // Reset to default colors (admin only)
resetColors: async (): Promise<ThemeSettings> => { resetColors: async (): Promise<ThemeSettings> => {
const response = await apiClient.post<ThemeSettings>('/cabinet/branding/colors/reset') const response = await apiClient.post<ThemeSettings>('/cabinet/branding/colors/reset');
return response.data return response.data;
}, },
// Get enabled themes (public, no auth required) // Get enabled themes (public, no auth required)
getEnabledThemes: async (): Promise<EnabledThemes> => { getEnabledThemes: async (): Promise<EnabledThemes> => {
try { try {
const response = await apiClient.get<EnabledThemes>('/cabinet/branding/themes') const response = await apiClient.get<EnabledThemes>('/cabinet/branding/themes');
return response.data return response.data;
} catch { } catch {
return DEFAULT_ENABLED_THEMES return DEFAULT_ENABLED_THEMES;
} }
}, },
// Update enabled themes (admin only) // Update enabled themes (admin only)
updateEnabledThemes: async (themes: Partial<EnabledThemes>): Promise<EnabledThemes> => { updateEnabledThemes: async (themes: Partial<EnabledThemes>): Promise<EnabledThemes> => {
const response = await apiClient.patch<EnabledThemes>('/cabinet/branding/themes', themes) const response = await apiClient.patch<EnabledThemes>('/cabinet/branding/themes', themes);
return response.data return response.data;
}, },
} };

View File

@@ -1,64 +1,80 @@
import apiClient from './client' import apiClient from './client';
import type { TicketNotificationList, UnreadCountResponse } from '../types' import type { TicketNotificationList, UnreadCountResponse } from '../types';
export const ticketNotificationsApi = { export const ticketNotificationsApi = {
// User notifications // User notifications
getNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise<TicketNotificationList> => { getNotifications: async (
unreadOnly = false,
limit = 50,
offset = 0,
): Promise<TicketNotificationList> => {
const response = await apiClient.get('/cabinet/tickets/notifications', { const response = await apiClient.get('/cabinet/tickets/notifications', {
params: { unread_only: unreadOnly, limit, offset } params: { unread_only: unreadOnly, limit, offset },
}) });
return response.data return response.data;
}, },
getUnreadCount: async (): Promise<UnreadCountResponse> => { getUnreadCount: async (): Promise<UnreadCountResponse> => {
const response = await apiClient.get('/cabinet/tickets/notifications/unread-count') const response = await apiClient.get('/cabinet/tickets/notifications/unread-count');
return response.data return response.data;
}, },
markAsRead: async (notificationId: number): Promise<{ success: boolean }> => { markAsRead: async (notificationId: number): Promise<{ success: boolean }> => {
const response = await apiClient.post(`/cabinet/tickets/notifications/${notificationId}/read`) const response = await apiClient.post(`/cabinet/tickets/notifications/${notificationId}/read`);
return response.data return response.data;
}, },
markAllAsRead: async (): Promise<{ success: boolean; marked_count: number }> => { markAllAsRead: async (): Promise<{ success: boolean; marked_count: number }> => {
const response = await apiClient.post('/cabinet/tickets/notifications/read-all') const response = await apiClient.post('/cabinet/tickets/notifications/read-all');
return response.data return response.data;
}, },
markTicketAsRead: async (ticketId: number): Promise<{ success: boolean; marked_count: number }> => { markTicketAsRead: async (
const response = await apiClient.post(`/cabinet/tickets/notifications/ticket/${ticketId}/read`) ticketId: number,
return response.data ): Promise<{ success: boolean; marked_count: number }> => {
const response = await apiClient.post(`/cabinet/tickets/notifications/ticket/${ticketId}/read`);
return response.data;
}, },
// Admin notifications // Admin notifications
getAdminNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise<TicketNotificationList> => { getAdminNotifications: async (
const params: Record<string, unknown> = { limit, offset } unreadOnly = false,
limit = 50,
offset = 0,
): Promise<TicketNotificationList> => {
const params: Record<string, unknown> = { limit, offset };
if (unreadOnly) { if (unreadOnly) {
params.unread_only = true params.unread_only = true;
} }
const response = await apiClient.get('/cabinet/admin/tickets/notifications', { params }) const response = await apiClient.get('/cabinet/admin/tickets/notifications', { params });
return response.data return response.data;
}, },
getAdminUnreadCount: async (): Promise<UnreadCountResponse> => { getAdminUnreadCount: async (): Promise<UnreadCountResponse> => {
const response = await apiClient.get('/cabinet/admin/tickets/notifications/unread-count') const response = await apiClient.get('/cabinet/admin/tickets/notifications/unread-count');
return response.data return response.data;
}, },
markAdminAsRead: async (notificationId: number): Promise<{ success: boolean }> => { markAdminAsRead: async (notificationId: number): Promise<{ success: boolean }> => {
const response = await apiClient.post(`/cabinet/admin/tickets/notifications/${notificationId}/read`) const response = await apiClient.post(
return response.data `/cabinet/admin/tickets/notifications/${notificationId}/read`,
);
return response.data;
}, },
markAllAdminAsRead: async (): Promise<{ success: boolean; marked_count: number }> => { markAllAdminAsRead: async (): Promise<{ success: boolean; marked_count: number }> => {
const response = await apiClient.post('/cabinet/admin/tickets/notifications/read-all') const response = await apiClient.post('/cabinet/admin/tickets/notifications/read-all');
return response.data return response.data;
}, },
markAdminTicketAsRead: async (ticketId: number): Promise<{ success: boolean; marked_count: number }> => { markAdminTicketAsRead: async (
const response = await apiClient.post(`/cabinet/admin/tickets/notifications/ticket/${ticketId}/read`) ticketId: number,
return response.data ): Promise<{ success: boolean; marked_count: number }> => {
const response = await apiClient.post(
`/cabinet/admin/tickets/notifications/ticket/${ticketId}/read`,
);
return response.data;
}, },
} };
export default ticketNotificationsApi export default ticketNotificationsApi;

View File

@@ -1,84 +1,84 @@
import apiClient from './client' import apiClient from './client';
import type { Ticket, TicketDetail, TicketMessage, PaginatedResponse } from '../types' import type { Ticket, TicketDetail, TicketMessage, PaginatedResponse } from '../types';
// Media upload response type // Media upload response type
interface MediaUploadResponse { interface MediaUploadResponse {
media_type: string media_type: string;
file_id: string file_id: string;
file_unique_id: string | null file_unique_id: string | null;
media_url: string media_url: string;
} }
// Media parameters for ticket messages // Media parameters for ticket messages
interface MediaParams { interface MediaParams {
media_type?: string media_type?: string;
media_file_id?: string media_file_id?: string;
media_caption?: string media_caption?: string;
} }
export const ticketsApi = { export const ticketsApi = {
// Get tickets list // Get tickets list
getTickets: async (params?: { getTickets: async (params?: {
page?: number page?: number;
per_page?: number per_page?: number;
status?: string status?: string;
}): Promise<PaginatedResponse<Ticket>> => { }): Promise<PaginatedResponse<Ticket>> => {
const response = await apiClient.get<PaginatedResponse<Ticket>>('/cabinet/tickets', { const response = await apiClient.get<PaginatedResponse<Ticket>>('/cabinet/tickets', {
params, params,
}) });
return response.data return response.data;
}, },
// Create ticket with optional media // Create ticket with optional media
createTicket: async ( createTicket: async (
title: string, title: string,
message: string, message: string,
media?: MediaParams media?: MediaParams,
): Promise<TicketDetail> => { ): Promise<TicketDetail> => {
const response = await apiClient.post<TicketDetail>('/cabinet/tickets', { const response = await apiClient.post<TicketDetail>('/cabinet/tickets', {
title, title,
message, message,
...media, ...media,
}) });
return response.data return response.data;
}, },
// Get ticket detail // Get ticket detail
getTicket: async (ticketId: number): Promise<TicketDetail> => { getTicket: async (ticketId: number): Promise<TicketDetail> => {
const response = await apiClient.get<TicketDetail>(`/cabinet/tickets/${ticketId}`) const response = await apiClient.get<TicketDetail>(`/cabinet/tickets/${ticketId}`);
return response.data return response.data;
}, },
// Add message to ticket with optional media // Add message to ticket with optional media
addMessage: async ( addMessage: async (
ticketId: number, ticketId: number,
message: string, message: string,
media?: MediaParams media?: MediaParams,
): Promise<TicketMessage> => { ): Promise<TicketMessage> => {
const response = await apiClient.post<TicketMessage>(`/cabinet/tickets/${ticketId}/messages`, { const response = await apiClient.post<TicketMessage>(`/cabinet/tickets/${ticketId}/messages`, {
message, message,
...media, ...media,
}) });
return response.data return response.data;
}, },
// Upload media file for tickets // Upload media file for tickets
uploadMedia: async (file: File, mediaType: string = 'photo'): Promise<MediaUploadResponse> => { uploadMedia: async (file: File, mediaType: string = 'photo'): Promise<MediaUploadResponse> => {
const formData = new FormData() const formData = new FormData();
formData.append('file', file) formData.append('file', file);
formData.append('media_type', mediaType) formData.append('media_type', mediaType);
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData, { const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData, {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
}) });
return response.data return response.data;
}, },
// Get media URL for display // Get media URL for display
getMediaUrl: (fileId: string): string => { getMediaUrl: (fileId: string): string => {
const baseUrl = import.meta.env.VITE_API_URL || '' const baseUrl = import.meta.env.VITE_API_URL || '';
return `${baseUrl}/cabinet/media/${fileId}` return `${baseUrl}/cabinet/media/${fileId}`;
}, },
} };

View File

@@ -1,182 +1,182 @@
import apiClient from './client' import apiClient from './client';
// ==================== TYPES ==================== // ==================== TYPES ====================
export interface WheelPrize { export interface WheelPrize {
id: number id: number;
display_name: string display_name: string;
emoji: string emoji: string;
color: string color: string;
prize_type: string prize_type: string;
} }
export interface WheelConfig { export interface WheelConfig {
is_enabled: boolean is_enabled: boolean;
name: string name: string;
spin_cost_stars: number | null spin_cost_stars: number | null;
spin_cost_days: number | null spin_cost_days: number | null;
spin_cost_stars_enabled: boolean spin_cost_stars_enabled: boolean;
spin_cost_days_enabled: boolean spin_cost_days_enabled: boolean;
prizes: WheelPrize[] prizes: WheelPrize[];
daily_limit: number daily_limit: number;
user_spins_today: number user_spins_today: number;
can_spin: boolean can_spin: boolean;
can_spin_reason: string | null can_spin_reason: string | null;
can_pay_stars: boolean can_pay_stars: boolean;
can_pay_days: boolean can_pay_days: boolean;
user_balance_kopeks: number user_balance_kopeks: number;
required_balance_kopeks: number required_balance_kopeks: number;
} }
export interface SpinAvailability { export interface SpinAvailability {
can_spin: boolean can_spin: boolean;
reason: string | null reason: string | null;
spins_remaining_today: number spins_remaining_today: number;
can_pay_stars: boolean can_pay_stars: boolean;
can_pay_days: boolean can_pay_days: boolean;
min_subscription_days: number min_subscription_days: number;
user_subscription_days: number user_subscription_days: number;
} }
export interface SpinResult { export interface SpinResult {
success: boolean success: boolean;
prize_id: number | null prize_id: number | null;
prize_type: string | null prize_type: string | null;
prize_value: number prize_value: number;
prize_display_name: string prize_display_name: string;
emoji: string emoji: string;
color: string color: string;
rotation_degrees: number rotation_degrees: number;
message: string message: string;
promocode: string | null promocode: string | null;
error: string | null error: string | null;
} }
export interface SpinHistoryItem { export interface SpinHistoryItem {
id: number id: number;
payment_type: string payment_type: string;
payment_amount: number payment_amount: number;
prize_type: string prize_type: string;
prize_value: number prize_value: number;
prize_display_name: string prize_display_name: string;
emoji: string emoji: string;
color: string color: string;
prize_value_kopeks: number prize_value_kopeks: number;
created_at: string created_at: string;
} }
export interface SpinHistoryResponse { export interface SpinHistoryResponse {
items: SpinHistoryItem[] items: SpinHistoryItem[];
total: number total: number;
page: number page: number;
per_page: number per_page: number;
pages: number pages: number;
} }
export interface StarsInvoiceResponse { export interface StarsInvoiceResponse {
invoice_url: string invoice_url: string;
stars_amount: number stars_amount: number;
} }
// Admin types // Admin types
export interface WheelPrizeAdmin { export interface WheelPrizeAdmin {
id: number id: number;
config_id: number config_id: number;
prize_type: string prize_type: string;
prize_value: number prize_value: number;
display_name: string display_name: string;
emoji: string emoji: string;
color: string color: string;
prize_value_kopeks: number prize_value_kopeks: number;
sort_order: number sort_order: number;
manual_probability: number | null manual_probability: number | null;
is_active: boolean is_active: boolean;
promo_balance_bonus_kopeks: number promo_balance_bonus_kopeks: number;
promo_subscription_days: number promo_subscription_days: number;
promo_traffic_gb: number promo_traffic_gb: number;
created_at: string | null created_at: string | null;
updated_at: string | null updated_at: string | null;
} }
// Type for creating a new prize (excludes id, config_id which are auto-generated) // Type for creating a new prize (excludes id, config_id which are auto-generated)
export interface CreateWheelPrizeData { export interface CreateWheelPrizeData {
prize_type: string prize_type: string;
prize_value: number prize_value: number;
display_name: string display_name: string;
emoji?: string emoji?: string;
color?: string color?: string;
prize_value_kopeks: number prize_value_kopeks: number;
sort_order?: number sort_order?: number;
manual_probability?: number | null manual_probability?: number | null;
is_active?: boolean is_active?: boolean;
promo_balance_bonus_kopeks?: number promo_balance_bonus_kopeks?: number;
promo_subscription_days?: number promo_subscription_days?: number;
promo_traffic_gb?: number promo_traffic_gb?: number;
} }
export interface AdminWheelConfig { export interface AdminWheelConfig {
id: number id: number;
is_enabled: boolean is_enabled: boolean;
name: string name: string;
spin_cost_stars: number spin_cost_stars: number;
spin_cost_days: number spin_cost_days: number;
spin_cost_stars_enabled: boolean spin_cost_stars_enabled: boolean;
spin_cost_days_enabled: boolean spin_cost_days_enabled: boolean;
rtp_percent: number rtp_percent: number;
daily_spin_limit: number daily_spin_limit: number;
min_subscription_days_for_day_payment: number min_subscription_days_for_day_payment: number;
promo_prefix: string promo_prefix: string;
promo_validity_days: number promo_validity_days: number;
prizes: WheelPrizeAdmin[] prizes: WheelPrizeAdmin[];
created_at: string | null created_at: string | null;
updated_at: string | null updated_at: string | null;
} }
export interface WheelStatistics { export interface WheelStatistics {
total_spins: number total_spins: number;
total_revenue_kopeks: number total_revenue_kopeks: number;
total_payout_kopeks: number total_payout_kopeks: number;
actual_rtp_percent: number actual_rtp_percent: number;
configured_rtp_percent: number configured_rtp_percent: number;
spins_by_payment_type: Record<string, { count: number; total_kopeks: number }> spins_by_payment_type: Record<string, { count: number; total_kopeks: number }>;
prizes_distribution: Array<{ prizes_distribution: Array<{
prize_type: string prize_type: string;
display_name: string display_name: string;
count: number count: number;
total_kopeks: number total_kopeks: number;
}> }>;
top_wins: Array<{ top_wins: Array<{
user_id: number user_id: number;
username: string | null username: string | null;
prize_display_name: string prize_display_name: string;
prize_value_kopeks: number prize_value_kopeks: number;
created_at: string | null created_at: string | null;
}> }>;
period_from: string | null period_from: string | null;
period_to: string | null period_to: string | null;
} }
export interface AdminSpinItem { export interface AdminSpinItem {
id: number id: number;
user_id: number user_id: number;
username: string | null username: string | null;
payment_type: string payment_type: string;
payment_amount: number payment_amount: number;
payment_value_kopeks: number payment_value_kopeks: number;
prize_type: string prize_type: string;
prize_value: number prize_value: number;
prize_display_name: string prize_display_name: string;
prize_value_kopeks: number prize_value_kopeks: number;
is_applied: boolean is_applied: boolean;
created_at: string created_at: string;
} }
export interface AdminSpinsResponse { export interface AdminSpinsResponse {
items: AdminSpinItem[] items: AdminSpinItem[];
total: number total: number;
page: number page: number;
per_page: number per_page: number;
pages: number pages: number;
} }
// ==================== USER API ==================== // ==================== USER API ====================
@@ -184,101 +184,107 @@ export interface AdminSpinsResponse {
export const wheelApi = { export const wheelApi = {
// Get wheel config // Get wheel config
getConfig: async (): Promise<WheelConfig> => { getConfig: async (): Promise<WheelConfig> => {
const response = await apiClient.get<WheelConfig>('/cabinet/wheel/config') const response = await apiClient.get<WheelConfig>('/cabinet/wheel/config');
return response.data return response.data;
}, },
// Check spin availability // Check spin availability
checkAvailability: async (): Promise<SpinAvailability> => { checkAvailability: async (): Promise<SpinAvailability> => {
const response = await apiClient.get<SpinAvailability>('/cabinet/wheel/availability') const response = await apiClient.get<SpinAvailability>('/cabinet/wheel/availability');
return response.data return response.data;
}, },
// Spin the wheel // Spin the wheel
spin: async (paymentType: 'telegram_stars' | 'subscription_days'): Promise<SpinResult> => { spin: async (paymentType: 'telegram_stars' | 'subscription_days'): Promise<SpinResult> => {
const response = await apiClient.post<SpinResult>('/cabinet/wheel/spin', { const response = await apiClient.post<SpinResult>('/cabinet/wheel/spin', {
payment_type: paymentType, payment_type: paymentType,
}) });
return response.data return response.data;
}, },
// Get spin history // Get spin history
getHistory: async (page = 1, perPage = 20): Promise<SpinHistoryResponse> => { getHistory: async (page = 1, perPage = 20): Promise<SpinHistoryResponse> => {
const response = await apiClient.get<SpinHistoryResponse>('/cabinet/wheel/history', { const response = await apiClient.get<SpinHistoryResponse>('/cabinet/wheel/history', {
params: { page, per_page: perPage }, params: { page, per_page: perPage },
}) });
return response.data return response.data;
}, },
// Create Stars invoice for Mini App payment // Create Stars invoice for Mini App payment
createStarsInvoice: async (): Promise<StarsInvoiceResponse> => { createStarsInvoice: async (): Promise<StarsInvoiceResponse> => {
const response = await apiClient.post<StarsInvoiceResponse>('/cabinet/wheel/stars-invoice') const response = await apiClient.post<StarsInvoiceResponse>('/cabinet/wheel/stars-invoice');
return response.data return response.data;
}, },
} };
// ==================== ADMIN API ==================== // ==================== ADMIN API ====================
export const adminWheelApi = { export const adminWheelApi = {
// Get full config // Get full config
getConfig: async (): Promise<AdminWheelConfig> => { getConfig: async (): Promise<AdminWheelConfig> => {
const response = await apiClient.get<AdminWheelConfig>('/cabinet/admin/wheel/config') const response = await apiClient.get<AdminWheelConfig>('/cabinet/admin/wheel/config');
return response.data return response.data;
}, },
// Update config // Update config
updateConfig: async (data: Partial<AdminWheelConfig>): Promise<AdminWheelConfig> => { updateConfig: async (data: Partial<AdminWheelConfig>): Promise<AdminWheelConfig> => {
const response = await apiClient.put<AdminWheelConfig>('/cabinet/admin/wheel/config', data) const response = await apiClient.put<AdminWheelConfig>('/cabinet/admin/wheel/config', data);
return response.data return response.data;
}, },
// Get prizes // Get prizes
getPrizes: async (): Promise<WheelPrizeAdmin[]> => { getPrizes: async (): Promise<WheelPrizeAdmin[]> => {
const response = await apiClient.get<WheelPrizeAdmin[]>('/cabinet/admin/wheel/prizes') const response = await apiClient.get<WheelPrizeAdmin[]>('/cabinet/admin/wheel/prizes');
return response.data return response.data;
}, },
// Create prize // Create prize
createPrize: async (data: CreateWheelPrizeData): Promise<WheelPrizeAdmin> => { createPrize: async (data: CreateWheelPrizeData): Promise<WheelPrizeAdmin> => {
const response = await apiClient.post<WheelPrizeAdmin>('/cabinet/admin/wheel/prizes', data) const response = await apiClient.post<WheelPrizeAdmin>('/cabinet/admin/wheel/prizes', data);
return response.data return response.data;
}, },
// Update prize // Update prize
updatePrize: async (prizeId: number, data: Partial<WheelPrizeAdmin>): Promise<WheelPrizeAdmin> => { updatePrize: async (
const response = await apiClient.put<WheelPrizeAdmin>(`/cabinet/admin/wheel/prizes/${prizeId}`, data) prizeId: number,
return response.data data: Partial<WheelPrizeAdmin>,
): Promise<WheelPrizeAdmin> => {
const response = await apiClient.put<WheelPrizeAdmin>(
`/cabinet/admin/wheel/prizes/${prizeId}`,
data,
);
return response.data;
}, },
// Delete prize // Delete prize
deletePrize: async (prizeId: number): Promise<void> => { deletePrize: async (prizeId: number): Promise<void> => {
await apiClient.delete(`/cabinet/admin/wheel/prizes/${prizeId}`) await apiClient.delete(`/cabinet/admin/wheel/prizes/${prizeId}`);
}, },
// Reorder prizes // Reorder prizes
reorderPrizes: async (prizeIds: number[]): Promise<void> => { reorderPrizes: async (prizeIds: number[]): Promise<void> => {
await apiClient.post('/cabinet/admin/wheel/prizes/reorder', { prize_ids: prizeIds }) await apiClient.post('/cabinet/admin/wheel/prizes/reorder', { prize_ids: prizeIds });
}, },
// Get statistics // Get statistics
getStatistics: async (dateFrom?: string, dateTo?: string): Promise<WheelStatistics> => { getStatistics: async (dateFrom?: string, dateTo?: string): Promise<WheelStatistics> => {
const response = await apiClient.get<WheelStatistics>('/cabinet/admin/wheel/statistics', { const response = await apiClient.get<WheelStatistics>('/cabinet/admin/wheel/statistics', {
params: { date_from: dateFrom, date_to: dateTo }, params: { date_from: dateFrom, date_to: dateTo },
}) });
return response.data return response.data;
}, },
// Get all spins // Get all spins
getSpins: async (params?: { getSpins: async (params?: {
user_id?: number user_id?: number;
date_from?: string date_from?: string;
date_to?: string date_to?: string;
page?: number page?: number;
per_page?: number per_page?: number;
}): Promise<AdminSpinsResponse> => { }): Promise<AdminSpinsResponse> => {
const response = await apiClient.get<AdminSpinsResponse>('/cabinet/admin/wheel/spins', { const response = await apiClient.get<AdminSpinsResponse>('/cabinet/admin/wheel/spins', {
params, params,
}) });
return response.data return response.data;
}, },
} };

View File

@@ -1,43 +1,44 @@
import { useEffect, useState, memo } from 'react' import { useEffect, useState, memo } from 'react';
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import { brandingApi } from '../api/branding' import { brandingApi } from '../api/branding';
const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled' const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled';
// Detect if user prefers reduced motion // Detect if user prefers reduced motion
const isLowPerformance = (): boolean => { const isLowPerformance = (): boolean => {
// Only check for reduced motion preference - let animation run everywhere else // Only check for reduced motion preference - let animation run everywhere else
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
return prefersReducedMotion return prefersReducedMotion;
} };
// Get cached value from localStorage // Get cached value from localStorage
const getCachedAnimationEnabled = (): boolean | null => { const getCachedAnimationEnabled = (): boolean | null => {
try { try {
const cached = localStorage.getItem(ANIMATION_CACHE_KEY) const cached = localStorage.getItem(ANIMATION_CACHE_KEY);
if (cached !== null) { if (cached !== null) {
return cached === 'true' return cached === 'true';
} }
} catch { } catch {
// localStorage not available // localStorage not available
} }
return null return null;
} };
// Update cache in localStorage // Update cache in localStorage
// eslint-disable-next-line react-refresh/only-export-components
export const setCachedAnimationEnabled = (enabled: boolean) => { export const setCachedAnimationEnabled = (enabled: boolean) => {
try { try {
localStorage.setItem(ANIMATION_CACHE_KEY, String(enabled)) localStorage.setItem(ANIMATION_CACHE_KEY, String(enabled));
} catch { } catch {
// localStorage not available // localStorage not available
} }
} };
// Memoized background component to prevent re-renders // Memoized background component to prevent re-renders
const AnimatedBackground = memo(function AnimatedBackground() { const AnimatedBackground = memo(function AnimatedBackground() {
// Start with cached value (null means unknown yet) // Start with cached value (null means unknown yet)
const [isEnabled, setIsEnabled] = useState<boolean | null>(() => getCachedAnimationEnabled()) const [isEnabled, setIsEnabled] = useState<boolean | null>(() => getCachedAnimationEnabled());
const [isLowPerf] = useState(() => isLowPerformance()) const [isLowPerf] = useState(() => isLowPerformance());
const { data: animationSettings } = useQuery({ const { data: animationSettings } = useQuery({
queryKey: ['animation-enabled'], queryKey: ['animation-enabled'],
@@ -45,24 +46,24 @@ const AnimatedBackground = memo(function AnimatedBackground() {
staleTime: 1000 * 60 * 5, // 5 minutes - reduce API calls staleTime: 1000 * 60 * 5, // 5 minutes - reduce API calls
refetchOnWindowFocus: false, // Don't refetch on focus - save resources refetchOnWindowFocus: false, // Don't refetch on focus - save resources
retry: false, retry: false,
}) });
// Update state and cache when data arrives // Update state and cache when data arrives
useEffect(() => { useEffect(() => {
if (animationSettings !== undefined) { if (animationSettings !== undefined) {
const enabled = animationSettings.enabled const enabled = animationSettings.enabled;
setIsEnabled(enabled) setIsEnabled(enabled);
setCachedAnimationEnabled(enabled) setCachedAnimationEnabled(enabled);
} }
}, [animationSettings]) }, [animationSettings]);
// Don't render if disabled or on low-performance devices // Don't render if disabled or on low-performance devices
if (isEnabled !== true || isLowPerf) { if (isEnabled !== true || isLowPerf) {
return null return null;
} }
// Render only 2 blobs on mobile for better performance // Render only 2 blobs on mobile for better performance
const isMobile = window.innerWidth < 768 const isMobile = window.innerWidth < 768;
return ( return (
<div className="wave-bg-container"> <div className="wave-bg-container">
@@ -75,7 +76,7 @@ const AnimatedBackground = memo(function AnimatedBackground() {
</> </>
)} )}
</div> </div>
) );
}) });
export default AnimatedBackground export default AnimatedBackground;

View File

@@ -1,238 +1,286 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react' import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom';
interface ColorPickerProps { interface ColorPickerProps {
value: string value: string;
onChange: (color: string) => void onChange: (color: string) => void;
label: string label: string;
description?: string description?: string;
disabled?: boolean disabled?: boolean;
} }
// Check if running in Telegram WebApp // Check if running in Telegram WebApp
const isTelegramWebApp = (): boolean => { const isTelegramWebApp = (): boolean => {
return !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp return !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp;
} };
// Convert hex to RGB // Convert hex to RGB
const hexToRgb = (hex: string): { r: number; g: number; b: number } => { const hexToRgb = (hex: string): { r: number; g: number; b: number } => {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result return result
? { ? {
r: parseInt(result[1], 16), r: parseInt(result[1], 16),
g: parseInt(result[2], 16), g: parseInt(result[2], 16),
b: parseInt(result[3], 16), b: parseInt(result[3], 16),
} }
: { r: 0, g: 0, b: 0 } : { r: 0, g: 0, b: 0 };
} };
// Convert RGB to hex // Convert RGB to hex
const rgbToHex = (r: number, g: number, b: number): string => { const rgbToHex = (r: number, g: number, b: number): string => {
return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('') return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('');
} };
// Convert RGB to HSL // Convert RGB to HSL
const rgbToHsl = (r: number, g: number, b: number): { h: number; s: number; l: number } => { const rgbToHsl = (r: number, g: number, b: number): { h: number; s: number; l: number } => {
r /= 255; g /= 255; b /= 255 r /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b) g /= 255;
let h = 0, s = 0 b /= 255;
const l = (max + min) / 2 const max = Math.max(r, g, b),
min = Math.min(r, g, b);
let h = 0,
s = 0;
const l = (max + min) / 2;
if (max !== min) { if (max !== min) {
const d = max - min const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min) s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) { switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break case r:
case g: h = ((b - r) / d + 2) / 6; break h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
case b: h = ((r - g) / d + 4) / 6; break break;
case g:
h = ((b - r) / d + 2) / 6;
break;
case b:
h = ((r - g) / d + 4) / 6;
break;
} }
} }
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) } return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
} };
// Convert HSL to RGB // Convert HSL to RGB
const hslToRgb = (h: number, s: number, l: number): { r: number; g: number; b: number } => { const hslToRgb = (h: number, s: number, l: number): { r: number; g: number; b: number } => {
h /= 360; s /= 100; l /= 100 h /= 360;
let r, g, b s /= 100;
l /= 100;
let r, g, b;
if (s === 0) { if (s === 0) {
r = g = b = l r = g = b = l;
} else { } else {
const hue2rgb = (p: number, q: number, t: number) => { const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1 if (t < 0) t += 1;
if (t > 1) t -= 1 if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1/2) return q if (t < 1 / 2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6 if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
} }
const q = l < 0.5 ? l * (1 + s) : l + s - l * s return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) };
const p = 2 * l - q };
r = hue2rgb(p, q, h + 1/3)
g = hue2rgb(p, q, h)
b = hue2rgb(p, q, h - 1/3)
}
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
}
const PRESET_COLORS = [ const PRESET_COLORS = [
'#3b82f6', '#ef4444', '#22c55e', '#f59e0b', '#3b82f6',
'#8b5cf6', '#ec4899', '#06b6d4', '#14b8a6', '#ef4444',
'#84cc16', '#f97316', '#6366f1', '#a855f7', '#22c55e',
] '#f59e0b',
'#8b5cf6',
'#ec4899',
'#06b6d4',
'#14b8a6',
'#84cc16',
'#f97316',
'#6366f1',
'#a855f7',
];
export function ColorPicker({ value, onChange, label, description, disabled }: ColorPickerProps) { export function ColorPicker({ value, onChange, label, description, disabled }: ColorPickerProps) {
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false);
const [localValue, setLocalValue] = useState(value) const [localValue, setLocalValue] = useState(value);
const [hsl, setHsl] = useState(() => { const [hsl, setHsl] = useState(() => {
const rgb = hexToRgb(value) const rgb = hexToRgb(value);
return rgbToHsl(rgb.r, rgb.g, rgb.b) return rgbToHsl(rgb.r, rgb.g, rgb.b);
}) });
const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number; openUp: boolean }>({ top: 0, left: 0, openUp: false }) const [pickerPosition, setPickerPosition] = useState<{
top: number;
left: number;
openUp: boolean;
}>({ top: 0, left: 0, openUp: false });
const buttonRef = useRef<HTMLButtonElement>(null) const buttonRef = useRef<HTMLButtonElement>(null);
const pickerRef = useRef<HTMLDivElement>(null) const pickerRef = useRef<HTMLDivElement>(null);
const colorInputRef = useRef<HTMLInputElement>(null) const colorInputRef = useRef<HTMLInputElement>(null);
const isTelegram = useMemo(() => isTelegramWebApp(), []) const isTelegram = useMemo(() => isTelegramWebApp(), []);
// Sync with external value // Sync with external value
useEffect(() => { useEffect(() => {
setLocalValue(value) setLocalValue(value);
const rgb = hexToRgb(value) const rgb = hexToRgb(value);
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
}, [value]) }, [value]);
// Calculate picker position // Calculate picker position
const updatePosition = useCallback(() => { const updatePosition = useCallback(() => {
if (!buttonRef.current) return if (!buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect() const rect = buttonRef.current.getBoundingClientRect();
const pickerHeight = 320 const pickerHeight = 320;
const pickerWidth = 280 const pickerWidth = 280;
const padding = 12 const padding = 12;
// Check if there's space below // Check if there's space below
const spaceBelow = window.innerHeight - rect.bottom const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top const spaceAbove = rect.top;
const openUp = spaceBelow < pickerHeight + padding && spaceAbove > spaceBelow const openUp = spaceBelow < pickerHeight + padding && spaceAbove > spaceBelow;
// Calculate left position (ensure it stays in viewport) // Calculate left position (ensure it stays in viewport)
let left = rect.left let left = rect.left;
if (left + pickerWidth > window.innerWidth - padding) { if (left + pickerWidth > window.innerWidth - padding) {
left = window.innerWidth - pickerWidth - padding left = window.innerWidth - pickerWidth - padding;
} }
if (left < padding) left = padding if (left < padding) left = padding;
setPickerPosition({ setPickerPosition({
top: openUp ? rect.top - pickerHeight - 8 : rect.bottom + 8, top: openUp ? rect.top - pickerHeight - 8 : rect.bottom + 8,
left, left,
openUp openUp,
}) });
}, []) }, []);
// Open picker // Open picker
const handleOpen = useCallback(() => { const handleOpen = useCallback(() => {
if (disabled) return if (disabled) return;
updatePosition() updatePosition();
setIsOpen(true) setIsOpen(true);
}, [disabled, updatePosition]) }, [disabled, updatePosition]);
// Close picker // Close picker
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
setIsOpen(false) setIsOpen(false);
}, []) }, []);
// Handle click outside // Handle click outside
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => { const handleClickOutside = (e: MouseEvent) => {
if ( if (
pickerRef.current && !pickerRef.current.contains(e.target as Node) && pickerRef.current &&
buttonRef.current && !buttonRef.current.contains(e.target as Node) !pickerRef.current.contains(e.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(e.target as Node)
) { ) {
handleClose() handleClose();
}
} }
};
const handleScroll = () => handleClose() const handleScroll = () => handleClose();
const handleResize = () => updatePosition() const handleResize = () => updatePosition();
document.addEventListener('mousedown', handleClickOutside) document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchstart', handleClickOutside as EventListener) document.addEventListener('touchstart', handleClickOutside as EventListener);
window.addEventListener('scroll', handleScroll, true) window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', handleResize) window.addEventListener('resize', handleResize);
return () => { return () => {
document.removeEventListener('mousedown', handleClickOutside) document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchstart', handleClickOutside as EventListener) document.removeEventListener('touchstart', handleClickOutside as EventListener);
window.removeEventListener('scroll', handleScroll, true) window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize);
} };
}, [isOpen, handleClose, updatePosition]) }, [isOpen, handleClose, updatePosition]);
// Update color from HSL // Update color from HSL
const updateColorFromHsl = useCallback((newHsl: { h: number; s: number; l: number }) => { const updateColorFromHsl = useCallback(
const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l) (newHsl: { h: number; s: number; l: number }) => {
const hex = rgbToHex(rgb.r, rgb.g, rgb.b) const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l);
setHsl(newHsl) const hex = rgbToHex(rgb.r, rgb.g, rgb.b);
setLocalValue(hex) setHsl(newHsl);
onChange(hex) setLocalValue(hex);
}, [onChange]) onChange(hex);
},
[onChange],
);
// Handle hue change // Handle hue change
const handleHueChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleHueChange = useCallback(
updateColorFromHsl({ ...hsl, h: parseInt(e.target.value) }) (e: React.ChangeEvent<HTMLInputElement>) => {
}, [hsl, updateColorFromHsl]) updateColorFromHsl({ ...hsl, h: parseInt(e.target.value) });
},
[hsl, updateColorFromHsl],
);
// Handle saturation change // Handle saturation change
const handleSaturationChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleSaturationChange = useCallback(
updateColorFromHsl({ ...hsl, s: parseInt(e.target.value) }) (e: React.ChangeEvent<HTMLInputElement>) => {
}, [hsl, updateColorFromHsl]) updateColorFromHsl({ ...hsl, s: parseInt(e.target.value) });
},
[hsl, updateColorFromHsl],
);
// Handle lightness change // Handle lightness change
const handleLightnessChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleLightnessChange = useCallback(
updateColorFromHsl({ ...hsl, l: parseInt(e.target.value) }) (e: React.ChangeEvent<HTMLInputElement>) => {
}, [hsl, updateColorFromHsl]) updateColorFromHsl({ ...hsl, l: parseInt(e.target.value) });
},
[hsl, updateColorFromHsl],
);
// Handle native color input // Handle native color input
const handleColorInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleColorInputChange = useCallback(
const newColor = e.target.value (e: React.ChangeEvent<HTMLInputElement>) => {
setLocalValue(newColor) const newColor = e.target.value;
const rgb = hexToRgb(newColor) setLocalValue(newColor);
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) const rgb = hexToRgb(newColor);
onChange(newColor) setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
}, [onChange]) onChange(newColor);
},
[onChange],
);
// Handle hex input // Handle hex input
const handleHexInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleHexInputChange = useCallback(
let newValue = e.target.value (e: React.ChangeEvent<HTMLInputElement>) => {
let newValue = e.target.value;
if (newValue && !newValue.startsWith('#')) { if (newValue && !newValue.startsWith('#')) {
newValue = '#' + newValue newValue = '#' + newValue;
} }
if (newValue === '' || newValue.match(/^#[0-9A-Fa-f]{0,6}$/)) { if (newValue === '' || newValue.match(/^#[0-9A-Fa-f]{0,6}$/)) {
setLocalValue(newValue) setLocalValue(newValue);
if (newValue.match(/^#[0-9A-Fa-f]{6}$/)) { if (newValue.match(/^#[0-9A-Fa-f]{6}$/)) {
const rgb = hexToRgb(newValue) const rgb = hexToRgb(newValue);
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
onChange(newValue) onChange(newValue);
} }
} }
}, [onChange]) },
[onChange],
);
// Handle preset click // Handle preset click
const handlePresetClick = useCallback((color: string) => { const handlePresetClick = useCallback(
setLocalValue(color) (color: string) => {
const rgb = hexToRgb(color) setLocalValue(color);
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b)) const rgb = hexToRgb(color);
onChange(color) setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
handleClose() onChange(color);
}, [onChange, handleClose]) handleClose();
},
[onChange, handleClose],
);
// Picker content // Picker content
const pickerContent = isOpen ? ( const pickerContent = isOpen ? (
<div <div
ref={pickerRef} ref={pickerRef}
className="fixed z-[9999] w-[280px] bg-dark-900 rounded-2xl border border-dark-700 shadow-2xl overflow-hidden" className="fixed z-[9999] w-[280px] overflow-hidden rounded-2xl border border-dark-700 bg-dark-900 shadow-2xl"
style={{ style={{
top: pickerPosition.top, top: pickerPosition.top,
left: pickerPosition.left, left: pickerPosition.left,
@@ -240,13 +288,10 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Color preview header */} {/* Color preview header */}
<div <div className="h-16 w-full" style={{ backgroundColor: localValue || '#000000' }} />
className="h-16 w-full"
style={{ backgroundColor: localValue || '#000000' }}
/>
{/* Controls */} {/* Controls */}
<div className="p-4 space-y-4"> <div className="space-y-4 p-4">
{/* Hue slider */} {/* Hue slider */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -259,9 +304,10 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
max="360" max="360"
value={hsl.h} value={hsl.h}
onChange={handleHueChange} onChange={handleHueChange}
className="w-full h-3 rounded-full appearance-none cursor-pointer" className="h-3 w-full cursor-pointer appearance-none rounded-full"
style={{ style={{
background: 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)', background:
'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)',
}} }}
/> />
</div> </div>
@@ -278,7 +324,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
max="100" max="100"
value={hsl.s} value={hsl.s}
onChange={handleSaturationChange} onChange={handleSaturationChange}
className="w-full h-3 rounded-full appearance-none cursor-pointer" className="h-3 w-full cursor-pointer appearance-none rounded-full"
style={{ style={{
background: `linear-gradient(to right, hsl(${hsl.h}, 0%, ${hsl.l}%), hsl(${hsl.h}, 100%, ${hsl.l}%))`, background: `linear-gradient(to right, hsl(${hsl.h}, 0%, ${hsl.l}%), hsl(${hsl.h}, 100%, ${hsl.l}%))`,
}} }}
@@ -297,7 +343,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
max="100" max="100"
value={hsl.l} value={hsl.l}
onChange={handleLightnessChange} onChange={handleLightnessChange}
className="w-full h-3 rounded-full appearance-none cursor-pointer" className="h-3 w-full cursor-pointer appearance-none rounded-full"
style={{ style={{
background: `linear-gradient(to right, #000000, hsl(${hsl.h}, ${hsl.s}%, 50%), #ffffff)`, background: `linear-gradient(to right, #000000, hsl(${hsl.h}, ${hsl.s}%, 50%), #ffffff)`,
}} }}
@@ -311,21 +357,21 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
type="text" type="text"
value={localValue} value={localValue}
onChange={handleHexInputChange} onChange={handleHexInputChange}
className="flex-1 h-9 px-3 text-sm font-mono uppercase bg-dark-800 border border-dark-700 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" className="h-9 flex-1 rounded-lg border border-dark-700 bg-dark-800 px-3 font-mono text-sm uppercase text-dark-100 focus:border-accent-500 focus:outline-none"
placeholder="#000000" placeholder="#000000"
maxLength={7} maxLength={7}
/> />
</div> </div>
{/* Presets */} {/* Presets */}
<div className="pt-2 border-t border-dark-700"> <div className="border-t border-dark-700 pt-2">
<span className="text-xs font-medium text-dark-400 block mb-2">Presets</span> <span className="mb-2 block text-xs font-medium text-dark-400">Presets</span>
<div className="grid grid-cols-6 gap-1.5"> <div className="grid grid-cols-6 gap-1.5">
{PRESET_COLORS.map((preset) => ( {PRESET_COLORS.map((preset) => (
<button <button
key={preset} key={preset}
onClick={() => handlePresetClick(preset)} onClick={() => handlePresetClick(preset)}
className={`w-full aspect-square rounded-lg transition-transform hover:scale-110 active:scale-95 ${ className={`aspect-square w-full rounded-lg transition-transform hover:scale-110 active:scale-95 ${
localValue.toLowerCase() === preset.toLowerCase() localValue.toLowerCase() === preset.toLowerCase()
? 'ring-2 ring-white ring-offset-2 ring-offset-dark-900' ? 'ring-2 ring-white ring-offset-2 ring-offset-dark-900'
: '' : ''
@@ -338,12 +384,12 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
</div> </div>
</div> </div>
</div> </div>
) : null ) : null;
return ( return (
<div className="relative min-w-0 overflow-hidden"> <div className="relative min-w-0 overflow-hidden">
<label className="block text-sm font-medium text-dark-200 mb-1 truncate">{label}</label> <label className="mb-1 block truncate text-sm font-medium text-dark-200">{label}</label>
{description && <p className="text-xs text-dark-500 mb-2 truncate">{description}</p>} {description && <p className="mb-2 truncate text-xs text-dark-500">{description}</p>}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Color preview button */} {/* Color preview button */}
@@ -352,7 +398,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
type="button" type="button"
onClick={handleOpen} onClick={handleOpen}
disabled={disabled} disabled={disabled}
className="w-10 h-10 rounded-xl border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0" className="h-10 w-10 flex-shrink-0 rounded-xl border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
style={{ backgroundColor: localValue || '#000000' }} style={{ backgroundColor: localValue || '#000000' }}
title={localValue} title={localValue}
/> />
@@ -363,7 +409,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
value={localValue} value={localValue}
onChange={handleHexInputChange} onChange={handleHexInputChange}
disabled={disabled} disabled={disabled}
className="flex-1 min-w-0 h-10 px-2 font-mono text-sm uppercase bg-dark-800 border border-dark-700 rounded-xl text-dark-100 focus:outline-none focus:border-accent-500 disabled:opacity-50" className="h-10 min-w-0 flex-1 rounded-xl border border-dark-700 bg-dark-800 px-2 font-mono text-sm uppercase text-dark-100 focus:border-accent-500 focus:outline-none disabled:opacity-50"
placeholder="#000000" placeholder="#000000"
maxLength={7} maxLength={7}
/> />
@@ -383,11 +429,21 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
type="button" type="button"
onClick={() => colorInputRef.current?.click()} onClick={() => colorInputRef.current?.click()}
disabled={disabled} disabled={disabled}
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50 flex-shrink-0" className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
title="System color picker" title="System color picker"
> >
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg
<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" /> className="h-5 w-5"
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> </svg>
</button> </button>
</> </>
@@ -397,5 +453,5 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
{/* Render picker in portal */} {/* Render picker in portal */}
{typeof document !== 'undefined' && createPortal(pickerContent, document.body)} {typeof document !== 'undefined' && createPortal(pickerContent, document.body)}
</div> </div>
) );
} }

View File

@@ -1,465 +1,537 @@
import { useState, useMemo, useEffect, useCallback, useRef } from 'react' import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import { subscriptionApi } from '../api/subscription' import { subscriptionApi } from '../api/subscription';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp' import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import type { AppInfo, AppConfig, LocalizedText } from '../types' import type { AppInfo, AppConfig, LocalizedText } from '../types';
interface ConnectionModalProps { interface ConnectionModalProps {
onClose: () => void onClose: () => void;
} }
// Icons // Icons
const CloseIcon = () => ( const CloseIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
) );
const CopyIcon = () => ( const CopyIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" 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" /> <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> </svg>
) );
const CheckIcon = () => ( const CheckIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
) );
const LinkIcon = () => ( const LinkIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg> </svg>
) );
const ChevronIcon = () => ( const ChevronIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg> </svg>
) );
const BackIcon = () => ( const BackIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg> </svg>
) );
// App icons // App icons
const HappIcon = () => ( const HappIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="currentColor"> <svg className="h-6 w-6" viewBox="0 0 50 50" fill="currentColor">
<path d="M22.3264 3H12.3611L9.44444 20.1525L21.3542 8.22034L22.3264 3Z"/> <path d="M22.3264 3H12.3611L9.44444 20.1525L21.3542 8.22034L22.3264 3Z" />
<path d="M10.9028 20.1525L22.8125 8.22034L20.8681 21.1469H28.4028L27.9167 21.6441L20.8681 28.8531H19.4097V30.5932L7.5 42.5254L10.9028 20.1525Z"/> <path d="M10.9028 20.1525L22.8125 8.22034L20.8681 21.1469H28.4028L27.9167 21.6441L20.8681 28.8531H19.4097V30.5932L7.5 42.5254L10.9028 20.1525Z" />
<path d="M41.0417 8.22034L28.8889 20.1525L31.684 3H41.7708L41.0417 8.22034Z"/> <path d="M41.0417 8.22034L28.8889 20.1525L31.684 3H41.7708L41.0417 8.22034Z" />
<path d="M30.3472 20.1525L42.5 8.22034L38.6111 30.3446L26.9444 42.5254L29.0104 28.8531H22.3264L29.6181 21.1469H30.3472V20.1525Z"/> <path d="M30.3472 20.1525L42.5 8.22034L38.6111 30.3446L26.9444 42.5254L29.0104 28.8531H22.3264L29.6181 21.1469H30.3472V20.1525Z" />
<path d="M40.0694 30.3446L28.4028 42.5254L27.9167 47H37.8819L40.0694 30.3446Z"/> <path d="M40.0694 30.3446L28.4028 42.5254L27.9167 47H37.8819L40.0694 30.3446Z" />
<path d="M18.6806 47H8.47222L8.95833 42.5254L20.8681 30.5932L18.6806 47Z"/> <path d="M18.6806 47H8.47222L8.95833 42.5254L20.8681 30.5932L18.6806 47Z" />
</svg> </svg>
) );
const ClashMetaIcon = () => ( const ClashMetaIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="currentColor"> <svg className="h-6 w-6" viewBox="0 0 50 50" fill="currentColor">
<path fillRule="evenodd" clipRule="evenodd" d="M4.99239 5.21742C4.0328 5.32232 3.19446 5.43999 3.12928 5.47886C2.94374 5.58955 2.96432 33.4961 3.14997 33.6449C3.2266 33.7062 4.44146 34.002 5.84976 34.3022C7.94234 34.7483 8.60505 34.8481 9.47521 34.8481C10.3607 34.8481 10.5706 34.8154 10.7219 34.6541C10.8859 34.479 10.9066 33.7222 10.9338 26.9143L10.9638 19.3685L11.2759 19.1094C11.6656 18.7859 12.1188 18.7789 12.5285 19.0899C12.702 19.2216 14.319 20.624 16.1219 22.2061C17.9247 23.7883 19.5136 25.1104 19.6527 25.144C19.7919 25.1777 20.3714 25.105 20.9406 24.9825C22.6144 24.6221 23.3346 24.5424 24.9233 24.5421C26.4082 24.5417 27.8618 24.71 29.2219 25.0398C29.6074 25.1333 30.0523 25.1784 30.2107 25.1399C30.369 25.1016 31.1086 24.5336 31.8543 23.8777C33.3462 22.5653 33.6461 22.3017 35.4359 20.7293C36.1082 20.1388 36.6831 19.6313 36.7137 19.6017C37.5681 18.7742 38.0857 18.6551 38.6132 19.1642L38.9383 19.478V34.5138L39.1856 34.6809C39.6343 34.9843 41.2534 34.9022 43.195 34.4775C44.1268 34.2737 45.2896 34.0291 45.779 33.9339C46.2927 33.8341 46.7276 33.687 46.8079 33.5861C47.0172 33.3228 47.0109 5.87708 46.8014 5.6005C46.6822 5.4431 46.2851 5.37063 44.605 5.1996C43.477 5.08482 42.2972 5.00505 41.983 5.02223L41.4121 5.05368L35.4898 10.261C27.3144 17.4495 27.7989 17.0418 27.5372 16.9533C27.4148 16.912 26.1045 16.8746 24.6253 16.8702C22.0674 16.8626 21.9233 16.8513 21.6777 16.6396C21.0693 16.115 17.2912 12.8028 14.5726 10.4108C12.9548 8.98729 10.9055 7.18761 10.0186 6.41134L8.40584 5L7.5715 5.01331C7.11256 5.02072 5.95198 5.11252 4.99239 5.21742Z"/> <path
fillRule="evenodd"
clipRule="evenodd"
d="M4.99239 5.21742C4.0328 5.32232 3.19446 5.43999 3.12928 5.47886C2.94374 5.58955 2.96432 33.4961 3.14997 33.6449C3.2266 33.7062 4.44146 34.002 5.84976 34.3022C7.94234 34.7483 8.60505 34.8481 9.47521 34.8481C10.3607 34.8481 10.5706 34.8154 10.7219 34.6541C10.8859 34.479 10.9066 33.7222 10.9338 26.9143L10.9638 19.3685L11.2759 19.1094C11.6656 18.7859 12.1188 18.7789 12.5285 19.0899C12.702 19.2216 14.319 20.624 16.1219 22.2061C17.9247 23.7883 19.5136 25.1104 19.6527 25.144C19.7919 25.1777 20.3714 25.105 20.9406 24.9825C22.6144 24.6221 23.3346 24.5424 24.9233 24.5421C26.4082 24.5417 27.8618 24.71 29.2219 25.0398C29.6074 25.1333 30.0523 25.1784 30.2107 25.1399C30.369 25.1016 31.1086 24.5336 31.8543 23.8777C33.3462 22.5653 33.6461 22.3017 35.4359 20.7293C36.1082 20.1388 36.6831 19.6313 36.7137 19.6017C37.5681 18.7742 38.0857 18.6551 38.6132 19.1642L38.9383 19.478V34.5138L39.1856 34.6809C39.6343 34.9843 41.2534 34.9022 43.195 34.4775C44.1268 34.2737 45.2896 34.0291 45.779 33.9339C46.2927 33.8341 46.7276 33.687 46.8079 33.5861C47.0172 33.3228 47.0109 5.87708 46.8014 5.6005C46.6822 5.4431 46.2851 5.37063 44.605 5.1996C43.477 5.08482 42.2972 5.00505 41.983 5.02223L41.4121 5.05368L35.4898 10.261C27.3144 17.4495 27.7989 17.0418 27.5372 16.9533C27.4148 16.912 26.1045 16.8746 24.6253 16.8702C22.0674 16.8626 21.9233 16.8513 21.6777 16.6396C21.0693 16.115 17.2912 12.8028 14.5726 10.4108C12.9548 8.98729 10.9055 7.18761 10.0186 6.41134L8.40584 5L7.5715 5.01331C7.11256 5.02072 5.95198 5.11252 4.99239 5.21742Z"
/>
</svg> </svg>
) );
const ShadowrocketIcon = () => ( const ShadowrocketIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"> <svg
<path d="M21.2394 36.832L16.5386 39.568C16.5386 39.568 13.7182 36.832 11.8379 33.184C9.95756 29.536 16.5386 23.152 16.5386 23.152M21.2394 36.832H28.7606M21.2394 36.832C21.2394 36.832 15.5985 24.064 17.4788 16.768C19.3591 9.472 25 4 25 4C25 4 30.6409 9.472 32.5212 16.768C34.4015 24.064 28.7606 36.832 28.7606 36.832M28.7606 36.832L33.4614 39.568C33.4614 39.568 36.2818 36.832 38.1621 33.184C40.0424 29.536 33.4614 23.152 33.4614 23.152M25 46L26.8803 40.528H23.1197L25 46ZM25.9402 17.68C26.4594 18.1837 26.4594 19.0003 25.9402 19.504C25.4209 20.0077 24.5791 20.0077 24.0598 19.504C23.5406 19.0003 23.5406 18.1837 24.0598 17.68C24.5791 17.1763 25.4209 17.1763 25.9402 17.68Z"/> className="h-6 w-6"
viewBox="0 0 50 50"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21.2394 36.832L16.5386 39.568C16.5386 39.568 13.7182 36.832 11.8379 33.184C9.95756 29.536 16.5386 23.152 16.5386 23.152M21.2394 36.832H28.7606M21.2394 36.832C21.2394 36.832 15.5985 24.064 17.4788 16.768C19.3591 9.472 25 4 25 4C25 4 30.6409 9.472 32.5212 16.768C34.4015 24.064 28.7606 36.832 28.7606 36.832M28.7606 36.832L33.4614 39.568C33.4614 39.568 36.2818 36.832 38.1621 33.184C40.0424 29.536 33.4614 23.152 33.4614 23.152M25 46L26.8803 40.528H23.1197L25 46ZM25.9402 17.68C26.4594 18.1837 26.4594 19.0003 25.9402 19.504C25.4209 20.0077 24.5791 20.0077 24.0598 19.504C23.5406 19.0003 23.5406 18.1837 24.0598 17.68C24.5791 17.1763 25.4209 17.1763 25.9402 17.68Z" />
</svg> </svg>
) );
const StreisandIcon = () => ( const StreisandIcon = () => (
<svg className="w-6 h-6" viewBox="0 0 50 50" fill="currentColor"> <svg className="h-6 w-6" viewBox="0 0 50 50" fill="currentColor">
<path d="M25 46L24.2602 47.0076C24.7027 47.3325 25.3054 47.3306 25.7459 47.0031L25 46ZM6.14773 32.1591H4.89773C4.89773 32.557 5.0872 32.9312 5.40797 33.1667L6.14773 32.1591ZM43.6136 32.1591L44.3595 33.1622C44.6767 32.9263 44.8636 32.5543 44.8636 32.1591H43.6136ZM6.14773 19.9886L5.42485 18.9689C5.09421 19.2032 4.89773 19.5834 4.89773 19.9886H6.14773ZM25 6.625L25.729 5.6096L25.0046 5.08952L24.2771 5.60522L25 6.625ZM43.6136 19.9886H44.8636C44.8636 19.586 44.6697 19.208 44.3426 18.9732L43.6136 19.9886ZM25 46L25.7398 44.9924L6.88748 31.1515L6.14773 32.1591L5.40797 33.1667L24.2602 47.0076L25 46ZM43.6136 32.1591L42.8678 31.156L24.2541 44.9969L25 46L25.7459 47.0031L44.3595 33.1622L43.6136 32.1591Z"/> <path d="M25 46L24.2602 47.0076C24.7027 47.3325 25.3054 47.3306 25.7459 47.0031L25 46ZM6.14773 32.1591H4.89773C4.89773 32.557 5.0872 32.9312 5.40797 33.1667L6.14773 32.1591ZM43.6136 32.1591L44.3595 33.1622C44.6767 32.9263 44.8636 32.5543 44.8636 32.1591H43.6136ZM6.14773 19.9886L5.42485 18.9689C5.09421 19.2032 4.89773 19.5834 4.89773 19.9886H6.14773ZM25 6.625L25.729 5.6096L25.0046 5.08952L24.2771 5.60522L25 6.625ZM43.6136 19.9886H44.8636C44.8636 19.586 44.6697 19.208 44.3426 18.9732L43.6136 19.9886ZM25 46L25.7398 44.9924L6.88748 31.1515L6.14773 32.1591L5.40797 33.1667L24.2602 47.0076L25 46ZM43.6136 32.1591L42.8678 31.156L24.2541 44.9969L25 46L25.7459 47.0031L44.3595 33.1622L43.6136 32.1591Z" />
</svg> </svg>
) );
const getAppIcon = (appName: string): React.ReactNode => { const getAppIcon = (appName: string): React.ReactNode => {
const name = appName.toLowerCase() const name = appName.toLowerCase();
if (name.includes('happ')) return <HappIcon /> if (name.includes('happ')) return <HappIcon />;
if (name.includes('shadowrocket') || name.includes('rocket')) return <ShadowrocketIcon /> if (name.includes('shadowrocket') || name.includes('rocket')) return <ShadowrocketIcon />;
if (name.includes('streisand')) return <StreisandIcon /> if (name.includes('streisand')) return <StreisandIcon />;
if (name.includes('clash') || name.includes('meta') || name.includes('verge')) return <ClashMetaIcon /> if (name.includes('clash') || name.includes('meta') || name.includes('verge'))
return <span className="text-lg">📦</span> return <ClashMetaIcon />;
} return <span className="text-lg">📦</span>;
};
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'] const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'];
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'] // eslint-disable-next-line no-script-url -- listing dangerous schemes to block them
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:'];
function isValidExternalUrl(url: string | undefined): boolean { function isValidExternalUrl(url: string | undefined): boolean {
if (!url) return false if (!url) return false;
const lowerUrl = url.toLowerCase().trim() const lowerUrl = url.toLowerCase().trim();
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false if (dangerousSchemes.some((scheme) => lowerUrl.startsWith(scheme))) return false;
return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://') return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://');
} }
function isValidDeepLink(url: string | undefined): boolean { function isValidDeepLink(url: string | undefined): boolean {
if (!url) return false if (!url) return false;
const lowerUrl = url.toLowerCase().trim() const lowerUrl = url.toLowerCase().trim();
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false if (dangerousSchemes.some((scheme) => lowerUrl.startsWith(scheme))) return false;
return lowerUrl.includes('://') return lowerUrl.includes('://');
} }
function detectPlatform(): string | null { function detectPlatform(): string | null {
if (typeof window === 'undefined' || !navigator?.userAgent) return null if (typeof window === 'undefined' || !navigator?.userAgent) return null;
const ua = navigator.userAgent.toLowerCase() const ua = navigator.userAgent.toLowerCase();
if (/iphone|ipad|ipod/.test(ua)) return 'ios' if (/iphone|ipad|ipod/.test(ua)) return 'ios';
if (/android/.test(ua)) return /tv|television/.test(ua) ? 'androidTV' : 'android' if (/android/.test(ua)) return /tv|television/.test(ua) ? 'androidTV' : 'android';
if (/macintosh|mac os x/.test(ua)) return 'macos' if (/macintosh|mac os x/.test(ua)) return 'macos';
if (/windows/.test(ua)) return 'windows' if (/windows/.test(ua)) return 'windows';
if (/linux/.test(ua)) return 'linux' if (/linux/.test(ua)) return 'linux';
return null return null;
} }
function useIsMobile() { function useIsMobile() {
const [isMobile, setIsMobile] = useState(() => { const [isMobile, setIsMobile] = useState(() => {
if (typeof window === 'undefined') return false if (typeof window === 'undefined') return false;
return window.innerWidth < 768 return window.innerWidth < 768;
}) });
useEffect(() => { useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768) const check = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', check) window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check) return () => window.removeEventListener('resize', check);
}, []) }, []);
return isMobile return isMobile;
} }
export default function ConnectionModal({ onClose }: ConnectionModalProps) { export default function ConnectionModal({ onClose }: ConnectionModalProps) {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation();
const [selectedApp, setSelectedApp] = useState<AppInfo | null>(null) const [selectedApp, setSelectedApp] = useState<AppInfo | null>(null);
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false);
const [showAppSelector, setShowAppSelector] = useState(false) const [showAppSelector, setShowAppSelector] = useState(false);
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null) const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp() const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset, webApp } =
const isMobileScreen = useIsMobile() useTelegramWebApp();
const isMobile = isMobileScreen const isMobileScreen = useIsMobile();
const scrollContainerRef = useRef<HTMLDivElement>(null) const isMobile = isMobileScreen;
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Ref для хранения актуального обработчика BackButton (фикс мигания) // Ref для хранения актуального обработчика BackButton (фикс мигания)
const backButtonHandlerRef = useRef<() => void>(() => {}) const backButtonHandlerRef = useRef<() => void>(() => {});
// Prevent scroll events from bubbling to parent/Telegram // Prevent scroll events from bubbling to parent/Telegram
const handleScrollContainerWheel = useCallback((e: React.WheelEvent) => { const handleScrollContainerWheel = useCallback((e: React.WheelEvent) => {
const container = e.currentTarget const container = e.currentTarget;
const { scrollTop, scrollHeight, clientHeight } = container const { scrollTop, scrollHeight, clientHeight } = container;
const isAtTop = scrollTop === 0 const isAtTop = scrollTop === 0;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1 const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1;
// Prevent scroll propagation when not at boundaries, or when scrolling away from boundary // Prevent scroll propagation when not at boundaries, or when scrolling away from boundary
if ((!isAtTop && !isAtBottom) || if ((!isAtTop && !isAtBottom) || (isAtTop && e.deltaY > 0) || (isAtBottom && e.deltaY < 0)) {
(isAtTop && e.deltaY > 0) || e.stopPropagation();
(isAtBottom && e.deltaY < 0)) {
e.stopPropagation()
} }
}, []) }, []);
const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0 const safeBottom = isTelegramWebApp
const safeTop = isTelegramWebApp ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0) : 0 ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom)
: 0;
const safeTop = isTelegramWebApp
? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0)
: 0;
const { data: appConfig, isLoading, error } = useQuery<AppConfig>({ const {
data: appConfig,
isLoading,
error,
} = useQuery<AppConfig>({
queryKey: ['appConfig'], queryKey: ['appConfig'],
queryFn: () => subscriptionApi.getAppConfig(), queryFn: () => subscriptionApi.getAppConfig(),
}) });
const detectedPlatform = useMemo(() => detectPlatform(), []) const detectedPlatform = useMemo(() => detectPlatform(), []);
useEffect(() => { useEffect(() => {
if (!appConfig?.platforms || selectedApp) return if (!appConfig?.platforms || selectedApp) return;
let platform = detectedPlatform let platform = detectedPlatform;
if (!platform || !appConfig.platforms[platform]?.length) { if (!platform || !appConfig.platforms[platform]?.length) {
platform = platformOrder.find(p => appConfig.platforms[p]?.length > 0) || null platform = platformOrder.find((p) => appConfig.platforms[p]?.length > 0) || null;
} }
if (!platform || !appConfig.platforms[platform]?.length) return if (!platform || !appConfig.platforms[platform]?.length) return;
const apps = appConfig.platforms[platform] const apps = appConfig.platforms[platform];
const app = apps.find(a => a.isFeatured) || apps[0] const app = apps.find((a) => a.isFeatured) || apps[0];
if (app) setSelectedApp(app) if (app) setSelectedApp(app);
}, [appConfig, detectedPlatform, selectedApp]) }, [appConfig, detectedPlatform, selectedApp]);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
onClose() onClose();
}, [onClose]) }, [onClose]);
const handleBack = useCallback(() => { const handleBack = useCallback(() => {
if (selectedPlatform) { if (selectedPlatform) {
setSelectedPlatform(null) setSelectedPlatform(null);
} else { } else {
setShowAppSelector(false) setShowAppSelector(false);
} }
}, [selectedPlatform]) }, [selectedPlatform]);
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault();
if (showAppSelector) handleBack() if (showAppSelector) handleBack();
else handleClose() else handleClose();
} }
} };
document.addEventListener('keydown', handleKeyDown) document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleClose, handleBack, showAppSelector]) }, [handleClose, handleBack, showAppSelector]);
// Обновляем ref при изменении логики (без перезапуска эффекта BackButton) // Обновляем ref при изменении логики (без перезапуска эффекта BackButton)
useEffect(() => { useEffect(() => {
backButtonHandlerRef.current = showAppSelector ? handleBack : handleClose backButtonHandlerRef.current = showAppSelector ? handleBack : handleClose;
}, [showAppSelector, handleBack, handleClose]) }, [showAppSelector, handleBack, handleClose]);
// Управление BackButton — эффект запускается только при mount/unmount // Управление BackButton — эффект запускается только при mount/unmount
// Используем стабильный обработчик через ref, чтобы избежать мигания // Используем стабильный обработчик через ref, чтобы избежать мигания
useEffect(() => { useEffect(() => {
if (!webApp?.BackButton) return if (!webApp?.BackButton) return;
const stableHandler = () => { const stableHandler = () => {
backButtonHandlerRef.current() backButtonHandlerRef.current();
} };
webApp.BackButton.show() webApp.BackButton.show();
webApp.BackButton.onClick(stableHandler) webApp.BackButton.onClick(stableHandler);
return () => { return () => {
webApp.BackButton.offClick(stableHandler) webApp.BackButton.offClick(stableHandler);
webApp.BackButton.hide() webApp.BackButton.hide();
} };
}, [webApp]) // Только webApp в зависимостях! }, [webApp]); // Только webApp в зависимостях!
useEffect(() => { useEffect(() => {
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = '' } return () => {
}, []) document.body.style.overflow = '';
};
}, []);
const getLocalizedText = (text: LocalizedText | undefined): string => { const getLocalizedText = (text: LocalizedText | undefined): string => {
if (!text) return '' if (!text) return '';
const lang = i18n.language || 'en' const lang = i18n.language || 'en';
return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || '' return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || '';
} };
const availablePlatforms = useMemo(() => { const availablePlatforms = useMemo(() => {
if (!appConfig?.platforms) return [] if (!appConfig?.platforms) return [];
const available = platformOrder.filter(key => appConfig.platforms[key]?.length > 0) const available = platformOrder.filter((key) => appConfig.platforms[key]?.length > 0);
if (detectedPlatform && available.includes(detectedPlatform)) { if (detectedPlatform && available.includes(detectedPlatform)) {
return [detectedPlatform, ...available.filter(p => p !== detectedPlatform)] return [detectedPlatform, ...available.filter((p) => p !== detectedPlatform)];
} }
return available return available;
}, [appConfig, detectedPlatform]) }, [appConfig, detectedPlatform]);
const copySubscriptionLink = async () => { const copySubscriptionLink = async () => {
if (!appConfig?.subscriptionUrl) return if (!appConfig?.subscriptionUrl) return;
try { try {
await navigator.clipboard.writeText(appConfig.subscriptionUrl) await navigator.clipboard.writeText(appConfig.subscriptionUrl);
setCopied(true) setCopied(true);
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000);
} catch { } catch {
const textarea = document.createElement('textarea') const textarea = document.createElement('textarea');
textarea.value = appConfig.subscriptionUrl textarea.value = appConfig.subscriptionUrl;
document.body.appendChild(textarea) document.body.appendChild(textarea);
textarea.select() textarea.select();
document.execCommand('copy') document.execCommand('copy');
document.body.removeChild(textarea) document.body.removeChild(textarea);
setCopied(true) setCopied(true);
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000);
}
} }
};
const handleConnect = (app: AppInfo) => { const handleConnect = (app: AppInfo) => {
if (!app.deepLink || !isValidDeepLink(app.deepLink)) return if (!app.deepLink || !isValidDeepLink(app.deepLink)) return;
const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en' const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en';
const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}` const redirectUrl = `${window.location.origin}/miniapp/redirect.html?url=${encodeURIComponent(app.deepLink)}&lang=${lang}`;
const tg = (window as unknown as { Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } } }).Telegram?.WebApp const tg = (
window as unknown as {
Telegram?: { WebApp?: { openLink?: (url: string, options?: object) => void } };
}
).Telegram?.WebApp;
if (tg?.openLink) { if (tg?.openLink) {
try { try {
tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true }) tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true });
return return;
} catch { /* fallback */ } } catch {
/* fallback */
} }
window.location.href = redirectUrl
} }
window.location.href = redirectUrl;
};
// Wrapper component // Wrapper component
const Wrapper = ({ children }: { children: React.ReactNode }) => { const Wrapper = ({ children }: { children: React.ReactNode }) => {
if (isMobile) { if (isMobile) {
const content = ( const content = (
<div <div
className="fixed inset-0 z-[9999] bg-dark-900 flex flex-col" className="fixed inset-0 z-[9999] flex flex-col bg-dark-900"
style={{ style={{
paddingTop: safeTop ? `${safeTop}px` : 'env(safe-area-inset-top, 0px)', paddingTop: safeTop ? `${safeTop}px` : 'env(safe-area-inset-top, 0px)',
paddingBottom: safeBottom ? `${safeBottom}px` : 'env(safe-area-inset-bottom, 0px)' paddingBottom: safeBottom ? `${safeBottom}px` : 'env(safe-area-inset-bottom, 0px)',
}} }}
> >
{children} {children}
</div> </div>
) );
if (typeof document !== 'undefined') return createPortal(content, document.body) if (typeof document !== 'undefined') return createPortal(content, document.body);
return content return content;
} }
// Desktop centered - positioned higher // Desktop centered - positioned higher
return ( return (
<div className="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[8vh]" onClick={handleClose}>
<div <div
className="relative w-full max-w-md max-h-[85vh] bg-dark-900 rounded-2xl border border-dark-700/50 shadow-2xl flex flex-col overflow-hidden" className="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[8vh]"
onClick={e => e.stopPropagation()} onClick={handleClose}
>
<div
className="relative flex max-h-[85vh] w-full max-w-md flex-col overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900 shadow-2xl"
onClick={(e) => e.stopPropagation()}
> >
{children} {children}
</div> </div>
</div> </div>
) );
} };
// Loading // Loading
if (isLoading) { if (isLoading) {
return ( return (
<Wrapper> <Wrapper>
<div className="flex-1 flex items-center justify-center"> <div className="flex flex-1 items-center justify-center">
<div className="w-10 h-10 border-[3px] border-accent-500/30 border-t-accent-500 rounded-full animate-spin" /> <div className="h-10 w-10 animate-spin rounded-full border-[3px] border-accent-500/30 border-t-accent-500" />
</div> </div>
</Wrapper> </Wrapper>
) );
} }
// Error // Error
if (error || !appConfig) { if (error || !appConfig) {
return ( return (
<Wrapper> <Wrapper>
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center"> <div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
<p className="text-dark-300 text-lg mb-4">{t('common.error')}</p> <p className="mb-4 text-lg text-dark-300">{t('common.error')}</p>
<button onClick={handleClose} className="btn-primary px-6 py-2">{t('common.close')}</button> <button onClick={handleClose} className="btn-primary px-6 py-2">
{t('common.close')}
</button>
</div> </div>
</Wrapper> </Wrapper>
) );
} }
// No subscription // No subscription
if (!appConfig.hasSubscription) { if (!appConfig.hasSubscription) {
return ( return (
<Wrapper> <Wrapper>
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center"> <div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
<h3 className="font-bold text-dark-100 text-xl mb-2">{t('subscription.connection.title')}</h3> <h3 className="mb-2 text-xl font-bold text-dark-100">
<p className="text-dark-400 mb-4">{t('subscription.connection.noSubscription')}</p> {t('subscription.connection.title')}
<button onClick={handleClose} className="btn-primary px-6 py-2">{t('common.close')}</button> </h3>
<p className="mb-4 text-dark-400">{t('subscription.connection.noSubscription')}</p>
<button onClick={handleClose} className="btn-primary px-6 py-2">
{t('common.close')}
</button>
</div> </div>
</Wrapper> </Wrapper>
) );
} }
// App selector // App selector
if (showAppSelector) { if (showAppSelector) {
const platformNames: Record<string, string> = { const platformNames: Record<string, string> = {
ios: 'iOS', android: 'Android', windows: 'Windows', ios: 'iOS',
macos: 'macOS', linux: 'Linux', androidTV: 'Android TV', appleTV: 'Apple TV' android: 'Android',
} windows: 'Windows',
macos: 'macOS',
linux: 'Linux',
androidTV: 'Android TV',
appleTV: 'Apple TV',
};
const platformIcons: Record<string, React.ReactNode> = { const platformIcons: Record<string, React.ReactNode> = {
ios: ( ios: (
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor"> <svg className="h-7 w-7" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/> <path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg> </svg>
), ),
android: ( android: (
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor"> <svg className="h-7 w-7" viewBox="0 0 24 24" fill="currentColor">
<path d="M17.6 9.48l1.84-3.18c.16-.31.04-.69-.26-.85-.29-.15-.65-.06-.83.22l-1.88 3.24c-1.39-.59-2.94-.92-4.58-.92s-3.19.33-4.58.92L5.5 5.67c-.16-.28-.54-.37-.83-.22-.31.16-.43.54-.26.85l1.84 3.18C3.38 11.11 1.5 14.12 1.5 17.5h21c0-3.38-1.88-6.39-4.9-8.02zM7 15.25c-.69 0-1.25-.56-1.25-1.25s.56-1.25 1.25-1.25 1.25.56 1.25 1.25-.56 1.25-1.25 1.25zm10 0c-.69 0-1.25-.56-1.25-1.25s.56-1.25 1.25-1.25 1.25.56 1.25 1.25-.56 1.25-1.25 1.25z"/> <path d="M17.6 9.48l1.84-3.18c.16-.31.04-.69-.26-.85-.29-.15-.65-.06-.83.22l-1.88 3.24c-1.39-.59-2.94-.92-4.58-.92s-3.19.33-4.58.92L5.5 5.67c-.16-.28-.54-.37-.83-.22-.31.16-.43.54-.26.85l1.84 3.18C3.38 11.11 1.5 14.12 1.5 17.5h21c0-3.38-1.88-6.39-4.9-8.02zM7 15.25c-.69 0-1.25-.56-1.25-1.25s.56-1.25 1.25-1.25 1.25.56 1.25 1.25-.56 1.25-1.25 1.25zm10 0c-.69 0-1.25-.56-1.25-1.25s.56-1.25 1.25-1.25 1.25.56 1.25 1.25-.56 1.25-1.25 1.25z" />
</svg> </svg>
), ),
windows: ( windows: (
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor"> <svg className="h-6 w-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 12V6.75l6-1.32v6.48L3 12zm17-9v8.75l-10 .15V5.21L20 3zM3 13l6 .09v6.81l-6-1.15V13zm17 .25V22l-10-1.91V13.1l10 .15z"/> <path d="M3 12V6.75l6-1.32v6.48L3 12zm17-9v8.75l-10 .15V5.21L20 3zM3 13l6 .09v6.81l-6-1.15V13zm17 .25V22l-10-1.91V13.1l10 .15z" />
</svg> </svg>
), ),
macos: ( macos: (
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor"> <svg className="h-7 w-7" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/> <path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
</svg> </svg>
), ),
linux: ( linux: (
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor"> <svg className="h-6 w-6" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489.117.779.456 1.462 1.047 1.993.545.487 1.183.805 1.894.983.718.178 1.463.201 2.215.201.752 0 1.498-.023 2.215-.201.711-.178 1.349-.496 1.894-.983.591-.531.93-1.214 1.047-1.993.123-.805-.009-1.657-.287-2.489-.589-1.77-1.831-3.47-2.716-4.521-.75-1.067-.974-1.928-1.05-3.02-.065-1.491 1.056-5.965-3.17-6.298-.165-.013-.325-.021-.48-.021zm-.126 1.09c.99.063 1.783.569 2.172 1.443.376.844.413 1.903.199 2.999-.215 1.096-.687 2.23-1.357 3.18-.669.95-1.405 1.705-2.017 2.379-.611.674-1.074 1.252-1.316 1.857-.242.605-.262 1.233-.006 1.873.256.64.755 1.198 1.36 1.547.606.35 1.247.536 1.88.536.634 0 1.275-.186 1.88-.536.606-.349 1.105-.907 1.36-1.547.256-.64.236-1.268-.006-1.873-.242-.605-.705-1.183-1.316-1.857-.612-.674-1.348-1.429-2.017-2.379-.67-.95-1.142-2.084-1.357-3.18-.214-1.096-.177-2.155.199-2.999.389-.874 1.182-1.38 2.172-1.443z"/> <path d="M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489.117.779.456 1.462 1.047 1.993.545.487 1.183.805 1.894.983.718.178 1.463.201 2.215.201.752 0 1.498-.023 2.215-.201.711-.178 1.349-.496 1.894-.983.591-.531.93-1.214 1.047-1.993.123-.805-.009-1.657-.287-2.489-.589-1.77-1.831-3.47-2.716-4.521-.75-1.067-.974-1.928-1.05-3.02-.065-1.491 1.056-5.965-3.17-6.298-.165-.013-.325-.021-.48-.021zm-.126 1.09c.99.063 1.783.569 2.172 1.443.376.844.413 1.903.199 2.999-.215 1.096-.687 2.23-1.357 3.18-.669.95-1.405 1.705-2.017 2.379-.611.674-1.074 1.252-1.316 1.857-.242.605-.262 1.233-.006 1.873.256.64.755 1.198 1.36 1.547.606.35 1.247.536 1.88.536.634 0 1.275-.186 1.88-.536.606-.349 1.105-.907 1.36-1.547.256-.64.236-1.268-.006-1.873-.242-.605-.705-1.183-1.316-1.857-.612-.674-1.348-1.429-2.017-2.379-.67-.95-1.142-2.084-1.357-3.18-.214-1.096-.177-2.155.199-2.999.389-.874 1.182-1.38 2.172-1.443z" />
</svg> </svg>
), ),
androidTV: ( androidTV: (
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor"> <svg className="h-7 w-7" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"/> <path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" />
</svg> </svg>
), ),
appleTV: ( appleTV: (
<svg className="w-7 h-7" viewBox="0 0 24 24" fill="currentColor"> <svg className="h-7 w-7" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z"/> <path d="M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h5v2h8v-2h5c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" />
</svg> </svg>
), ),
} };
// Step 1: Platform selection // Step 1: Platform selection
if (!selectedPlatform) { if (!selectedPlatform) {
return ( return (
<Wrapper> <Wrapper>
<div className="flex items-center gap-3 p-4 border-b border-dark-800"> <div className="flex items-center gap-3 border-b border-dark-800 p-4">
<button onClick={handleBack} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-300"> <button
onClick={handleBack}
className="-ml-2 rounded-xl p-2 text-dark-300 hover:bg-dark-800"
>
<BackIcon /> <BackIcon />
</button> </button>
<h2 className="font-bold text-dark-100 text-lg">{t('subscription.connection.selectPlatform') || 'Выберите платформу'}</h2> <h2 className="text-lg font-bold text-dark-100">
{t('subscription.connection.selectPlatform') || 'Выберите платформу'}
</h2>
</div> </div>
<div className="p-4 space-y-2"> <div className="space-y-2 p-4">
{availablePlatforms.map(platform => { {availablePlatforms.map((platform) => {
const apps = appConfig.platforms[platform] const apps = appConfig.platforms[platform];
if (!apps?.length) return null if (!apps?.length) return null;
const isCurrentPlatform = platform === detectedPlatform const isCurrentPlatform = platform === detectedPlatform;
const appCount = apps.length const appCount = apps.length;
return ( return (
<button <button
key={platform} key={platform}
onClick={() => setSelectedPlatform(platform)} onClick={() => setSelectedPlatform(platform)}
className={`w-full flex items-center gap-4 p-4 rounded-xl transition-all active:scale-[0.98] ${ className={`flex w-full items-center gap-4 rounded-xl p-4 transition-all active:scale-[0.98] ${
isCurrentPlatform isCurrentPlatform
? 'bg-accent-500/10 ring-1 ring-accent-500/30' ? 'bg-accent-500/10 ring-1 ring-accent-500/30'
: 'bg-dark-800/50 hover:bg-dark-800' : 'bg-dark-800/50 hover:bg-dark-800'
}`} }`}
> >
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${ <div
isCurrentPlatform ? 'bg-accent-500/20 text-accent-400' : 'bg-dark-700 text-dark-300' className={`flex h-12 w-12 items-center justify-center rounded-xl ${
}`}> isCurrentPlatform
? 'bg-accent-500/20 text-accent-400'
: 'bg-dark-700 text-dark-300'
}`}
>
{platformIcons[platform] || <span className="text-xl">📱</span>} {platformIcons[platform] || <span className="text-xl">📱</span>}
</div> </div>
<div className="flex-1 text-left"> <div className="flex-1 text-left">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`font-medium ${isCurrentPlatform ? 'text-accent-400' : 'text-dark-100'}`}> <span
className={`font-medium ${isCurrentPlatform ? 'text-accent-400' : 'text-dark-100'}`}
>
{platformNames[platform] || platform} {platformNames[platform] || platform}
</span> </span>
{isCurrentPlatform && ( {isCurrentPlatform && (
<span className="text-xs text-accent-500 bg-accent-500/10 px-2 py-0.5 rounded-full"> <span className="rounded-full bg-accent-500/10 px-2 py-0.5 text-xs text-accent-500">
{t('subscription.connection.yourDevice')} {t('subscription.connection.yourDevice')}
</span> </span>
)} )}
</div> </div>
<span className="text-sm text-dark-400"> <span className="text-sm text-dark-400">
{appCount} {appCount === 1 ? (t('subscription.connection.app') || 'приложение') : (t('subscription.connection.apps') || 'приложений')} {appCount}{' '}
{appCount === 1
? t('subscription.connection.app') || 'приложение'
: t('subscription.connection.apps') || 'приложений'}
</span> </span>
</div> </div>
<ChevronIcon /> <ChevronIcon />
</button> </button>
) );
})} })}
</div> </div>
</Wrapper> </Wrapper>
) );
} }
// Step 2: App selection for chosen platform // Step 2: App selection for chosen platform
const apps = appConfig.platforms[selectedPlatform] || [] const apps = appConfig.platforms[selectedPlatform] || [];
const isCurrentPlatform = selectedPlatform === detectedPlatform const isCurrentPlatform = selectedPlatform === detectedPlatform;
return ( return (
<Wrapper> <Wrapper>
<div className="flex items-center gap-3 p-4 border-b border-dark-800"> <div className="flex items-center gap-3 border-b border-dark-800 p-4">
<button onClick={handleBack} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-300"> <button
onClick={handleBack}
className="-ml-2 rounded-xl p-2 text-dark-300 hover:bg-dark-800"
>
<BackIcon /> <BackIcon />
</button> </button>
<div className="flex-1"> <div className="flex-1">
<h2 className="font-bold text-dark-100 text-lg">{platformNames[selectedPlatform] || selectedPlatform}</h2> <h2 className="text-lg font-bold text-dark-100">
{platformNames[selectedPlatform] || selectedPlatform}
</h2>
{isCurrentPlatform && ( {isCurrentPlatform && (
<span className="text-xs text-accent-500">{t('subscription.connection.yourDevice')}</span> <span className="text-xs text-accent-500">
{t('subscription.connection.yourDevice')}
</span>
)} )}
</div> </div>
</div> </div>
@@ -470,13 +542,17 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
onWheel={handleScrollContainerWheel} onWheel={handleScrollContainerWheel}
> >
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{apps.map(app => { {apps.map((app) => {
const isSelected = selectedApp?.id === app.id const isSelected = selectedApp?.id === app.id;
return ( return (
<button <button
key={app.id} key={app.id}
onClick={() => { setSelectedApp(app); setShowAppSelector(false); setSelectedPlatform(null) }} onClick={() => {
className={`relative flex flex-col items-center p-4 rounded-xl transition-all active:scale-95 ${ setSelectedApp(app);
setShowAppSelector(false);
setSelectedPlatform(null);
}}
className={`relative flex flex-col items-center rounded-xl p-4 transition-all active:scale-95 ${
isSelected isSelected
? 'bg-accent-500/15 ring-2 ring-accent-500/50' ? 'bg-accent-500/15 ring-2 ring-accent-500/50'
: 'bg-dark-800/50 hover:bg-dark-800' : 'bg-dark-800/50 hover:bg-dark-800'
@@ -484,85 +560,107 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
> >
{/* Featured badge */} {/* Featured badge */}
{app.isFeatured && ( {app.isFeatured && (
<div className="absolute -top-1.5 -right-1.5 w-6 h-6 bg-accent-500 rounded-full flex items-center justify-center shadow-lg"> <div className="absolute -right-1.5 -top-1.5 flex h-6 w-6 items-center justify-center rounded-full bg-accent-500 shadow-lg">
<svg className="w-3.5 h-3.5 text-white" fill="currentColor" viewBox="0 0 20 20"> <svg
className="h-3.5 w-3.5 text-white"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /> <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg> </svg>
</div> </div>
)} )}
{/* App icon */} {/* App icon */}
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center mb-3 transition-colors ${ <div
className={`mb-3 flex h-14 w-14 items-center justify-center rounded-2xl transition-colors ${
isSelected isSelected
? 'bg-accent-500/20 text-accent-400' ? 'bg-accent-500/20 text-accent-400'
: 'bg-dark-700/80 text-dark-300' : 'bg-dark-700/80 text-dark-300'
}`}> }`}
>
{getAppIcon(app.name)} {getAppIcon(app.name)}
</div> </div>
{/* App name */} {/* App name */}
<span className={`text-sm font-medium text-center leading-tight line-clamp-2 ${ <span
className={`line-clamp-2 text-center text-sm font-medium leading-tight ${
isSelected ? 'text-accent-400' : 'text-dark-200' isSelected ? 'text-accent-400' : 'text-dark-200'
}`}> }`}
>
{app.name} {app.name}
</span> </span>
{/* Selection indicator */} {/* Selection indicator */}
{isSelected && ( {isSelected && (
<div className="absolute bottom-2 left-1/2 -translate-x-1/2 w-2 h-2 bg-accent-500 rounded-full" /> <div className="absolute bottom-2 left-1/2 h-2 w-2 -translate-x-1/2 rounded-full bg-accent-500" />
)} )}
</button> </button>
) );
})} })}
</div> </div>
</div> </div>
</Wrapper> </Wrapper>
) );
} }
// Main view // Main view
return ( return (
<Wrapper> <Wrapper>
<div className="p-4 border-b border-dark-800"> <div className="border-b border-dark-800 p-4">
<div className="flex items-center justify-between mb-3"> <div className="mb-3 flex items-center justify-between">
<h2 className="font-bold text-dark-100 text-lg">{t('subscription.connection.title')}</h2> <h2 className="text-lg font-bold text-dark-100">{t('subscription.connection.title')}</h2>
<button onClick={handleClose} className="p-2 -mr-2 rounded-xl hover:bg-dark-800 text-dark-400"> <button
onClick={handleClose}
className="-mr-2 rounded-xl p-2 text-dark-400 hover:bg-dark-800"
>
<CloseIcon /> <CloseIcon />
</button> </button>
</div> </div>
<button <button
onClick={() => setShowAppSelector(true)} onClick={() => setShowAppSelector(true)}
className="w-full flex items-center gap-3 p-3 rounded-xl bg-dark-800/50 hover:bg-dark-800 transition-colors" className="flex w-full items-center gap-3 rounded-xl bg-dark-800/50 p-3 transition-colors hover:bg-dark-800"
> >
<div className="w-10 h-10 rounded-lg bg-accent-500/10 flex items-center justify-center text-accent-400"> <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-accent-500/10 text-accent-400">
{selectedApp && getAppIcon(selectedApp.name)} {selectedApp && getAppIcon(selectedApp.name)}
</div> </div>
<div className="flex-1 text-left"> <div className="flex-1 text-left">
<div className="font-medium text-dark-100">{selectedApp?.name}</div> <div className="font-medium text-dark-100">{selectedApp?.name}</div>
<div className="text-sm text-dark-400">{t('subscription.connection.changeApp') || 'Сменить приложение'}</div> <div className="text-sm text-dark-400">
{t('subscription.connection.changeApp') || 'Сменить приложение'}
</div>
</div> </div>
<ChevronIcon /> <ChevronIcon />
</button> </button>
</div> </div>
<div <div
className={`${isMobile ? 'flex-1' : 'max-h-[50vh]'} overflow-y-auto p-4 space-y-4`} className={`${isMobile ? 'flex-1' : 'max-h-[50vh]'} space-y-4 overflow-y-auto p-4`}
style={{ overscrollBehavior: 'contain', WebkitOverflowScrolling: 'touch' }} style={{ overscrollBehavior: 'contain', WebkitOverflowScrolling: 'touch' }}
onWheel={handleScrollContainerWheel} onWheel={handleScrollContainerWheel}
> >
{selectedApp?.installationStep && ( {selectedApp?.installationStep && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-accent-500/20 flex items-center justify-center text-xs font-bold text-accent-400">1</span> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent-500/20 text-xs font-bold text-accent-400">
<span className="font-medium text-dark-100">{t('subscription.connection.installApp')}</span> 1
</span>
<span className="font-medium text-dark-100">
{t('subscription.connection.installApp')}
</span>
</div> </div>
<p className="text-dark-400 text-sm ml-8">{getLocalizedText(selectedApp.installationStep.description)}</p> <p className="ml-8 text-sm text-dark-400">
{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && ( {getLocalizedText(selectedApp.installationStep.description)}
<div className="flex flex-wrap gap-2 ml-8"> </p>
{selectedApp.installationStep.buttons.filter(btn => isValidExternalUrl(btn.buttonLink)).map((btn, idx) => ( {selectedApp.installationStep.buttons &&
selectedApp.installationStep.buttons.length > 0 && (
<div className="ml-8 flex flex-wrap gap-2">
{selectedApp.installationStep.buttons
.filter((btn) => isValidExternalUrl(btn.buttonLink))
.map((btn, idx) => (
<a <a
key={idx} key={idx}
href={btn.buttonLink} href={btn.buttonLink}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-dark-800 text-dark-200 text-sm hover:bg-dark-700" className="inline-flex items-center gap-1.5 rounded-lg bg-dark-800 px-3 py-1.5 text-sm text-dark-200 hover:bg-dark-700"
> >
{getLocalizedText(btn.buttonText)} {getLocalizedText(btn.buttonText)}
</a> </a>
@@ -575,15 +673,21 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
{selectedApp?.addSubscriptionStep && ( {selectedApp?.addSubscriptionStep && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-accent-500/20 flex items-center justify-center text-xs font-bold text-accent-400">2</span> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-accent-500/20 text-xs font-bold text-accent-400">
<span className="font-medium text-dark-100">{t('subscription.connection.addSubscription')}</span> 2
</span>
<span className="font-medium text-dark-100">
{t('subscription.connection.addSubscription')}
</span>
</div> </div>
<p className="text-dark-400 text-sm ml-8">{getLocalizedText(selectedApp.addSubscriptionStep.description)}</p> <p className="ml-8 text-sm text-dark-400">
<div className="space-y-2 ml-8"> {getLocalizedText(selectedApp.addSubscriptionStep.description)}
</p>
<div className="ml-8 space-y-2">
{selectedApp.deepLink && ( {selectedApp.deepLink && (
<button <button
onClick={() => handleConnect(selectedApp)} onClick={() => handleConnect(selectedApp)}
className="w-full btn-primary h-11 text-sm font-semibold flex items-center justify-center gap-2" className="btn-primary flex h-11 w-full items-center justify-center gap-2 text-sm font-semibold"
> >
<LinkIcon /> <LinkIcon />
{t('subscription.connection.addToApp', { appName: selectedApp.name })} {t('subscription.connection.addToApp', { appName: selectedApp.name })}
@@ -593,14 +697,16 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
{!appConfig?.hideLink && ( {!appConfig?.hideLink && (
<button <button
onClick={copySubscriptionLink} onClick={copySubscriptionLink}
className={`w-full h-11 rounded-xl border transition-all flex items-center justify-center gap-2 text-sm font-medium ${ className={`flex h-11 w-full items-center justify-center gap-2 rounded-xl border text-sm font-medium transition-all ${
copied copied
? 'border-success-500 bg-success-500/10 text-success-400' ? 'border-success-500 bg-success-500/10 text-success-400'
: 'border-dark-600 text-dark-300 hover:bg-dark-800' : 'border-dark-600 text-dark-300 hover:bg-dark-800'
}`} }`}
> >
{copied ? <CheckIcon /> : <CopyIcon />} {copied ? <CheckIcon /> : <CopyIcon />}
{copied ? t('subscription.connection.copied') : t('subscription.connection.copyLink')} {copied
? t('subscription.connection.copied')
: t('subscription.connection.copyLink')}
</button> </button>
)} )}
</div> </div>
@@ -610,13 +716,19 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
{selectedApp?.connectAndUseStep && ( {selectedApp?.connectAndUseStep && (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-success-500/20 flex items-center justify-center text-xs font-bold text-success-400">3</span> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-success-500/20 text-xs font-bold text-success-400">
<span className="font-medium text-dark-100">{t('subscription.connection.connectVpn')}</span> 3
</span>
<span className="font-medium text-dark-100">
{t('subscription.connection.connectVpn')}
</span>
</div> </div>
<p className="text-dark-400 text-sm ml-8">{getLocalizedText(selectedApp.connectAndUseStep.description)}</p> <p className="ml-8 text-sm text-dark-400">
{getLocalizedText(selectedApp.connectAndUseStep.description)}
</p>
</div> </div>
)} )}
</div> </div>
</Wrapper> </Wrapper>
) );
} }

View File

@@ -1,20 +1,20 @@
import { useState } from 'react' import { useState } from 'react';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import { balanceApi } from '../api/balance' import { balanceApi } from '../api/balance';
import { useCurrency } from '../hooks/useCurrency' import { useCurrency } from '../hooks/useCurrency';
import TopUpModal from './TopUpModal' import TopUpModal from './TopUpModal';
import type { PaymentMethod } from '../types' import type { PaymentMethod } from '../types';
interface InsufficientBalancePromptProps { interface InsufficientBalancePromptProps {
/** Amount missing in kopeks */ /** Amount missing in kopeks */
missingAmountKopeks: number missingAmountKopeks: number;
/** Optional custom message */ /** Optional custom message */
message?: string message?: string;
/** Compact mode for inline use */ /** Compact mode for inline use */
compact?: boolean compact?: boolean;
/** Additional className */ /** Additional className */
className?: string className?: string;
} }
export default function InsufficientBalancePrompt({ export default function InsufficientBalancePrompt({
@@ -23,40 +23,55 @@ export default function InsufficientBalancePrompt({
compact = false, compact = false,
className = '', className = '',
}: InsufficientBalancePromptProps) { }: InsufficientBalancePromptProps) {
const { t } = useTranslation() const { t } = useTranslation();
const { formatAmount, currencySymbol } = useCurrency() const { formatAmount, currencySymbol } = useCurrency();
const [showMethodSelect, setShowMethodSelect] = useState(false) const [showMethodSelect, setShowMethodSelect] = useState(false);
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null) const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null);
const { data: paymentMethods } = useQuery({ const { data: paymentMethods } = useQuery({
queryKey: ['payment-methods'], queryKey: ['payment-methods'],
queryFn: balanceApi.getPaymentMethods, queryFn: balanceApi.getPaymentMethods,
enabled: showMethodSelect, enabled: showMethodSelect,
}) });
const missingRubles = missingAmountKopeks / 100 const missingRubles = missingAmountKopeks / 100;
const displayAmount = formatAmount(missingRubles) const displayAmount = formatAmount(missingRubles);
const handleMethodSelect = (method: PaymentMethod) => { const handleMethodSelect = (method: PaymentMethod) => {
setSelectedMethod(method) setSelectedMethod(method);
setShowMethodSelect(false) setShowMethodSelect(false);
} };
if (compact) { if (compact) {
return ( return (
<> <>
<div className={`flex items-center justify-between gap-3 p-3 bg-error-500/10 border border-error-500/30 rounded-xl ${className}`}> <div
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 className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
<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" /> 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> </svg>
<span> <span>
{message || t('balance.insufficientFunds')}: <span className="font-semibold">{displayAmount} {currencySymbol}</span> {message || t('balance.insufficientFunds')}:{' '}
<span className="font-semibold">
{displayAmount} {currencySymbol}
</span>
</span> </span>
</div> </div>
<button <button
onClick={() => setShowMethodSelect(true)} onClick={() => setShowMethodSelect(true)}
className="btn-primary text-xs py-1.5 px-3 whitespace-nowrap" className="btn-primary whitespace-nowrap px-3 py-1.5 text-xs"
> >
{t('balance.topUp')} {t('balance.topUp')}
</button> </button>
@@ -78,37 +93,54 @@ export default function InsufficientBalancePrompt({
/> />
)} )}
</> </>
) );
} }
return ( return (
<> <>
<div className={`p-4 bg-gradient-to-br from-error-500/10 to-warning-500/5 border border-error-500/30 rounded-xl ${className}`}> <div
className={`rounded-xl border border-error-500/30 bg-gradient-to-br from-error-500/10 to-warning-500/5 p-4 ${className}`}
>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-error-500/20 flex items-center justify-center flex-shrink-0"> <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-error-500/20">
<svg className="w-5 h-5 text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
<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" /> 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> </svg>
</div> </div>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="text-error-400 font-medium mb-1"> <div className="mb-1 font-medium text-error-400">{t('balance.insufficientFunds')}</div>
{t('balance.insufficientFunds')} <div className="text-sm text-dark-300">{message || t('balance.topUpToComplete')}</div>
</div>
<div className="text-dark-300 text-sm">
{message || t('balance.topUpToComplete')}
</div>
<div className="mt-3 flex items-center gap-3"> <div className="mt-3 flex items-center gap-3">
<div className="text-lg font-bold text-dark-100"> <div className="text-lg font-bold text-dark-100">
{t('balance.missing')}: <span className="text-error-400">{displayAmount} {currencySymbol}</span> {t('balance.missing')}:{' '}
<span className="text-error-400">
{displayAmount} {currencySymbol}
</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<button <button
onClick={() => setShowMethodSelect(true)} onClick={() => setShowMethodSelect(true)}
className="btn-primary w-full mt-4 py-2.5 flex items-center justify-center gap-2" className="btn-primary mt-4 flex w-full items-center justify-center gap-2 py-2.5"
>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
> >
<svg className="w-5 h-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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg> </svg>
{t('balance.topUpBalance')} {t('balance.topUpBalance')}
@@ -131,80 +163,115 @@ export default function InsufficientBalancePrompt({
/> />
)} )}
</> </>
) );
} }
interface PaymentMethodModalProps { interface PaymentMethodModalProps {
paymentMethods: PaymentMethod[] | undefined paymentMethods: PaymentMethod[] | undefined;
onSelect: (method: PaymentMethod) => void onSelect: (method: PaymentMethod) => void;
onClose: () => void onClose: () => void;
} }
function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethodModalProps) { function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethodModalProps) {
const { t } = useTranslation() const { t } = useTranslation();
const { formatAmount, currencySymbol } = useCurrency() const { formatAmount, currencySymbol } = useCurrency();
return ( return (
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[60] flex items-center justify-center px-4 pt-14 pb-28 sm:pt-0 sm:pb-0"> <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 px-4 pb-28 pt-14 backdrop-blur-sm sm:pb-0 sm:pt-0">
<div className="absolute inset-0" onClick={onClose} /> <div className="absolute inset-0" onClick={onClose} />
<div className="relative w-full max-w-sm bg-dark-900/95 backdrop-blur-xl rounded-3xl border border-dark-700/50 shadow-2xl overflow-hidden max-h-full flex flex-col"> <div className="relative flex max-h-full w-full max-w-sm flex-col overflow-hidden rounded-3xl border border-dark-700/50 bg-dark-900/95 shadow-2xl backdrop-blur-xl">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50"> <div className="flex items-center justify-between bg-dark-800/50 px-4 py-3">
<span className="font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</span> <span className="font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</span>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400" aria-label="Close"> <button
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> onClick={onClose}
className="rounded-lg p-1.5 text-dark-400 hover:bg-dark-700"
aria-label="Close"
>
<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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-2"> <div className="flex-1 space-y-2 overflow-y-auto p-3">
{!paymentMethods ? ( {!paymentMethods ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<div className="w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" /> <div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div> </div>
) : paymentMethods.length === 0 ? ( ) : paymentMethods.length === 0 ? (
<div className="text-center py-6 text-dark-400 text-sm"> <div className="py-6 text-center text-sm text-dark-400">
{t('balance.noPaymentMethods')} {t('balance.noPaymentMethods')}
</div> </div>
) : ( ) : (
paymentMethods.map((method) => { paymentMethods.map((method) => {
const methodKey = method.id.toLowerCase().replace(/-/g, '_') const methodKey = method.id.toLowerCase().replace(/-/g, '_');
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) const translatedName = t(`balance.paymentMethods.${methodKey}.name`, {
defaultValue: '',
});
return ( return (
<button <button
key={method.id} key={method.id}
disabled={!method.is_available} disabled={!method.is_available}
onClick={() => method.is_available && onSelect(method)} onClick={() => method.is_available && onSelect(method)}
className={`w-full p-3 rounded-xl text-left flex items-center gap-3 ${ className={`flex w-full items-center gap-3 rounded-xl p-3 text-left ${
method.is_available method.is_available
? 'bg-dark-800 hover:bg-dark-700 active:bg-dark-600' ? 'bg-dark-800 hover:bg-dark-700 active:bg-dark-600'
: 'bg-dark-800/50 opacity-50' : 'bg-dark-800/50 opacity-50'
}`} }`}
> >
<div className="w-9 h-9 rounded-lg bg-accent-500/20 flex items-center justify-center flex-shrink-0"> <div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-accent-500/20">
<svg className="w-4 h-4 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
<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" /> 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="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> </svg>
</div> </div>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="font-medium text-dark-100 text-sm">{translatedName || method.name}</div> <div className="text-sm font-medium text-dark-100">
{translatedName || method.name}
</div>
<div className="text-xs text-dark-500"> <div className="text-xs text-dark-500">
{formatAmount(method.min_amount_kopeks / 100, 0)} {formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol} {formatAmount(method.min_amount_kopeks / 100, 0)} {' '}
{formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
</div> </div>
</div> </div>
<svg className="w-4 h-4 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> className="h-4 w-4 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> </svg>
</button> </button>
) );
}) })
)} )}
</div> </div>
</div> </div>
</div> </div>
) );
} }

View File

@@ -1,53 +1,53 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react';
const languages = [ const languages = [
{ code: 'ru', name: 'RU', flag: '🇷🇺', fullName: 'Русский' }, { code: 'ru', name: 'RU', flag: '🇷🇺', fullName: 'Русский' },
{ code: 'en', name: 'EN', flag: '🇬🇧', fullName: 'English' }, { code: 'en', name: 'EN', flag: '🇬🇧', fullName: 'English' },
{ code: 'zh', name: 'ZH', flag: '🇨🇳', fullName: '中文' }, { code: 'zh', name: 'ZH', flag: '🇨🇳', fullName: '中文' },
{ code: 'fa', name: 'FA', flag: '🇮🇷', fullName: 'فارسی' }, { code: 'fa', name: 'FA', flag: '🇮🇷', fullName: 'فارسی' },
] ];
export default function LanguageSwitcher() { export default function LanguageSwitcher() {
const { i18n } = useTranslation() const { i18n } = useTranslation();
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null) const dropdownRef = useRef<HTMLDivElement>(null);
const currentLang = languages.find((l) => l.code === i18n.language) || languages[0] const currentLang = languages.find((l) => l.code === i18n.language) || languages[0];
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false) setIsOpen(false);
} }
} }
document.addEventListener('mousedown', handleClickOutside) document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside);
}, []) }, []);
const changeLanguage = (code: string) => { const changeLanguage = (code: string) => {
i18n.changeLanguage(code) i18n.changeLanguage(code);
// Set document direction for RTL languages // Set document direction for RTL languages
document.documentElement.dir = code === 'fa' ? 'rtl' : 'ltr' document.documentElement.dir = code === 'fa' ? 'rtl' : 'ltr';
setIsOpen(false) setIsOpen(false);
} };
// Set initial direction on mount // Set initial direction on mount
useEffect(() => { useEffect(() => {
document.documentElement.dir = i18n.language === 'fa' ? 'rtl' : 'ltr' document.documentElement.dir = i18n.language === 'fa' ? 'rtl' : 'ltr';
}, [i18n.language]) }, [i18n.language]);
return ( return (
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-1.5 px-2.5 py-2 rounded-xl border border-dark-700/50 hover:border-dark-600 bg-dark-800/50 hover:bg-dark-700 transition-all text-sm" className="flex items-center gap-1.5 rounded-xl border border-dark-700/50 bg-dark-800/50 px-2.5 py-2 text-sm transition-all hover:border-dark-600 hover:bg-dark-700"
aria-label="Change language" aria-label="Change language"
> >
<span>{currentLang.flag}</span> <span>{currentLang.flag}</span>
<span className="font-medium text-dark-200">{currentLang.name}</span> <span className="font-medium text-dark-200">{currentLang.name}</span>
<svg <svg
className={`w-3.5 h-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" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@@ -57,12 +57,12 @@ export default function LanguageSwitcher() {
</button> </button>
{isOpen && ( {isOpen && (
<div className="absolute right-0 mt-2 w-40 bg-dark-800 rounded-xl shadow-lg border border-dark-700/50 py-1 z-50 animate-fade-in"> <div className="absolute right-0 z-50 mt-2 w-40 animate-fade-in rounded-xl border border-dark-700/50 bg-dark-800 py-1 shadow-lg">
{languages.map((lang) => ( {languages.map((lang) => (
<button <button
key={lang.code} key={lang.code}
onClick={() => changeLanguage(lang.code)} onClick={() => changeLanguage(lang.code)}
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${ className={`flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
lang.code === i18n.language lang.code === i18n.language
? 'bg-accent-500/10 text-accent-400' ? 'bg-accent-500/10 text-accent-400'
: 'text-dark-300 hover:bg-dark-700/50' : 'text-dark-300 hover:bg-dark-700/50'
@@ -75,5 +75,5 @@ export default function LanguageSwitcher() {
</div> </div>
)} )}
</div> </div>
) );
} }

View File

@@ -1,146 +1,147 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
interface OnboardingStep { interface OnboardingStep {
target: string // data-onboarding attribute value target: string; // data-onboarding attribute value
title: string title: string;
description: string description: string;
placement: 'top' | 'bottom' | 'left' | 'right' placement: 'top' | 'bottom' | 'left' | 'right';
} }
interface OnboardingProps { interface OnboardingProps {
steps: OnboardingStep[] steps: OnboardingStep[];
onComplete: () => void onComplete: () => void;
onSkip: () => void onSkip: () => void;
} }
const STORAGE_KEY = 'onboarding_completed' const STORAGE_KEY = 'onboarding_completed';
// eslint-disable-next-line react-refresh/only-export-components
export function useOnboarding() { export function useOnboarding() {
const [isCompleted, setIsCompleted] = useState(() => { const [isCompleted, setIsCompleted] = useState(() => {
return localStorage.getItem(STORAGE_KEY) === 'true' return localStorage.getItem(STORAGE_KEY) === 'true';
}) });
const complete = useCallback(() => { const complete = useCallback(() => {
localStorage.setItem(STORAGE_KEY, 'true') localStorage.setItem(STORAGE_KEY, 'true');
setIsCompleted(true) setIsCompleted(true);
}, []) }, []);
const reset = useCallback(() => { const reset = useCallback(() => {
localStorage.removeItem(STORAGE_KEY) localStorage.removeItem(STORAGE_KEY);
setIsCompleted(false) setIsCompleted(false);
}, []) }, []);
return { isCompleted, complete, reset } return { isCompleted, complete, reset };
} }
export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProps) { export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProps) {
const { t } = useTranslation() const { t } = useTranslation();
const [currentStep, setCurrentStep] = useState(0) const [currentStep, setCurrentStep] = useState(0);
const [targetRect, setTargetRect] = useState<DOMRect | null>(null) const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
const [isVisible, setIsVisible] = useState(false) const [isVisible, setIsVisible] = useState(false);
const tooltipRef = useRef<HTMLDivElement>(null) const tooltipRef = useRef<HTMLDivElement>(null);
const step = steps[currentStep] const step = steps[currentStep];
// Find and highlight target element // Find and highlight target element
useEffect(() => { useEffect(() => {
const findTarget = () => { const findTarget = () => {
const target = document.querySelector(`[data-onboarding="${step.target}"]`) const target = document.querySelector(`[data-onboarding="${step.target}"]`);
if (target) { if (target) {
const rect = target.getBoundingClientRect() const rect = target.getBoundingClientRect();
setTargetRect(rect) setTargetRect(rect);
// Scroll element into view if needed // Scroll element into view if needed
target.scrollIntoView({ behavior: 'smooth', block: 'center' }) target.scrollIntoView({ behavior: 'smooth', block: 'center' });
// Delay visibility for smooth animation // Delay visibility for smooth animation
setTimeout(() => setIsVisible(true), 100) setTimeout(() => setIsVisible(true), 100);
}
} }
};
setIsVisible(false) setIsVisible(false);
const timer = setTimeout(findTarget, 300) const timer = setTimeout(findTarget, 300);
return () => clearTimeout(timer) return () => clearTimeout(timer);
}, [step.target]) }, [step.target]);
// Recalculate position on resize/scroll // Recalculate position on resize/scroll
useEffect(() => { useEffect(() => {
const updatePosition = () => { const updatePosition = () => {
const target = document.querySelector(`[data-onboarding="${step.target}"]`) const target = document.querySelector(`[data-onboarding="${step.target}"]`);
if (target) { if (target) {
setTargetRect(target.getBoundingClientRect()) setTargetRect(target.getBoundingClientRect());
}
} }
};
window.addEventListener('resize', updatePosition) window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true) window.addEventListener('scroll', updatePosition, true);
return () => { return () => {
window.removeEventListener('resize', updatePosition) window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true) window.removeEventListener('scroll', updatePosition, true);
} };
}, [step.target]) }, [step.target]);
const handleNext = () => { const handleNext = () => {
if (currentStep < steps.length - 1) { if (currentStep < steps.length - 1) {
setCurrentStep(currentStep + 1) setCurrentStep(currentStep + 1);
} else { } else {
onComplete() onComplete();
}
} }
};
const handlePrev = () => { const handlePrev = () => {
if (currentStep > 0) { if (currentStep > 0) {
setCurrentStep(currentStep - 1) setCurrentStep(currentStep - 1);
}
} }
};
const handleSkip = () => { const handleSkip = () => {
onSkip() onSkip();
} };
// Calculate tooltip position // Calculate tooltip position
const getTooltipStyle = (): React.CSSProperties => { const getTooltipStyle = (): React.CSSProperties => {
if (!targetRect) return { opacity: 0 } if (!targetRect) return { opacity: 0 };
const padding = 16 const padding = 16;
const tooltipWidth = 320 const tooltipWidth = 320;
const tooltipHeight = tooltipRef.current?.offsetHeight || 150 const tooltipHeight = tooltipRef.current?.offsetHeight || 150;
let top = 0 let top = 0;
let left = 0 let left = 0;
switch (step.placement) { switch (step.placement) {
case 'bottom': case 'bottom':
top = targetRect.bottom + padding top = targetRect.bottom + padding;
left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2 left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2;
break break;
case 'top': case 'top':
top = targetRect.top - tooltipHeight - padding top = targetRect.top - tooltipHeight - padding;
left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2 left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2;
break break;
case 'left': case 'left':
top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2 top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2;
left = targetRect.left - tooltipWidth - padding left = targetRect.left - tooltipWidth - padding;
break break;
case 'right': case 'right':
top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2 top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2;
left = targetRect.right + padding left = targetRect.right + padding;
break break;
} }
// Keep within viewport // Keep within viewport
const viewportWidth = window.innerWidth const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight const viewportHeight = window.innerHeight;
if (left < padding) left = padding if (left < padding) left = padding;
if (left + tooltipWidth > viewportWidth - padding) { if (left + tooltipWidth > viewportWidth - padding) {
left = viewportWidth - tooltipWidth - padding left = viewportWidth - tooltipWidth - padding;
} }
if (top < padding) top = padding if (top < padding) top = padding;
if (top + tooltipHeight > viewportHeight - padding) { if (top + tooltipHeight > viewportHeight - padding) {
top = viewportHeight - tooltipHeight - padding top = viewportHeight - tooltipHeight - padding;
} }
return { return {
@@ -149,22 +150,22 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
width: tooltipWidth, width: tooltipWidth,
opacity: isVisible ? 1 : 0, opacity: isVisible ? 1 : 0,
transform: isVisible ? 'scale(1)' : 'scale(0.95)', transform: isVisible ? 'scale(1)' : 'scale(0.95)',
} };
} };
// Spotlight style // Spotlight style
const getSpotlightStyle = (): React.CSSProperties => { const getSpotlightStyle = (): React.CSSProperties => {
if (!targetRect) return { opacity: 0 } if (!targetRect) return { opacity: 0 };
const padding = 8 const padding = 8;
return { return {
top: targetRect.top - padding, top: targetRect.top - padding,
left: targetRect.left - padding, left: targetRect.left - padding,
width: targetRect.width + padding * 2, width: targetRect.width + padding * 2,
height: targetRect.height + padding * 2, height: targetRect.height + padding * 2,
opacity: isVisible ? 1 : 0, opacity: isVisible ? 1 : 0,
} };
} };
return createPortal( return createPortal(
<div className="onboarding-overlay" style={{ opacity: isVisible ? 1 : 0 }}> <div className="onboarding-overlay" style={{ opacity: isVisible ? 1 : 0 }}>
@@ -178,7 +179,7 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
style={getTooltipStyle()} style={getTooltipStyle()}
> >
{/* Progress indicator */} {/* Progress indicator */}
<div className="flex items-center gap-1.5 mb-4"> <div className="mb-4 flex items-center gap-1.5">
{steps.map((s, index) => ( {steps.map((s, index) => (
<div <div
key={s.target} key={s.target}
@@ -194,25 +195,25 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
</div> </div>
{/* Content */} {/* Content */}
<h3 className="text-lg font-semibold text-dark-50 mb-2">{step.title}</h3> <h3 className="mb-2 text-lg font-semibold text-dark-50">{step.title}</h3>
<p className="text-dark-400 text-sm mb-5">{step.description}</p> <p className="mb-5 text-sm text-dark-400">{step.description}</p>
{/* Actions */} {/* Actions */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<button <button
onClick={handleSkip} onClick={handleSkip}
className="text-dark-500 hover:text-dark-300 text-sm transition-colors" className="text-sm text-dark-500 transition-colors hover:text-dark-300"
> >
{t('onboarding.skip', 'Skip')} {t('onboarding.skip', 'Skip')}
</button> </button>
<div className="flex gap-2"> <div className="flex gap-2">
{currentStep > 0 && ( {currentStep > 0 && (
<button onClick={handlePrev} className="btn-ghost text-sm px-3 py-1.5"> <button onClick={handlePrev} className="btn-ghost px-3 py-1.5 text-sm">
{t('common.back', 'Back')} {t('common.back', 'Back')}
</button> </button>
)} )}
<button onClick={handleNext} className="btn-primary text-sm px-4 py-1.5"> <button onClick={handleNext} className="btn-primary px-4 py-1.5 text-sm">
{currentStep === steps.length - 1 {currentStep === steps.length - 1
? t('onboarding.finish', 'Finish') ? t('onboarding.finish', 'Finish')
: t('common.next', 'Next')} : t('common.next', 'Next')}
@@ -235,6 +236,6 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
/> />
)} )}
</div>, </div>,
document.body document.body,
) );
} }

View File

@@ -1,97 +1,111 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { promoApi } from '../api/promo' import { promoApi } from '../api/promo';
const SparklesIcon = () => ( const SparklesIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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.09z" /> <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.09z"
/>
</svg> </svg>
) );
const ClockIcon = () => ( const ClockIcon = () => (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /> className="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
) );
const formatTimeLeft = (expiresAt: string, t: (key: string) => string): string => { const formatTimeLeft = (expiresAt: string, t: (key: string) => string): string => {
const now = new Date() const now = new Date();
// Ensure UTC parsing - if no timezone specified, assume UTC // Ensure UTC parsing - if no timezone specified, assume UTC
let expires: Date let expires: Date;
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) { if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
expires = new Date(expiresAt) expires = new Date(expiresAt);
} else { } else {
// No timezone - treat as UTC // No timezone - treat as UTC
expires = new Date(expiresAt + 'Z') expires = new Date(expiresAt + 'Z');
} }
const diffMs = expires.getTime() - now.getTime() const diffMs = expires.getTime() - now.getTime();
if (diffMs <= 0) return '' if (diffMs <= 0) return '';
const hours = Math.floor(diffMs / (1000 * 60 * 60)) const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) { if (hours > 24) {
const days = Math.floor(hours / 24) const days = Math.floor(hours / 24);
return `${days}${t('promo.time.days')}` return `${days}${t('promo.time.days')}`;
} }
if (hours > 0) { if (hours > 0) {
return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}` return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}`;
} }
return `${minutes}${t('promo.time.minutes')}` return `${minutes}${t('promo.time.minutes')}`;
} };
export default function PromoDiscountBadge() { export default function PromoDiscountBadge() {
const { t } = useTranslation() const { t } = useTranslation();
const navigate = useNavigate() const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null) const dropdownRef = useRef<HTMLDivElement>(null);
const { data: activeDiscount } = useQuery({ const { data: activeDiscount } = useQuery({
queryKey: ['active-discount'], queryKey: ['active-discount'],
queryFn: promoApi.getActiveDiscount, queryFn: promoApi.getActiveDiscount,
staleTime: 30000, staleTime: 30000,
refetchInterval: 60000, // Refresh every minute refetchInterval: 60000, // Refresh every minute
}) });
// Close dropdown on click outside // Close dropdown on click outside
useEffect(() => { useEffect(() => {
function handleClickOutside(event: MouseEvent) { function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false) setIsOpen(false);
} }
} }
document.addEventListener('mousedown', handleClickOutside) document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside);
}, []) }, []);
// Don't render if no active discount // Don't render if no active discount
if (!activeDiscount || !activeDiscount.is_active || !activeDiscount.discount_percent) { if (!activeDiscount || !activeDiscount.is_active || !activeDiscount.discount_percent) {
return null return null;
} }
const timeLeft = activeDiscount.expires_at ? formatTimeLeft(activeDiscount.expires_at, t) : null const timeLeft = activeDiscount.expires_at ? formatTimeLeft(activeDiscount.expires_at, t) : null;
const handleClick = () => { const handleClick = () => {
setIsOpen(!isOpen) setIsOpen(!isOpen);
} };
const handleGoToSubscription = () => { const handleGoToSubscription = () => {
setIsOpen(false) setIsOpen(false);
navigate('/subscription') navigate('/subscription');
} };
return ( return (
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
{/* Badge button */} {/* Badge button */}
<button <button
onClick={handleClick} onClick={handleClick}
className="relative flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-success-500/15 hover:bg-success-500/25 transition-all group" className="group relative flex items-center gap-1 rounded-lg bg-success-500/15 px-2.5 py-1.5 transition-all hover:bg-success-500/25"
title={t('promo.activeDiscount', 'Active discount')} title={t('promo.activeDiscount', 'Active discount')}
> >
<span className="font-bold text-success-400 text-sm"> <span className="text-sm font-bold text-success-400">
-{activeDiscount.discount_percent}% -{activeDiscount.discount_percent}%
</span> </span>
</button> </button>
@@ -101,21 +115,19 @@ export default function PromoDiscountBadge() {
<> <>
{/* Mobile backdrop */} {/* Mobile backdrop */}
<div <div
className="fixed inset-0 bg-black/30 z-40 sm:hidden" className="fixed inset-0 z-40 bg-black/30 sm:hidden"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
/> />
<div className="fixed left-4 right-4 top-20 sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-72 bg-dark-800 rounded-xl shadow-xl border border-dark-700/50 z-50 animate-fade-in overflow-hidden"> <div className="fixed left-4 right-4 top-20 z-50 animate-fade-in overflow-hidden rounded-xl border border-dark-700/50 bg-dark-800 shadow-xl sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-72">
{/* Header */} {/* Header */}
<div className="bg-gradient-to-r from-success-500/20 to-accent-500/20 p-4 border-b border-dark-700/50"> <div className="border-b border-dark-700/50 bg-gradient-to-r from-success-500/20 to-accent-500/20 p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-success-500/20 flex items-center justify-center text-success-400"> <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-success-500/20 text-success-400">
<SparklesIcon /> <SparklesIcon />
</div> </div>
<div> <div>
<div className="font-semibold text-dark-100"> <div className="font-semibold text-dark-100">{t('promo.discountActive')}</div>
{t('promo.discountActive')}
</div>
<div className="text-2xl font-bold text-success-400"> <div className="text-2xl font-bold text-success-400">
-{activeDiscount.discount_percent}% -{activeDiscount.discount_percent}%
</div> </div>
@@ -124,23 +136,24 @@ export default function PromoDiscountBadge() {
</div> </div>
{/* Content */} {/* Content */}
<div className="p-4 space-y-3"> <div className="space-y-3 p-4">
<p className="text-sm text-dark-300"> <p className="text-sm text-dark-300">{t('promo.discountDescription')}</p>
{t('promo.discountDescription')}
</p>
{/* Time remaining */} {/* Time remaining */}
{timeLeft && ( {timeLeft && (
<div className="flex items-center gap-2 text-sm text-dark-400 bg-dark-900/50 px-3 py-2 rounded-lg"> <div className="flex items-center gap-2 rounded-lg bg-dark-900/50 px-3 py-2 text-sm text-dark-400">
<ClockIcon /> <ClockIcon />
<span>{t('promo.expiresIn')}: <span className="text-warning-400 font-medium">{timeLeft}</span></span> <span>
{t('promo.expiresIn')}:{' '}
<span className="font-medium text-warning-400">{timeLeft}</span>
</span>
</div> </div>
)} )}
{/* CTA Button */} {/* CTA Button */}
<button <button
onClick={handleGoToSubscription} onClick={handleGoToSubscription}
className="w-full btn-primary py-2.5 text-sm font-medium" className="btn-primary w-full py-2.5 text-sm font-medium"
> >
{t('promo.useNow')} {t('promo.useNow')}
</button> </button>
@@ -149,5 +162,5 @@ export default function PromoDiscountBadge() {
</> </>
)} )}
</div> </div>
) );
} }

View File

@@ -1,144 +1,165 @@
import { useState } from 'react' import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { promoApi, PromoOffer } from '../api/promo' import { promoApi, PromoOffer } from '../api/promo';
// Icons // Icons
const GiftIcon = () => ( const GiftIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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 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" /> <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> </svg>
) );
const ClockIcon = () => ( const ClockIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
) );
const SparklesIcon = () => ( const SparklesIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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.09z" /> <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.09z"
/>
</svg> </svg>
) );
const CheckIcon = () => ( const CheckIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" 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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg> </svg>
) );
const ServerIcon = () => ( const ServerIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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" /> <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> </svg>
) );
// Helper functions // Helper functions
const formatTimeLeft = (expiresAt: string): string => { const formatTimeLeft = (expiresAt: string): string => {
const now = new Date() const now = new Date();
// Ensure UTC parsing - if no timezone specified, assume UTC // Ensure UTC parsing - if no timezone specified, assume UTC
let expires: Date let expires: Date;
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) { if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
expires = new Date(expiresAt) expires = new Date(expiresAt);
} else { } else {
// No timezone - treat as UTC // No timezone - treat as UTC
expires = new Date(expiresAt + 'Z') expires = new Date(expiresAt + 'Z');
} }
const diffMs = expires.getTime() - now.getTime() const diffMs = expires.getTime() - now.getTime();
if (diffMs <= 0) return 'Истекло' if (diffMs <= 0) return 'Истекло';
const hours = Math.floor(diffMs / (1000 * 60 * 60)) const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) { if (hours > 24) {
const days = Math.floor(hours / 24) const days = Math.floor(hours / 24);
return `${days} дн.` return `${days} дн.`;
} }
if (hours > 0) { if (hours > 0) {
return `${hours}ч ${minutes}м` return `${hours}ч ${minutes}м`;
} }
return `${minutes}м` return `${minutes}м`;
} };
const getOfferIcon = (effectType: string) => { const getOfferIcon = (effectType: string) => {
if (effectType === 'test_access') return <ServerIcon /> if (effectType === 'test_access') return <ServerIcon />;
return <SparklesIcon /> return <SparklesIcon />;
} };
const getOfferTitle = (offer: PromoOffer): string => { const getOfferTitle = (offer: PromoOffer): string => {
if (offer.effect_type === 'test_access') { if (offer.effect_type === 'test_access') {
return 'Тестовый доступ' return 'Тестовый доступ';
} }
if (offer.discount_percent) { if (offer.discount_percent) {
return `Скидка ${offer.discount_percent}%` return `Скидка ${offer.discount_percent}%`;
} }
return 'Специальное предложение' return 'Специальное предложение';
} };
const getOfferDescription = (offer: PromoOffer): string => { const getOfferDescription = (offer: PromoOffer): string => {
if (offer.effect_type === 'test_access') { if (offer.effect_type === 'test_access') {
const squads = offer.extra_data?.test_squad_uuids?.length || 0 const squads = offer.extra_data?.test_squad_uuids?.length || 0;
return squads > 0 ? `Доступ к ${squads} серверам` : 'Доступ к дополнительным серверам' return squads > 0 ? `Доступ к ${squads} серверам` : 'Доступ к дополнительным серверам';
} }
return 'Активируйте скидку на покупку подписки' return 'Активируйте скидку на покупку подписки';
} };
interface PromoOffersSectionProps { interface PromoOffersSectionProps {
className?: string className?: string;
} }
export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) { export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) {
const queryClient = useQueryClient() const queryClient = useQueryClient();
const [claimingId, setClaimingId] = useState<number | null>(null) const [claimingId, setClaimingId] = useState<number | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null) const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Fetch available offers // Fetch available offers
const { data: offers = [], isLoading: offersLoading } = useQuery({ const { data: offers = [], isLoading: offersLoading } = useQuery({
queryKey: ['promo-offers'], queryKey: ['promo-offers'],
queryFn: promoApi.getOffers, queryFn: promoApi.getOffers,
staleTime: 30000, staleTime: 30000,
}) });
// Fetch active discount // Fetch active discount
const { data: activeDiscount } = useQuery({ const { data: activeDiscount } = useQuery({
queryKey: ['active-discount'], queryKey: ['active-discount'],
queryFn: promoApi.getActiveDiscount, queryFn: promoApi.getActiveDiscount,
staleTime: 30000, staleTime: 30000,
}) });
// Claim offer mutation // Claim offer mutation
const claimMutation = useMutation({ const claimMutation = useMutation({
mutationFn: promoApi.claimOffer, mutationFn: promoApi.claimOffer,
onSuccess: (result) => { onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['promo-offers'] }) queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
queryClient.invalidateQueries({ queryKey: ['active-discount'] }) queryClient.invalidateQueries({ queryKey: ['active-discount'] });
queryClient.invalidateQueries({ queryKey: ['subscription'] }) queryClient.invalidateQueries({ queryKey: ['subscription'] });
setSuccessMessage(result.message) setSuccessMessage(result.message);
setClaimingId(null) setClaimingId(null);
setTimeout(() => setSuccessMessage(null), 5000) setTimeout(() => setSuccessMessage(null), 5000);
}, },
onError: (error: any) => { onError: (error: unknown) => {
setErrorMessage(error.response?.data?.detail || 'Не удалось активировать предложение') const axiosErr = error as { response?: { data?: { detail?: string } } };
setClaimingId(null) setErrorMessage(axiosErr.response?.data?.detail || 'Не удалось активировать предложение');
setTimeout(() => setErrorMessage(null), 5000) setClaimingId(null);
setTimeout(() => setErrorMessage(null), 5000);
}, },
}) });
const handleClaim = (offerId: number) => { const handleClaim = (offerId: number) => {
setClaimingId(offerId) setClaimingId(offerId);
setErrorMessage(null) setErrorMessage(null);
setSuccessMessage(null) setSuccessMessage(null);
claimMutation.mutate(offerId) claimMutation.mutate(offerId);
} };
// Filter unclaimed and active offers // Filter unclaimed and active offers
const availableOffers = offers.filter(o => o.is_active && !o.is_claimed) const availableOffers = offers.filter((o) => o.is_active && !o.is_claimed);
// Don't render if no offers and no active discount // Don't render if no offers and no active discount
if (!offersLoading && availableOffers.length === 0 && (!activeDiscount || !activeDiscount.is_active)) { if (
return null !offersLoading &&
availableOffers.length === 0 &&
(!activeDiscount || !activeDiscount.is_active)
) {
return null;
} }
return ( return (
@@ -147,15 +168,15 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
{activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && ( {activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && (
<div className="card border-accent-500/30 bg-gradient-to-br from-accent-500/10 to-transparent"> <div className="card border-accent-500/30 bg-gradient-to-br from-accent-500/10 to-transparent">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-accent-500/20 flex items-center justify-center flex-shrink-0 text-accent-400"> <div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-accent-500/20 text-accent-400">
<CheckIcon /> <CheckIcon />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="mb-1 flex items-center gap-2">
<h3 className="font-semibold text-dark-100"> <h3 className="font-semibold text-dark-100">
Скидка {activeDiscount.discount_percent}% активна Скидка {activeDiscount.discount_percent}% активна
</h3> </h3>
<span className="px-2 py-0.5 text-xs bg-accent-500/20 text-accent-400 rounded"> <span className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
Действует Действует
</span> </span>
</div> </div>
@@ -174,14 +195,14 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
{/* Success/Error Messages */} {/* Success/Error Messages */}
{successMessage && ( {successMessage && (
<div className="p-4 bg-success-500/10 border border-success-500/30 text-success-400 rounded-xl flex items-center gap-3"> <div className="flex items-center gap-3 rounded-xl border border-success-500/30 bg-success-500/10 p-4 text-success-400">
<CheckIcon /> <CheckIcon />
<span>{successMessage}</span> <span>{successMessage}</span>
</div> </div>
)} )}
{errorMessage && ( {errorMessage && (
<div className="p-4 bg-error-500/10 border border-error-500/30 text-error-400 rounded-xl"> <div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-error-400">
{errorMessage} {errorMessage}
</div> </div>
)} )}
@@ -192,26 +213,22 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
{availableOffers.map((offer) => ( {availableOffers.map((offer) => (
<div <div
key={offer.id} key={offer.id}
className="card border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-transparent hover:border-orange-500/50 transition-colors" className="card border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-transparent transition-colors hover:border-orange-500/50"
> >
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-orange-500/20 flex items-center justify-center flex-shrink-0 text-orange-400"> <div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-orange-500/20 text-orange-400">
{getOfferIcon(offer.effect_type)} {getOfferIcon(offer.effect_type)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="mb-1 flex items-center gap-2">
<h3 className="font-semibold text-dark-100"> <h3 className="font-semibold text-dark-100">{getOfferTitle(offer)}</h3>
{getOfferTitle(offer)}
</h3>
{offer.effect_type === 'test_access' && ( {offer.effect_type === 'test_access' && (
<span className="px-2 py-0.5 text-xs bg-purple-500/20 text-purple-400 rounded"> <span className="rounded bg-purple-500/20 px-2 py-0.5 text-xs text-purple-400">
Тест Тест
</span> </span>
)} )}
</div> </div>
<p className="text-sm text-dark-400 mb-3"> <p className="mb-3 text-sm text-dark-400">{getOfferDescription(offer)}</p>
{getOfferDescription(offer)}
</p>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-1 text-xs text-dark-500"> <div className="flex items-center gap-1 text-xs text-dark-500">
<ClockIcon /> <ClockIcon />
@@ -220,11 +237,11 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
<button <button
onClick={() => handleClaim(offer.id)} onClick={() => handleClaim(offer.id)}
disabled={claimingId === offer.id} disabled={claimingId === offer.id}
className="px-4 py-2 bg-orange-500 text-white text-sm font-medium rounded-lg hover:bg-orange-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" className="flex items-center gap-2 rounded-lg bg-orange-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-50"
> >
{claimingId === offer.id ? ( {claimingId === offer.id ? (
<> <>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>Активация...</span> <span>Активация...</span>
</> </>
) : ( ) : (
@@ -246,14 +263,14 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
{offersLoading && ( {offersLoading && (
<div className="card"> <div className="card">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-dark-700 animate-pulse" /> <div className="h-12 w-12 animate-pulse rounded-xl bg-dark-700" />
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<div className="h-5 w-32 bg-dark-700 rounded animate-pulse" /> <div className="h-5 w-32 animate-pulse rounded bg-dark-700" />
<div className="h-4 w-48 bg-dark-700 rounded animate-pulse" /> <div className="h-4 w-48 animate-pulse rounded bg-dark-700" />
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
) );
} }

View File

@@ -1,47 +1,45 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
interface TelegramLoginButtonProps { interface TelegramLoginButtonProps {
botUsername: string botUsername: string;
} }
export default function TelegramLoginButton({ export default function TelegramLoginButton({ botUsername }: TelegramLoginButtonProps) {
botUsername, const { t } = useTranslation();
}: TelegramLoginButtonProps) { const containerRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
// Load widget script // Load widget script
useEffect(() => { useEffect(() => {
if (!containerRef.current || !botUsername) return if (!containerRef.current || !botUsername) return;
// Clear previous widget using safe DOM API // Clear previous widget using safe DOM API
while (containerRef.current.firstChild) { while (containerRef.current.firstChild) {
containerRef.current.removeChild(containerRef.current.firstChild) containerRef.current.removeChild(containerRef.current.firstChild);
} }
// Get current URL for redirect // Get current URL for redirect
const redirectUrl = `${window.location.origin}/auth/telegram/callback` const redirectUrl = `${window.location.origin}/auth/telegram/callback`;
// Create script element for Telegram Login Widget // Create script element for Telegram Login Widget
const script = document.createElement('script') const script = document.createElement('script');
script.src = 'https://telegram.org/js/telegram-widget.js?22' script.src = 'https://telegram.org/js/telegram-widget.js?22';
script.setAttribute('data-telegram-login', botUsername) script.setAttribute('data-telegram-login', botUsername);
script.setAttribute('data-size', 'large') script.setAttribute('data-size', 'large');
script.setAttribute('data-radius', '8') script.setAttribute('data-radius', '8');
script.setAttribute('data-auth-url', redirectUrl) script.setAttribute('data-auth-url', redirectUrl);
script.setAttribute('data-request-access', 'write') script.setAttribute('data-request-access', 'write');
script.async = true script.async = true;
containerRef.current.appendChild(script) containerRef.current.appendChild(script);
}, [botUsername]) }, [botUsername]);
if (!botUsername || botUsername === 'your_bot') { if (!botUsername || botUsername === 'your_bot') {
return ( return (
<div className="text-center text-gray-500 text-sm py-4"> <div className="py-4 text-center text-sm text-gray-500">
{t('auth.telegramNotConfigured')} {t('auth.telegramNotConfigured')}
</div> </div>
) );
} }
return ( return (
@@ -51,19 +49,19 @@ export default function TelegramLoginButton({
{/* Fallback link for mobile */} {/* Fallback link for mobile */}
<div className="text-center"> <div className="text-center">
<p className="text-xs text-gray-500 mb-2">{t('auth.orOpenInApp')}</p> <p className="mb-2 text-xs text-gray-500">{t('auth.orOpenInApp')}</p>
<a <a
href={`https://t.me/${botUsername}`} href={`https://t.me/${botUsername}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center text-sm text-telegram-blue hover:underline" className="text-telegram-blue inline-flex items-center text-sm hover:underline"
> >
<svg className="w-4 h-4 mr-1" viewBox="0 0 24 24" fill="currentColor"> <svg className="mr-1 h-4 w-4" 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"/> <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> </svg>
@{botUsername} @{botUsername}
</a> </a>
</div> </div>
</div> </div>
) );
} }

View File

@@ -1,106 +1,122 @@
import { useState, useEffect, useCallback, useMemo } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { COLOR_PRESETS, ColorPreset } from '../data/colorPresets' import { COLOR_PRESETS, ColorPreset } from '../data/colorPresets';
import { hexToHsl, hslToHex, isValidHex, HSLColor } from '../utils/colorConversion' import { hexToHsl, hslToHex, isValidHex, HSLColor } from '../utils/colorConversion';
import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme' import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme';
import { applyThemeColors } from '../hooks/useThemeColors' import { applyThemeColors } from '../hooks/useThemeColors';
interface ThemeBentoPickerProps { interface ThemeBentoPickerProps {
currentColors: ThemeColors currentColors: ThemeColors;
onColorsChange: (colors: ThemeColors) => void onColorsChange: (colors: ThemeColors) => void;
onSave: () => void onSave: () => void;
isSaving: boolean isSaving: boolean;
} }
const CheckIcon = () => ( const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg> </svg>
) );
const ChevronDownIcon = () => ( const ChevronDownIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg> </svg>
) );
const MoonIcon = () => ( const MoonIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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" /> <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> </svg>
) );
const SunIcon = () => ( const SunIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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" /> <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> </svg>
) );
const StatusIcon = () => ( const StatusIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
/>
</svg> </svg>
) );
const PaletteIcon = () => ( const PaletteIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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" /> <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> </svg>
) );
function PresetCard({ function PresetCard({
preset, preset,
isSelected, isSelected,
onClick, onClick,
}: { }: {
preset: ColorPreset preset: ColorPreset;
isSelected: boolean isSelected: boolean;
onClick: () => void onClick: () => void;
}) { }) {
const { i18n } = useTranslation() const { i18n } = useTranslation();
const isRu = i18n.language === 'ru' const isRu = i18n.language === 'ru';
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className={`relative h-full w-full p-3 text-left transition-all duration-200 group rounded-2xl flex flex-col ${ className={`group relative flex h-full w-full flex-col rounded-2xl p-3 text-left transition-all duration-200 ${
isSelected isSelected
? 'bg-dark-800/90 border-2 border-accent-500 shadow-lg shadow-accent-500/20 scale-[1.02] z-10' ? 'z-10 scale-[1.02] border-2 border-accent-500 bg-dark-800/90 shadow-lg shadow-accent-500/20'
: 'bg-dark-900/60 border border-dark-700/50 hover:bg-dark-800/70 hover:border-dark-600/60 hover:scale-[1.01]' : 'border border-dark-700/50 bg-dark-900/60 hover:scale-[1.01] hover:border-dark-600/60 hover:bg-dark-800/70'
}`} }`}
> >
<div <div
className="w-full h-12 rounded-xl mb-2.5 relative overflow-hidden shrink-0" className="relative mb-2.5 h-12 w-full shrink-0 overflow-hidden rounded-xl"
style={{ backgroundColor: preset.preview.background }} style={{ backgroundColor: preset.preview.background }}
> >
<div <div
className="absolute bottom-1.5 left-1.5 w-6 h-6 rounded-lg shadow-md" className="absolute bottom-1.5 left-1.5 h-6 w-6 rounded-lg shadow-md"
style={{ backgroundColor: preset.preview.accent }} style={{ backgroundColor: preset.preview.accent }}
/> />
<div <div
className="absolute bottom-2.5 right-2 w-10 h-1 rounded-full opacity-60" className="absolute bottom-2.5 right-2 h-1 w-10 rounded-full opacity-60"
style={{ backgroundColor: preset.preview.text }} style={{ backgroundColor: preset.preview.text }}
/> />
<div <div
className="absolute bottom-5 right-2 w-7 h-1 rounded-full opacity-40" className="absolute bottom-5 right-2 h-1 w-7 rounded-full opacity-40"
style={{ backgroundColor: preset.preview.text }} style={{ backgroundColor: preset.preview.text }}
/> />
</div> </div>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<h4 className="text-xs font-semibold text-dark-100 truncate"> <h4 className="truncate text-xs font-semibold text-dark-100">
{isRu ? preset.nameRu : preset.name} {isRu ? preset.nameRu : preset.name}
</h4> </h4>
</div> </div>
{isSelected && ( {isSelected && (
<div className="w-5 h-5 rounded-full bg-accent-500 flex items-center justify-center text-white shrink-0"> <div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent-500 text-white">
<CheckIcon /> <CheckIcon />
</div> </div>
)} )}
</div> </div>
</button> </button>
) );
} }
function HSLSlider({ function HSLSlider({
@@ -111,18 +127,18 @@ function HSLSlider({
gradient, gradient,
suffix = '', suffix = '',
}: { }: {
label: string label: string;
value: number value: number;
onChange: (value: number) => void onChange: (value: number) => void;
max: number max: number;
gradient: string gradient: string;
suffix?: string suffix?: string;
}) { }) {
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-xs font-medium text-dark-300">{label}</label> <label className="text-xs font-medium text-dark-300">{label}</label>
<span className="text-xs text-dark-500 font-mono tabular-nums"> <span className="font-mono text-xs tabular-nums text-dark-500">
{value} {value}
{suffix} {suffix}
</span> </span>
@@ -133,11 +149,11 @@ function HSLSlider({
max={max} max={max}
value={value} value={value}
onChange={(e) => onChange(parseInt(e.target.value))} onChange={(e) => onChange(parseInt(e.target.value))}
className="w-full h-2.5 rounded-full appearance-none cursor-pointer" className="h-2.5 w-full cursor-pointer appearance-none rounded-full"
style={{ background: gradient }} style={{ background: gradient }}
/> />
</div> </div>
) );
} }
function CompactColorInput({ function CompactColorInput({
@@ -145,45 +161,45 @@ function CompactColorInput({
value, value,
onChange, onChange,
}: { }: {
label: string label: string;
value: string value: string;
onChange: (color: string) => void onChange: (color: string) => void;
}) { }) {
const [localValue, setLocalValue] = useState(value) const [localValue, setLocalValue] = useState(value);
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false);
useEffect(() => { useEffect(() => {
setLocalValue(value) setLocalValue(value);
}, [value]) }, [value]);
const handleChange = (newValue: string) => { const handleChange = (newValue: string) => {
let formatted = newValue.toUpperCase() let formatted = newValue.toUpperCase();
if (!formatted.startsWith('#')) { if (!formatted.startsWith('#')) {
formatted = '#' + formatted formatted = '#' + formatted;
} }
setLocalValue(formatted) setLocalValue(formatted);
if (isValidHex(formatted)) { if (isValidHex(formatted)) {
onChange(formatted) onChange(formatted);
}
} }
};
const handleBlur = () => { const handleBlur = () => {
setIsEditing(false) setIsEditing(false);
if (!isValidHex(localValue)) { if (!isValidHex(localValue)) {
setLocalValue(value) setLocalValue(value);
}
} }
};
return ( return (
<div className="flex items-center gap-2 p-2 rounded-xl bg-dark-800/40 hover:bg-dark-800/60 transition-colors group"> <div className="group flex items-center gap-2 rounded-xl bg-dark-800/40 p-2 transition-colors hover:bg-dark-800/60">
<button <button
type="button" type="button"
onClick={() => setIsEditing(true)} onClick={() => setIsEditing(true)}
className="w-8 h-8 rounded-lg border border-dark-600/50 shadow-inner shrink-0 transition-transform hover:scale-105" className="h-8 w-8 shrink-0 rounded-lg border border-dark-600/50 shadow-inner transition-transform hover:scale-105"
style={{ backgroundColor: value }} style={{ backgroundColor: value }}
/> />
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<label className="text-[10px] uppercase tracking-wide text-dark-500 block leading-none mb-0.5"> <label className="mb-0.5 block text-[10px] uppercase leading-none tracking-wide text-dark-500">
{label} {label}
</label> </label>
{isEditing ? ( {isEditing ? (
@@ -194,21 +210,21 @@ function CompactColorInput({
onBlur={handleBlur} onBlur={handleBlur}
onKeyDown={(e) => e.key === 'Enter' && handleBlur()} onKeyDown={(e) => e.key === 'Enter' && handleBlur()}
autoFocus autoFocus
className="bg-transparent text-xs font-mono text-dark-200 w-full outline-none" className="w-full bg-transparent font-mono text-xs text-dark-200 outline-none"
maxLength={7} maxLength={7}
/> />
) : ( ) : (
<button <button
type="button" type="button"
onClick={() => setIsEditing(true)} onClick={() => setIsEditing(true)}
className="text-xs font-mono text-dark-300 hover:text-dark-100 transition-colors text-left" className="text-left font-mono text-xs text-dark-300 transition-colors hover:text-dark-100"
> >
{value.toUpperCase()} {value.toUpperCase()}
</button> </button>
)} )}
</div> </div>
</div> </div>
) );
} }
function CollapsibleSection({ function CollapsibleSection({
@@ -219,24 +235,24 @@ function CollapsibleSection({
children, children,
badge, badge,
}: { }: {
title: string title: string;
icon: React.ReactNode icon: React.ReactNode;
isOpen: boolean isOpen: boolean;
onToggle: () => void onToggle: () => void;
children: React.ReactNode children: React.ReactNode;
badge?: string badge?: string;
}) { }) {
return ( return (
<div className="rounded-2xl bg-dark-900/50 border border-dark-700/40 overflow-hidden"> <div className="overflow-hidden rounded-2xl border border-dark-700/40 bg-dark-900/50">
<button <button
onClick={onToggle} onClick={onToggle}
className="w-full flex items-center justify-between px-4 py-3 hover:bg-dark-800/30 transition-colors" className="flex w-full items-center justify-between px-4 py-3 transition-colors hover:bg-dark-800/30"
> >
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="text-dark-400">{icon}</div> <div className="text-dark-400">{icon}</div>
<span className="text-sm font-medium text-dark-200">{title}</span> <span className="text-sm font-medium text-dark-200">{title}</span>
{badge && ( {badge && (
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-dark-700/50 text-dark-400 font-mono"> <span className="rounded-md bg-dark-700/50 px-1.5 py-0.5 font-mono text-[10px] text-dark-400">
{badge} {badge}
</span> </span>
)} )}
@@ -254,11 +270,11 @@ function CollapsibleSection({
}`} }`}
> >
<div className="overflow-hidden"> <div className="overflow-hidden">
<div className="px-4 pb-4 pt-1 border-t border-dark-700/30">{children}</div> <div className="border-t border-dark-700/30 px-4 pb-4 pt-1">{children}</div>
</div> </div>
</div> </div>
</div> </div>
) );
} }
export function ThemeBentoPicker({ export function ThemeBentoPicker({
@@ -267,67 +283,67 @@ export function ThemeBentoPicker({
onSave, onSave,
isSaving, isSaving,
}: ThemeBentoPickerProps) { }: ThemeBentoPickerProps) {
const { t } = useTranslation() const { t } = useTranslation();
const [hsl, setHsl] = useState<HSLColor>(() => hexToHsl(currentColors.accent)) const [hsl, setHsl] = useState<HSLColor>(() => hexToHsl(currentColors.accent));
const [hexInput, setHexInput] = useState(currentColors.accent) const [hexInput, setHexInput] = useState(currentColors.accent);
const [hasChanges, setHasChanges] = useState(false) const [hasChanges, setHasChanges] = useState(false);
const [isPresetsOpen, setIsPresetsOpen] = useState(false) const [isPresetsOpen, setIsPresetsOpen] = useState(false);
const [isAccentOpen, setIsAccentOpen] = useState(false) const [isAccentOpen, setIsAccentOpen] = useState(false);
const [isDarkOpen, setIsDarkOpen] = useState(false) const [isDarkOpen, setIsDarkOpen] = useState(false);
const [isLightOpen, setIsLightOpen] = useState(false) const [isLightOpen, setIsLightOpen] = useState(false);
const [isStatusOpen, setIsStatusOpen] = useState(false) const [isStatusOpen, setIsStatusOpen] = useState(false);
const selectedPresetId = useMemo(() => { const selectedPresetId = useMemo(() => {
const match = COLOR_PRESETS.find( const match = COLOR_PRESETS.find(
(p) => (p) =>
p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() && p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() &&
p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() && p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() &&
p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase() p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase(),
) );
return match?.id ?? null return match?.id ?? null;
}, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground]) }, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground]);
useEffect(() => { useEffect(() => {
setHsl(hexToHsl(currentColors.accent)) setHsl(hexToHsl(currentColors.accent));
setHexInput(currentColors.accent) setHexInput(currentColors.accent);
}, [currentColors.accent]) }, [currentColors.accent]);
const updateColor = useCallback( const updateColor = useCallback(
(key: keyof ThemeColors, value: string) => { (key: keyof ThemeColors, value: string) => {
const newColors = { ...currentColors, [key]: value } const newColors = { ...currentColors, [key]: value };
onColorsChange(newColors) onColorsChange(newColors);
applyThemeColors(newColors) applyThemeColors(newColors);
setHasChanges(true) setHasChanges(true);
}, },
[currentColors, onColorsChange] [currentColors, onColorsChange],
) );
const updateAccentFromHsl = useCallback( const updateAccentFromHsl = useCallback(
(newHsl: HSLColor) => { (newHsl: HSLColor) => {
setHsl(newHsl) setHsl(newHsl);
const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l) const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l);
setHexInput(newHex) setHexInput(newHex);
updateColor('accent', newHex) updateColor('accent', newHex);
}, },
[updateColor] [updateColor],
) );
const handleHexInputChange = (value: string) => { const handleHexInputChange = (value: string) => {
setHexInput(value) setHexInput(value);
if (isValidHex(value)) { if (isValidHex(value)) {
const newHsl = hexToHsl(value) const newHsl = hexToHsl(value);
setHsl(newHsl) setHsl(newHsl);
updateColor('accent', value) updateColor('accent', value);
}
} }
};
const handlePresetSelect = (preset: ColorPreset) => { const handlePresetSelect = (preset: ColorPreset) => {
onColorsChange(preset.colors) onColorsChange(preset.colors);
applyThemeColors(preset.colors) applyThemeColors(preset.colors);
setHasChanges(true) setHasChanges(true);
} };
const hueGradient = useMemo(() => { const hueGradient = useMemo(() => {
return `linear-gradient(to right, return `linear-gradient(to right,
@@ -338,23 +354,23 @@ export function ThemeBentoPicker({
hsl(240, ${hsl.s}%, ${hsl.l}%), hsl(240, ${hsl.s}%, ${hsl.l}%),
hsl(300, ${hsl.s}%, ${hsl.l}%), hsl(300, ${hsl.s}%, ${hsl.l}%),
hsl(360, ${hsl.s}%, ${hsl.l}%) hsl(360, ${hsl.s}%, ${hsl.l}%)
)` )`;
}, [hsl.s, hsl.l]) }, [hsl.s, hsl.l]);
const saturationGradient = useMemo(() => { const saturationGradient = useMemo(() => {
return `linear-gradient(to right, return `linear-gradient(to right,
hsl(${hsl.h}, 0%, ${hsl.l}%), hsl(${hsl.h}, 0%, ${hsl.l}%),
hsl(${hsl.h}, 100%, ${hsl.l}%) hsl(${hsl.h}, 100%, ${hsl.l}%)
)` )`;
}, [hsl.h, hsl.l]) }, [hsl.h, hsl.l]);
const lightnessGradient = useMemo(() => { const lightnessGradient = useMemo(() => {
return `linear-gradient(to right, return `linear-gradient(to right,
hsl(${hsl.h}, ${hsl.s}%, 0%), hsl(${hsl.h}, ${hsl.s}%, 0%),
hsl(${hsl.h}, ${hsl.s}%, 50%), hsl(${hsl.h}, ${hsl.s}%, 50%),
hsl(${hsl.h}, ${hsl.s}%, 100%) hsl(${hsl.h}, ${hsl.s}%, 100%)
)` )`;
}, [hsl.h, hsl.s]) }, [hsl.h, hsl.s]);
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@@ -365,9 +381,13 @@ export function ThemeBentoPicker({
isOpen={isPresetsOpen} isOpen={isPresetsOpen}
onToggle={() => setIsPresetsOpen(!isPresetsOpen)} onToggle={() => setIsPresetsOpen(!isPresetsOpen)}
> >
<div className="grid grid-cols-2 sm:grid-cols-3 auto-rows-fr gap-4 p-1"> <div className="grid auto-rows-fr grid-cols-2 gap-4 p-1 sm:grid-cols-3">
{COLOR_PRESETS.map((preset, index) => ( {COLOR_PRESETS.map((preset, index) => (
<div key={preset.id} className="min-h-[100px]" style={{ '--stagger': index } as React.CSSProperties}> <div
key={preset.id}
className="min-h-[100px]"
style={{ '--stagger': index } as React.CSSProperties}
>
<PresetCard <PresetCard
preset={preset} preset={preset}
isSelected={selectedPresetId === preset.id} isSelected={selectedPresetId === preset.id}
@@ -379,7 +399,7 @@ export function ThemeBentoPicker({
</CollapsibleSection> </CollapsibleSection>
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-xs font-medium text-dark-400 uppercase tracking-wide"> <h3 className="text-xs font-medium uppercase tracking-wide text-dark-400">
{t('admin.theme.customizeColors', 'Customize Colors')} {t('admin.theme.customizeColors', 'Customize Colors')}
</h3> </h3>
@@ -392,13 +412,13 @@ export function ThemeBentoPicker({
> >
<div className="space-y-4"> <div className="space-y-4">
<div <div
className="w-full h-14 rounded-xl shadow-inner relative overflow-hidden" className="relative h-14 w-full overflow-hidden rounded-xl shadow-inner"
style={{ style={{
background: `linear-gradient(135deg, ${hexInput} 0%, ${hslToHex(hsl.h, hsl.s, Math.max(20, hsl.l - 20))} 100%)`, background: `linear-gradient(135deg, ${hexInput} 0%, ${hslToHex(hsl.h, hsl.s, Math.max(20, hsl.l - 20))} 100%)`,
}} }}
> >
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent" /> <div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent" />
<div className="absolute bottom-2 right-3 text-white/80 text-xs font-mono drop-shadow"> <div className="absolute bottom-2 right-3 font-mono text-xs text-white/80 drop-shadow">
{hexInput.toUpperCase()} {hexInput.toUpperCase()}
</div> </div>
</div> </div>
@@ -431,7 +451,7 @@ export function ThemeBentoPicker({
/> />
<div> <div>
<label className="text-xs font-medium text-dark-300 mb-1.5 block"> <label className="mb-1.5 block text-xs font-medium text-dark-300">
{t('admin.theme.hexCode', 'HEX Code')} {t('admin.theme.hexCode', 'HEX Code')}
</label> </label>
<input <input
@@ -440,7 +460,7 @@ export function ThemeBentoPicker({
onChange={(e) => handleHexInputChange(e.target.value)} onChange={(e) => handleHexInputChange(e.target.value)}
placeholder="#3b82f6" placeholder="#3b82f6"
maxLength={7} maxLength={7}
className="input w-full text-sm font-mono uppercase" className="input w-full font-mono text-sm uppercase"
/> />
</div> </div>
</div> </div>
@@ -532,8 +552,8 @@ export function ThemeBentoPicker({
</CollapsibleSection> </CollapsibleSection>
</div> </div>
<div className="rounded-2xl bg-dark-900/50 border border-dark-700/40 p-4"> <div className="rounded-2xl border border-dark-700/40 bg-dark-900/50 p-4">
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-3"> <h4 className="mb-3 text-xs font-medium uppercase tracking-wide text-dark-400">
{t('theme.preview', 'Preview')} {t('theme.preview', 'Preview')}
</h4> </h4>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
@@ -548,12 +568,12 @@ export function ThemeBentoPicker({
</div> </div>
{hasChanges && ( {hasChanges && (
<div className="flex justify-end animate-fade-in"> <div className="flex animate-fade-in justify-end">
<button onClick={onSave} disabled={isSaving} className="btn-primary"> <button onClick={onSave} disabled={isSaving} className="btn-primary">
{isSaving ? t('common.saving', 'Saving...') : t('common.save', 'Save')} {isSaving ? t('common.saving', 'Saving...') : t('common.save', 'Save')}
</button> </button>
</div> </div>
)} )}
</div> </div>
) );
} }

View File

@@ -1,50 +1,55 @@
import { useState, useRef, useEffect, useCallback } from 'react' import { useState, useRef, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { ticketNotificationsApi } from '../api/ticketNotifications' import { ticketNotificationsApi } from '../api/ticketNotifications';
import { useAuthStore } from '../store/auth' import { useAuthStore } from '../store/auth';
import { useToast } from './Toast' import { useToast } from './Toast';
import { useWebSocket, WSMessage } from '../hooks/useWebSocket' import { useWebSocket, WSMessage } from '../hooks/useWebSocket';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp' import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import type { TicketNotification } from '../types' import type { TicketNotification } from '../types';
const BellIcon = () => ( const BellIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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" /> <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> </svg>
) );
const CheckIcon = () => ( const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
) );
interface TicketNotificationBellProps { interface TicketNotificationBellProps {
isAdmin?: boolean isAdmin?: boolean;
} }
export default function TicketNotificationBell({ isAdmin = false }: TicketNotificationBellProps) { export default function TicketNotificationBell({ isAdmin = false }: TicketNotificationBellProps) {
const { t } = useTranslation() const { t } = useTranslation();
const navigate = useNavigate() const navigate = useNavigate();
const queryClient = useQueryClient() const queryClient = useQueryClient();
const { isAuthenticated } = useAuthStore() const { isAuthenticated } = useAuthStore();
const { showToast } = useToast() const { showToast } = useToast();
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null) const dropdownRef = useRef<HTMLDivElement>(null);
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
// Calculate dropdown top position (account for fullscreen safe area + TG buttons) // Calculate dropdown top position (account for fullscreen safe area + TG buttons)
const dropdownTop = isFullscreen const dropdownTop = isFullscreen
? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45 + 64 // safe area + TG buttons + header ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45 + 64 // safe area + TG buttons + header
: 64 // default header height : 64; // default header height
// Show toast for WebSocket notification // Show toast for WebSocket notification
const showWSNotificationToast = useCallback((message: WSMessage) => { const showWSNotificationToast = useCallback(
const isNewTicket = message.type === 'ticket.new' (message: WSMessage) => {
const isAdminReply = message.type === 'ticket.admin_reply' const isNewTicket = message.type === 'ticket.new';
const isUserReply = message.type === 'ticket.user_reply' const isAdminReply = message.type === 'ticket.admin_reply';
const isUserReply = message.type === 'ticket.user_reply';
const icon = isNewTicket ? ( const icon = isNewTicket ? (
<span className="text-lg">🎫</span> <span className="text-lg">🎫</span>
@@ -52,22 +57,30 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
<span className="text-lg">💬</span> <span className="text-lg">💬</span>
) : ( ) : (
<span className="text-lg">📨</span> <span className="text-lg">📨</span>
) );
const ticketTitle = message.title || '' const ticketTitle = message.title || '';
let toastTitle: string let toastTitle: string;
let toastMessage: string let toastMessage: string;
if (isNewTicket) { if (isNewTicket) {
toastTitle = t('notifications.newTicketTitle', 'New Ticket') toastTitle = t('notifications.newTicketTitle', 'New Ticket');
toastMessage = message.message || t('notifications.newTicket', 'New ticket: {{title}}', { title: ticketTitle }) toastMessage =
message.message ||
t('notifications.newTicket', 'New ticket: {{title}}', { title: ticketTitle });
} else if (isUserReply) { } else if (isUserReply) {
toastTitle = t('notifications.newUserReplyTitle', 'User Reply') toastTitle = t('notifications.newUserReplyTitle', 'User Reply');
toastMessage = message.message || t('notifications.newUserReply', 'User replied in ticket: {{title}}', { title: ticketTitle }) toastMessage =
message.message ||
t('notifications.newUserReply', 'User replied in ticket: {{title}}', {
title: ticketTitle,
});
} else { } else {
toastTitle = t('notifications.newReplyTitle', 'New Reply') toastTitle = t('notifications.newReplyTitle', 'New Reply');
toastMessage = message.message || t('notifications.newReply', 'New reply in ticket: {{title}}', { title: ticketTitle }) toastMessage =
message.message ||
t('notifications.newReply', 'New reply in ticket: {{title}}', { title: ticketTitle });
} }
showToast({ showToast({
@@ -76,134 +89,164 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
message: toastMessage, message: toastMessage,
icon, icon,
onClick: () => { onClick: () => {
navigate(isAdmin ? `/admin/tickets?ticket=${message.ticket_id}` : `/support?ticket=${message.ticket_id}`) navigate(
isAdmin
? `/admin/tickets?ticket=${message.ticket_id}`
: `/support?ticket=${message.ticket_id}`,
);
}, },
duration: 8000, duration: 8000,
}) });
}, [showToast, navigate, isAdmin, t]) },
[showToast, navigate, isAdmin, t],
);
// Handle WebSocket message // Handle WebSocket message
const handleWSMessage = useCallback((message: WSMessage) => { const handleWSMessage = useCallback(
(message: WSMessage) => {
// Check if this notification is relevant for this user type // Check if this notification is relevant for this user type
const isAdminNotification = message.type === 'ticket.new' || message.type === 'ticket.user_reply' const isAdminNotification =
const isUserNotification = message.type === 'ticket.admin_reply' message.type === 'ticket.new' || message.type === 'ticket.user_reply';
const isUserNotification = message.type === 'ticket.admin_reply';
if ((isAdmin && isAdminNotification) || (!isAdmin && isUserNotification)) { if ((isAdmin && isAdminNotification) || (!isAdmin && isUserNotification)) {
// Show toast // Show toast
showWSNotificationToast(message) showWSNotificationToast(message);
// Invalidate queries to refresh count and list // Invalidate queries to refresh count and list
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
}) });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
}) });
} }
}, [isAdmin, showWSNotificationToast, queryClient]) },
[isAdmin, showWSNotificationToast, queryClient],
);
// WebSocket connection // WebSocket connection
useWebSocket({ useWebSocket({
onMessage: handleWSMessage, onMessage: handleWSMessage,
}) });
// Fetch unread count (with slower polling as fallback when WS disconnects) // Fetch unread count (with slower polling as fallback when WS disconnects)
const { data: unreadData } = useQuery({ const { data: unreadData } = useQuery({
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'], queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
queryFn: isAdmin ? ticketNotificationsApi.getAdminUnreadCount : ticketNotificationsApi.getUnreadCount, queryFn: isAdmin
? ticketNotificationsApi.getAdminUnreadCount
: ticketNotificationsApi.getUnreadCount,
enabled: isAuthenticated, enabled: isAuthenticated,
refetchInterval: 60000, // Poll every 60 seconds as fallback refetchInterval: 60000, // Poll every 60 seconds as fallback
staleTime: 30000, staleTime: 30000,
}) });
// Fetch notifications when dropdown is open // Fetch notifications when dropdown is open
const { data: notificationsData, isLoading } = useQuery({ const { data: notificationsData, isLoading } = useQuery({
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'], queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
queryFn: () => isAdmin queryFn: () =>
isAdmin
? ticketNotificationsApi.getAdminNotifications(false, 10) ? ticketNotificationsApi.getAdminNotifications(false, 10)
: ticketNotificationsApi.getNotifications(false, 10), : ticketNotificationsApi.getNotifications(false, 10),
enabled: isAuthenticated && isOpen, enabled: isAuthenticated && isOpen,
staleTime: 5000, staleTime: 5000,
}) });
// Mark all as read mutation // Mark all as read mutation
const markAllReadMutation = useMutation({ const markAllReadMutation = useMutation({
mutationFn: isAdmin ? ticketNotificationsApi.markAllAdminAsRead : ticketNotificationsApi.markAllAsRead, mutationFn: isAdmin
? ticketNotificationsApi.markAllAdminAsRead
: ticketNotificationsApi.markAllAsRead,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] }) queryClient.invalidateQueries({
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] }) queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
});
queryClient.invalidateQueries({
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
});
}, },
}) });
// Mark single as read mutation // Mark single as read mutation
const markReadMutation = useMutation({ const markReadMutation = useMutation({
mutationFn: isAdmin ? ticketNotificationsApi.markAdminAsRead : ticketNotificationsApi.markAsRead, mutationFn: isAdmin
? ticketNotificationsApi.markAdminAsRead
: ticketNotificationsApi.markAsRead,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] }) queryClient.invalidateQueries({
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] }) queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
});
queryClient.invalidateQueries({
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
});
}, },
}) });
// Close dropdown when clicking outside // Close dropdown when clicking outside
useEffect(() => { useEffect(() => {
const handleClickOutside = (event: MouseEvent) => { const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false) setIsOpen(false);
}
} }
};
document.addEventListener('mousedown', handleClickOutside) document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside);
}, []) }, []);
const handleNotificationClick = (notification: TicketNotification) => { const handleNotificationClick = (notification: TicketNotification) => {
if (!notification.is_read) { if (!notification.is_read) {
markReadMutation.mutate(notification.id) markReadMutation.mutate(notification.id);
}
setIsOpen(false)
navigate(isAdmin ? `/admin/tickets?ticket=${notification.ticket_id}` : `/support?ticket=${notification.ticket_id}`)
} }
setIsOpen(false);
navigate(
isAdmin
? `/admin/tickets?ticket=${notification.ticket_id}`
: `/support?ticket=${notification.ticket_id}`,
);
};
const formatTime = (dateStr: string) => { const formatTime = (dateStr: string) => {
const date = new Date(dateStr) const date = new Date(dateStr);
const now = new Date() const now = new Date();
const diffMs = now.getTime() - date.getTime() const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000) const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60) const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24) const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return t('notifications.justNow', 'Just now') if (diffMins < 1) return t('notifications.justNow', 'Just now');
if (diffMins < 60) return t('notifications.minutesAgo', '{{count}} min ago', { count: diffMins }) if (diffMins < 60)
if (diffHours < 24) return t('notifications.hoursAgo', '{{count}} h ago', { count: diffHours }) return t('notifications.minutesAgo', '{{count}} min ago', { count: diffMins });
return t('notifications.daysAgo', '{{count}} d ago', { count: diffDays }) if (diffHours < 24) return t('notifications.hoursAgo', '{{count}} h ago', { count: diffHours });
} return t('notifications.daysAgo', '{{count}} d ago', { count: diffDays });
};
const getNotificationIcon = (type: string) => { const getNotificationIcon = (type: string) => {
switch (type) { switch (type) {
case 'new_ticket': case 'new_ticket':
return <span className="text-lg">🎫</span> return <span className="text-lg">🎫</span>;
case 'admin_reply': case 'admin_reply':
return <span className="text-lg">💬</span> return <span className="text-lg">💬</span>;
case 'user_reply': case 'user_reply':
return <span className="text-lg">📨</span> return <span className="text-lg">📨</span>;
default: default:
return <span className="text-lg">🔔</span> return <span className="text-lg">🔔</span>;
}
} }
};
const unreadCount = unreadData?.unread_count || 0 const unreadCount = unreadData?.unread_count || 0;
return ( return (
<div className="relative" ref={dropdownRef}> <div className="relative" ref={dropdownRef}>
{/* Bell button */} {/* Bell button */}
<button <button
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
className="relative p-2 rounded-xl transition-all duration-200 bg-dark-800/50 hover:bg-dark-700 border border-dark-700/50 text-dark-400 hover:text-accent-400" className="relative rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-all duration-200 hover:bg-dark-700 hover:text-accent-400"
title={t('notifications.ticketNotifications', 'Ticket notifications')} title={t('notifications.ticketNotifications', 'Ticket notifications')}
> >
<BellIcon /> <BellIcon />
{unreadCount > 0 && ( {unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] flex items-center justify-center text-xs font-bold text-white bg-error-500 rounded-full px-1 animate-scale-in-bounce"> <span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] animate-scale-in-bounce items-center justify-center rounded-full bg-error-500 px-1 text-xs font-bold text-white">
{unreadCount > 99 ? '99+' : unreadCount} {unreadCount > 99 ? '99+' : unreadCount}
</span> </span>
)} )}
@@ -212,13 +255,13 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
{/* Dropdown */} {/* Dropdown */}
{isOpen && ( {isOpen && (
<div <div
className={`fixed sm:absolute sm:top-auto right-4 sm:right-0 left-4 sm:left-auto mt-0 sm:mt-2 w-auto sm:w-96 bg-dark-900/95 backdrop-blur-xl border border-dark-700/50 rounded-2xl shadow-2xl shadow-black/30 overflow-hidden z-50 animate-scale-in ${ className={`fixed left-4 right-4 z-50 mt-0 w-auto animate-scale-in overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900/95 shadow-2xl shadow-black/30 backdrop-blur-xl sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-96 ${
!isFullscreen ? 'top-16' : '' !isFullscreen ? 'top-16' : ''
}`} }`}
style={isFullscreen ? { top: `${dropdownTop}px` } : undefined} style={isFullscreen ? { top: `${dropdownTop}px` } : undefined}
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700/50 bg-dark-800/30"> <div className="flex items-center justify-between border-b border-dark-700/50 bg-dark-800/30 px-4 py-3">
<h3 className="text-sm font-semibold text-dark-100"> <h3 className="text-sm font-semibold text-dark-100">
{t('notifications.ticketNotifications', 'Ticket Notifications')} {t('notifications.ticketNotifications', 'Ticket Notifications')}
</h3> </h3>
@@ -226,7 +269,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
<button <button
onClick={() => markAllReadMutation.mutate()} onClick={() => markAllReadMutation.mutate()}
disabled={markAllReadMutation.isPending} disabled={markAllReadMutation.isPending}
className="flex items-center gap-1.5 text-xs text-accent-400 hover:text-accent-300 disabled:opacity-50 transition-colors" className="flex items-center gap-1.5 text-xs text-accent-400 transition-colors hover:text-accent-300 disabled:opacity-50"
> >
<CheckIcon /> <CheckIcon />
{t('notifications.markAllRead', 'Mark all read')} {t('notifications.markAllRead', 'Mark all read')}
@@ -238,32 +281,34 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
<div className="max-h-80 overflow-y-auto"> <div className="max-h-80 overflow-y-auto">
{isLoading ? ( {isLoading ? (
<div className="p-8 text-center text-dark-500"> <div className="p-8 text-center text-dark-500">
<div className="animate-spin w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full mx-auto"></div> <div className="mx-auto h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent"></div>
</div> </div>
) : notificationsData?.items && notificationsData.items.length > 0 ? ( ) : notificationsData?.items && notificationsData.items.length > 0 ? (
notificationsData.items.map((notification: TicketNotification) => ( notificationsData.items.map((notification: TicketNotification) => (
<button <button
key={notification.id} key={notification.id}
onClick={() => handleNotificationClick(notification)} onClick={() => handleNotificationClick(notification)}
className={`w-full text-left px-4 py-3 border-b border-dark-800/50 last:border-b-0 hover:bg-dark-800/50 transition-all duration-200 ${ className={`w-full border-b border-dark-800/50 px-4 py-3 text-left transition-all duration-200 last:border-b-0 hover:bg-dark-800/50 ${
!notification.is_read ? 'bg-accent-500/5' : '' !notification.is_read ? 'bg-accent-500/5' : ''
}`} }`}
> >
<div className="flex gap-3"> <div className="flex gap-3">
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-dark-800/50 flex items-center justify-center"> <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-dark-800/50">
{getNotificationIcon(notification.notification_type)} {getNotificationIcon(notification.notification_type)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<p className={`text-sm leading-relaxed ${!notification.is_read ? 'text-dark-100 font-medium' : 'text-dark-300'}`}> <p
className={`text-sm leading-relaxed ${!notification.is_read ? 'font-medium text-dark-100' : 'text-dark-300'}`}
>
{notification.message} {notification.message}
</p> </p>
<p className="text-xs text-dark-500 mt-1"> <p className="mt-1 text-xs text-dark-500">
{formatTime(notification.created_at)} {formatTime(notification.created_at)}
</p> </p>
</div> </div>
{!notification.is_read && ( {!notification.is_read && (
<div className="flex-shrink-0 pt-1"> <div className="flex-shrink-0 pt-1">
<span className="w-2.5 h-2.5 bg-accent-500 rounded-full block shadow-lg shadow-accent-500/50"></span> <span className="block h-2.5 w-2.5 rounded-full bg-accent-500 shadow-lg shadow-accent-500/50"></span>
</div> </div>
)} )}
</div> </div>
@@ -271,23 +316,25 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
)) ))
) : ( ) : (
<div className="p-8 text-center"> <div className="p-8 text-center">
<div className="w-12 h-12 rounded-2xl bg-dark-800/50 flex items-center justify-center mx-auto mb-3 text-dark-500"> <div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-dark-800/50 text-dark-500">
<BellIcon /> <BellIcon />
</div> </div>
<p className="text-sm text-dark-500">{t('notifications.noNotifications', 'No notifications')}</p> <p className="text-sm text-dark-500">
{t('notifications.noNotifications', 'No notifications')}
</p>
</div> </div>
)} )}
</div> </div>
{/* Footer */} {/* Footer */}
{notificationsData?.items && notificationsData.items.length > 0 && ( {notificationsData?.items && notificationsData.items.length > 0 && (
<div className="px-4 py-3 border-t border-dark-700/50 bg-dark-800/30"> <div className="border-t border-dark-700/50 bg-dark-800/30 px-4 py-3">
<button <button
onClick={() => { onClick={() => {
setIsOpen(false) setIsOpen(false);
navigate(isAdmin ? '/admin/tickets' : '/support') navigate(isAdmin ? '/admin/tickets' : '/support');
}} }}
className="w-full text-center text-sm text-accent-400 hover:text-accent-300 py-1 transition-colors" className="w-full py-1 text-center text-sm text-accent-400 transition-colors hover:text-accent-300"
> >
{t('notifications.viewAll', 'View all tickets')} {t('notifications.viewAll', 'View all tickets')}
</button> </button>
@@ -296,5 +343,5 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
</div> </div>
)} )}
</div> </div>
) );
} }

View File

@@ -1,94 +1,99 @@
import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react' import {
createContext,
useContext,
useState,
useCallback,
useRef,
useEffect,
ReactNode,
} from 'react';
interface ToastOptions { interface ToastOptions {
type?: 'success' | 'error' | 'info' | 'warning' type?: 'success' | 'error' | 'info' | 'warning';
message: string message: string;
title?: string title?: string;
icon?: ReactNode icon?: ReactNode;
duration?: number duration?: number;
onClick?: () => void onClick?: () => void;
} }
interface Toast extends ToastOptions { interface Toast extends ToastOptions {
id: number id: number;
} }
interface ToastContextType { interface ToastContextType {
showToast: (options: ToastOptions) => void showToast: (options: ToastOptions) => void;
} }
const ToastContext = createContext<ToastContextType | null>(null) const ToastContext = createContext<ToastContextType | null>(null);
// eslint-disable-next-line react-refresh/only-export-components
export function useToast() { export function useToast() {
const context = useContext(ToastContext) const context = useContext(ToastContext);
if (!context) { if (!context) {
throw new Error('useToast must be used within ToastProvider') throw new Error('useToast must be used within ToastProvider');
} }
return context return context;
} }
export function ToastProvider({ children }: { children: ReactNode }) { export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]) const [toasts, setToasts] = useState<Toast[]>([]);
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map()) const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const showToast = useCallback((options: ToastOptions) => { const showToast = useCallback((options: ToastOptions) => {
const id = Date.now() + Math.random() // Avoid ID collision const id = Date.now() + Math.random(); // Avoid ID collision
const toast: Toast = { id, duration: 5000, type: 'info', ...options } const toast: Toast = { id, duration: 5000, type: 'info', ...options };
setToasts(prev => [...prev, toast]) setToasts((prev) => [...prev, toast]);
const timer = setTimeout(() => { const timer = setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id)) setToasts((prev) => prev.filter((t) => t.id !== id));
timersRef.current.delete(id) timersRef.current.delete(id);
}, toast.duration) }, toast.duration);
timersRef.current.set(id, timer) timersRef.current.set(id, timer);
}, []) }, []);
const removeToast = useCallback((id: number) => { const removeToast = useCallback((id: number) => {
// Clear timer when manually removing // Clear timer when manually removing
const timer = timersRef.current.get(id) const timer = timersRef.current.get(id);
if (timer) { if (timer) {
clearTimeout(timer) clearTimeout(timer);
timersRef.current.delete(id) timersRef.current.delete(id);
} }
setToasts(prev => prev.filter(t => t.id !== id)) setToasts((prev) => prev.filter((t) => t.id !== id));
}, []) }, []);
// Cleanup all timers on unmount // Cleanup all timers on unmount
useEffect(() => { useEffect(() => {
const timers = timersRef.current const timers = timersRef.current;
return () => { return () => {
timers.forEach(timer => clearTimeout(timer)) timers.forEach((timer) => clearTimeout(timer));
timers.clear() timers.clear();
} };
}, []) }, []);
return ( return (
<ToastContext.Provider value={{ showToast }}> <ToastContext.Provider value={{ showToast }}>
{children} {children}
{/* Toast Container */} {/* Toast Container */}
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-3 pointer-events-none"> <div className="pointer-events-none fixed right-4 top-4 z-[100] flex flex-col gap-3">
{toasts.map((toast) => ( {toasts.map((toast) => (
<ToastItem <ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
key={toast.id}
toast={toast}
onClose={() => removeToast(toast.id)}
/>
))} ))}
</div> </div>
</ToastContext.Provider> </ToastContext.Provider>
) );
} }
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
const handleClick = () => { const handleClick = () => {
if (toast.onClick) { if (toast.onClick) {
toast.onClick() toast.onClick();
onClose() onClose();
}
} }
};
const typeStyles = { const typeStyles = {
success: { success: {
@@ -115,81 +120,105 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
icon: 'text-accent-400', icon: 'text-accent-400',
iconBg: 'bg-accent-500/20', iconBg: 'bg-accent-500/20',
}, },
} };
const style = typeStyles[toast.type || 'info'] const style = typeStyles[toast.type || 'info'];
const defaultIcons = { const defaultIcons = {
success: ( success: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg> </svg>
), ),
error: ( error: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
), ),
warning: ( warning: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
<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" /> 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> </svg>
), ),
info: ( info: (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> 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> </svg>
), ),
} };
return ( return (
<div <div
className={` className={`pointer-events-auto w-80 sm:w-96 ${style.bg} border backdrop-blur-xl ${style.border} animate-slide-in-right overflow-hidden rounded-2xl shadow-2xl shadow-black/20 ${toast.onClick ? 'cursor-pointer hover:scale-[1.02] active:scale-[0.98]' : ''} transition-transform duration-200`}
pointer-events-auto
w-80 sm:w-96
${style.bg}
backdrop-blur-xl
border ${style.border}
rounded-2xl
shadow-2xl shadow-black/20
overflow-hidden
animate-slide-in-right
${toast.onClick ? 'cursor-pointer hover:scale-[1.02] active:scale-[0.98]' : ''}
transition-transform duration-200
`}
onClick={handleClick} onClick={handleClick}
> >
{/* Glow effect */} {/* Glow effect */}
<div className={`absolute inset-0 ${style.bg} blur-xl opacity-50`} /> <div className={`absolute inset-0 ${style.bg} opacity-50 blur-xl`} />
<div className="relative p-4"> <div className="relative p-4">
<div className="flex gap-3"> <div className="flex gap-3">
{/* Icon */} {/* Icon */}
<div className={`flex-shrink-0 w-10 h-10 rounded-xl ${style.iconBg} flex items-center justify-center ${style.icon}`}> <div
className={`h-10 w-10 flex-shrink-0 rounded-xl ${style.iconBg} flex items-center justify-center ${style.icon}`}
>
{toast.icon || defaultIcons[toast.type || 'info']} {toast.icon || defaultIcons[toast.type || 'info']}
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0 pt-0.5"> <div className="min-w-0 flex-1 pt-0.5">
{toast.title && ( {toast.title && (
<p className="text-sm font-semibold text-dark-100 mb-0.5"> <p className="mb-0.5 text-sm font-semibold text-dark-100">{toast.title}</p>
{toast.title}
</p>
)} )}
<p className="text-sm text-dark-300 leading-relaxed"> <p className="text-sm leading-relaxed text-dark-300">{toast.message}</p>
{toast.message}
</p>
</div> </div>
{/* Close button */} {/* Close button */}
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
onClose() onClose();
}} }}
className="flex-shrink-0 w-6 h-6 rounded-lg hover:bg-dark-700/50 flex items-center justify-center text-dark-500 hover:text-dark-300 transition-colors" className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg text-dark-500 transition-colors hover:bg-dark-700/50 hover:text-dark-300"
>
<svg
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
@@ -206,5 +235,5 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
</div> </div>
</div> </div>
</div> </div>
) );
} }

View File

@@ -1,285 +1,345 @@
import { useState, useRef, useEffect, useCallback } from 'react' import { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query';
import { balanceApi } from '../api/balance' import { balanceApi } from '../api/balance';
import { useCurrency } from '../hooks/useCurrency' import { useCurrency } from '../hooks/useCurrency';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp' import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit' import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
import type { PaymentMethod } from '../types' import type { PaymentMethod } from '../types';
import BentoCard from './ui/BentoCard' import BentoCard from './ui/BentoCard';
// Icons // Icons
const CloseIcon = () => ( const CloseIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
) );
const WalletIcon = () => ( const WalletIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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 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" /> <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> </svg>
) );
const StarIcon = () => ( const StarIcon = () => (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" /> <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg> </svg>
) );
const CardIcon = () => ( const CardIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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 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" /> <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> </svg>
) );
const CryptoIcon = () => ( const CryptoIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"
/>
</svg> </svg>
) );
const SparklesIcon = () => ( const SparklesIcon = () => (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"> <svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<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.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" /> <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.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> </svg>
) );
const ExternalLinkIcon = () => ( const ExternalLinkIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg> </svg>
) );
const CopyIcon = () => ( const CopyIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" 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" /> <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> </svg>
) );
const CheckIcon = () => ( const CheckIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" 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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg> </svg>
) );
interface TopUpModalProps { interface TopUpModalProps {
method: PaymentMethod method: PaymentMethod;
onClose: () => void onClose: () => void;
initialAmountRubles?: number initialAmountRubles?: number;
} }
function useIsMobile() { function useIsMobile() {
const [isMobile, setIsMobile] = useState(() => { const [isMobile, setIsMobile] = useState(() => {
if (typeof window === 'undefined') return false if (typeof window === 'undefined') return false;
return window.innerWidth < 640 return window.innerWidth < 640;
}) });
useEffect(() => { useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 640) const check = () => setIsMobile(window.innerWidth < 640);
window.addEventListener('resize', check) window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check) return () => window.removeEventListener('resize', check);
}, []) }, []);
return isMobile return isMobile;
} }
// Get method icon based on method type // Get method icon based on method type
const getMethodIcon = (methodId: string) => { const getMethodIcon = (methodId: string) => {
const id = methodId.toLowerCase() const id = methodId.toLowerCase();
if (id.includes('stars')) return <StarIcon /> if (id.includes('stars')) return <StarIcon />;
if (id.includes('crypto') || id.includes('ton') || id.includes('usdt')) return <CryptoIcon /> if (id.includes('crypto') || id.includes('ton') || id.includes('usdt')) return <CryptoIcon />;
return <CardIcon /> return <CardIcon />;
} };
export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) { export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) {
const { t } = useTranslation() const { t } = useTranslation();
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency() const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } =
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp() useCurrency();
const inputRef = useRef<HTMLInputElement>(null) const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp();
const isMobileScreen = useIsMobile() const inputRef = useRef<HTMLInputElement>(null);
const isMobileScreen = useIsMobile();
const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0 const safeBottom = isTelegramWebApp
? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom)
: 0;
const getInitialAmount = (): string => { const getInitialAmount = (): string => {
if (!initialAmountRubles || initialAmountRubles <= 0) return '' if (!initialAmountRubles || initialAmountRubles <= 0) return '';
const converted = convertAmount(initialAmountRubles) const converted = convertAmount(initialAmountRubles);
return (targetCurrency === 'IRR' || targetCurrency === 'RUB') return targetCurrency === 'IRR' || targetCurrency === 'RUB'
? Math.ceil(converted).toString() ? Math.ceil(converted).toString()
: converted.toFixed(2) : converted.toFixed(2);
} };
const [amount, setAmount] = useState(getInitialAmount) const [amount, setAmount] = useState(getInitialAmount);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
const [selectedOption, setSelectedOption] = useState<string | null>( const [selectedOption, setSelectedOption] = useState<string | null>(
method.options && method.options.length > 0 ? method.options[0].id : null method.options && method.options.length > 0 ? method.options[0].id : null,
) );
const [paymentUrl, setPaymentUrl] = useState<string | null>(null) const [paymentUrl, setPaymentUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false);
const [isInputFocused, setIsInputFocused] = useState(false) const [isInputFocused, setIsInputFocused] = useState(false);
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
onClose() onClose();
}, [onClose]) }, [onClose]);
// Keyboard: Escape to close (PC) // Keyboard: Escape to close (PC)
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault() e.preventDefault();
handleClose() handleClose();
} }
} };
document.addEventListener('keydown', handleKeyDown) document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleClose]) }, [handleClose]);
// Telegram back button (Android) // Telegram back button (Android)
useEffect(() => { useEffect(() => {
if (!webApp?.BackButton) return if (!webApp?.BackButton) return;
webApp.BackButton.show() webApp.BackButton.show();
webApp.BackButton.onClick(handleClose) webApp.BackButton.onClick(handleClose);
return () => { return () => {
webApp.BackButton.offClick(handleClose) webApp.BackButton.offClick(handleClose);
webApp.BackButton.hide() webApp.BackButton.hide();
} };
}, [webApp, handleClose]) }, [webApp, handleClose]);
// Scroll lock // Scroll lock
useEffect(() => { useEffect(() => {
const scrollY = window.scrollY const scrollY = window.scrollY;
const preventScroll = (e: TouchEvent) => { const preventScroll = (e: TouchEvent) => {
const target = e.target as HTMLElement const target = e.target as HTMLElement;
if (target.closest('[data-modal-content]')) return if (target.closest('[data-modal-content]')) return;
e.preventDefault() e.preventDefault();
} };
const preventWheel = (e: WheelEvent) => { const preventWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement const target = e.target as HTMLElement;
if (target.closest('[data-modal-content]')) return if (target.closest('[data-modal-content]')) return;
e.preventDefault() e.preventDefault();
} };
document.addEventListener('touchmove', preventScroll, { passive: false }) document.addEventListener('touchmove', preventScroll, { passive: false });
document.addEventListener('wheel', preventWheel, { passive: false }) document.addEventListener('wheel', preventWheel, { passive: false });
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden';
return () => { return () => {
document.removeEventListener('touchmove', preventScroll) document.removeEventListener('touchmove', preventScroll);
document.removeEventListener('wheel', preventWheel) document.removeEventListener('wheel', preventWheel);
document.body.style.overflow = '' document.body.style.overflow = '';
window.scrollTo(0, scrollY) window.scrollTo(0, scrollY);
} };
}, []) }, []);
const hasOptions = method.options && method.options.length > 0 const hasOptions = method.options && method.options.length > 0;
const minRubles = method.min_amount_kopeks / 100 const minRubles = method.min_amount_kopeks / 100;
const maxRubles = method.max_amount_kopeks / 100 const maxRubles = method.max_amount_kopeks / 100;
const methodKey = method.id.toLowerCase().replace(/-/g, '_') const methodKey = method.id.toLowerCase().replace(/-/g, '_');
const isStarsMethod = methodKey.includes('stars') const isStarsMethod = methodKey.includes('stars');
const methodName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name const methodName =
t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name;
const starsPaymentMutation = useMutation({ const starsPaymentMutation = useMutation({
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks), mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
onSuccess: (data) => { onSuccess: (data) => {
const webApp = window.Telegram?.WebApp const webApp = window.Telegram?.WebApp;
if (!data.invoice_url) { setError('Сервер не вернул ссылку на оплату'); return } if (!data.invoice_url) {
if (!webApp?.openInvoice) { setError('Оплата Stars доступна только в Telegram Mini App'); return } setError('Сервер не вернул ссылку на оплату');
return;
}
if (!webApp?.openInvoice) {
setError('Оплата Stars доступна только в Telegram Mini App');
return;
}
try { try {
webApp.openInvoice(data.invoice_url, (status) => { webApp.openInvoice(data.invoice_url, (status) => {
if (status === 'paid') { setError(null); onClose() } if (status === 'paid') {
else if (status === 'failed') { setError(t('wheel.starsPaymentFailed')) } setError(null);
}) onClose();
} catch (e) { setError('Ошибка: ' + String(e)) } } else if (status === 'failed') {
setError(t('wheel.starsPaymentFailed'));
}
});
} catch (e) {
setError('Ошибка: ' + String(e));
}
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
const axiosError = err as { response?: { data?: { detail?: string }, status?: number } } const axiosError = err as { response?: { data?: { detail?: string }; status?: number } };
setError(`Ошибка: ${axiosError?.response?.data?.detail || 'Не удалось создать счёт'}`) setError(`Ошибка: ${axiosError?.response?.data?.detail || 'Не удалось создать счёт'}`);
}, },
}) });
const topUpMutation = useMutation<{ const topUpMutation = useMutation<
payment_id: string; payment_url?: string; invoice_url?: string {
amount_kopeks: number; amount_rubles: number; status: string; expires_at: string | null payment_id: string;
}, unknown, number>({ payment_url?: string;
mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined), invoice_url?: string;
amount_kopeks: number;
amount_rubles: number;
status: string;
expires_at: string | null;
},
unknown,
number
>({
mutationFn: (amountKopeks: number) =>
balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined),
onSuccess: (data) => { onSuccess: (data) => {
const redirectUrl = data.payment_url || (data as any).invoice_url const redirectUrl = data.payment_url || data.invoice_url;
if (redirectUrl) { if (redirectUrl) {
// Always show the payment link for user to click manually // Always show the payment link for user to click manually
// This ensures it works on all platforms including iOS Safari // This ensures it works on all platforms including iOS Safari
setPaymentUrl(redirectUrl) setPaymentUrl(redirectUrl);
} }
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '' const detail =
setError(detail.includes('not yet implemented') ? t('balance.useBot') : (detail || t('common.error'))) (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '';
setError(
detail.includes('not yet implemented') ? t('balance.useBot') : detail || t('common.error'),
);
}, },
}) });
const handleSubmit = () => { const handleSubmit = () => {
setError(null) setError(null);
setPaymentUrl(null) setPaymentUrl(null);
inputRef.current?.blur() inputRef.current?.blur();
if (!checkRateLimit(RATE_LIMIT_KEYS.PAYMENT, 3, 30000)) { if (!checkRateLimit(RATE_LIMIT_KEYS.PAYMENT, 3, 30000)) {
setError('Подождите ' + getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) + ' сек.') setError('Подождите ' + getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) + ' сек.');
return return;
} }
if (hasOptions && !selectedOption) { setError('Выберите способ'); return } if (hasOptions && !selectedOption) {
const amountCurrency = parseFloat(amount) setError('Выберите способ');
if (isNaN(amountCurrency) || amountCurrency <= 0) { setError('Введите сумму'); return } return;
const amountRubles = convertToRub(amountCurrency) }
const amountCurrency = parseFloat(amount);
if (isNaN(amountCurrency) || amountCurrency <= 0) {
setError('Введите сумму');
return;
}
const amountRubles = convertToRub(amountCurrency);
if (amountRubles < minRubles || amountRubles > maxRubles) { if (amountRubles < minRubles || amountRubles > maxRubles) {
setError(`Сумма: ${minRubles} ${maxRubles}`); return setError(`Сумма: ${minRubles} ${maxRubles}`);
return;
} }
const amountKopeks = Math.round(amountRubles * 100) const amountKopeks = Math.round(amountRubles * 100);
if (isStarsMethod) { starsPaymentMutation.mutate(amountKopeks) } if (isStarsMethod) {
else { topUpMutation.mutate(amountKopeks) } starsPaymentMutation.mutate(amountKopeks);
} else {
topUpMutation.mutate(amountKopeks);
} }
};
const quickAmounts = [100, 300, 500, 1000].filter((a) => a >= minRubles && a <= maxRubles) const quickAmounts = [100, 300, 500, 1000].filter((a) => a >= minRubles && a <= maxRubles);
const currencyDecimals = (targetCurrency === 'IRR' || targetCurrency === 'RUB') ? 0 : 2 const currencyDecimals = targetCurrency === 'IRR' || targetCurrency === 'RUB' ? 0 : 2;
const getQuickValue = (rub: number) => (targetCurrency === 'IRR') const getQuickValue = (rub: number) =>
targetCurrency === 'IRR'
? Math.round(convertAmount(rub)).toString() ? Math.round(convertAmount(rub)).toString()
: convertAmount(rub).toFixed(currencyDecimals) : convertAmount(rub).toFixed(currencyDecimals);
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending const isPending = topUpMutation.isPending || starsPaymentMutation.isPending;
const handleCopyUrl = async () => { const handleCopyUrl = async () => {
if (!paymentUrl) return if (!paymentUrl) return;
try { try {
await navigator.clipboard.writeText(paymentUrl) await navigator.clipboard.writeText(paymentUrl);
setCopied(true) setCopied(true);
setTimeout(() => setCopied(false), 2000) setTimeout(() => setCopied(false), 2000);
} catch (e) { } catch (e) {
console.warn('Failed to copy:', e) console.warn('Failed to copy:', e);
} }
} };
// Auto-focus input - works on mobile in Telegram WebApp // Auto-focus input - works on mobile in Telegram WebApp
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (inputRef.current) { if (inputRef.current) {
inputRef.current.focus() inputRef.current.focus();
if (isMobileScreen) { if (isMobileScreen) {
inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' }) inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
} }
} }
}, 100) }, 100);
return () => clearTimeout(timer) return () => clearTimeout(timer);
}, []) // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Content JSX - shared between mobile and desktop // Content JSX - shared between mobile and desktop
const contentJSX = ( const contentJSX = (
<div className="space-y-5"> <div className="space-y-5">
{/* Header icon and method */} {/* Header icon and method */}
<div className="flex items-center gap-4 pb-1"> <div className="flex items-center gap-4 pb-1">
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center ${ <div
className={`flex h-14 w-14 items-center justify-center rounded-2xl ${
isStarsMethod isStarsMethod
? 'bg-gradient-to-br from-yellow-500/20 to-orange-500/20 text-yellow-400' ? 'bg-gradient-to-br from-yellow-500/20 to-orange-500/20 text-yellow-400'
: 'bg-gradient-to-br from-accent-500/20 to-accent-600/20 text-accent-400' : 'bg-gradient-to-br from-accent-500/20 to-accent-600/20 text-accent-400'
}`}> }`}
<div className="w-7 h-7 flex items-center justify-center"> >
{getMethodIcon(method.id)} <div className="flex h-7 w-7 items-center justify-center">{getMethodIcon(method.id)}</div>
</div>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-bold text-dark-100">{methodName}</h3> <h3 className="text-lg font-bold text-dark-100">{methodName}</h3>
@@ -299,16 +359,16 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
key={opt.id} key={opt.id}
type="button" type="button"
onClick={() => setSelectedOption(opt.id)} onClick={() => setSelectedOption(opt.id)}
className={`relative py-3 px-4 rounded-xl text-sm font-semibold transition-all duration-200 ${ className={`relative rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-200 ${
selectedOption === opt.id selectedOption === opt.id
? 'bg-accent-500/15 text-accent-400 ring-2 ring-accent-500/40' ? 'bg-accent-500/15 text-accent-400 ring-2 ring-accent-500/40'
: 'bg-dark-800/70 text-dark-300 hover:bg-dark-700/70 border border-dark-700/50' : 'border border-dark-700/50 bg-dark-800/70 text-dark-300 hover:bg-dark-700/70'
}`} }`}
> >
{opt.name} {opt.name}
{selectedOption === opt.id && ( {selectedOption === opt.id && (
<span className="absolute top-1.5 right-1.5"> <span className="absolute right-1.5 top-1.5">
<span className="w-2 h-2 rounded-full bg-accent-500 block" /> <span className="block h-2 w-2 rounded-full bg-accent-500" />
</span> </span>
)} )}
</button> </button>
@@ -321,11 +381,13 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-dark-400">{t('balance.enterAmount')}</label> <label className="text-sm font-medium text-dark-400">{t('balance.enterAmount')}</label>
<div className="flex gap-2"> <div className="flex gap-2">
<div className={`relative flex-1 rounded-2xl transition-all duration-200 ${ <div
className={`relative flex-1 rounded-2xl transition-all duration-200 ${
isInputFocused isInputFocused
? 'ring-2 ring-accent-500/50 bg-dark-800' ? 'bg-dark-800 ring-2 ring-accent-500/50'
: 'bg-dark-800/70 border border-dark-700/50' : 'border border-dark-700/50 bg-dark-800/70'
}`}> }`}
>
<input <input
ref={inputRef} ref={inputRef}
type="number" type="number"
@@ -335,9 +397,14 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
onChange={(e) => setAmount(e.target.value)} onChange={(e) => setAmount(e.target.value)}
onFocus={() => setIsInputFocused(true)} onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)} onBlur={() => setIsInputFocused(false)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSubmit() } }} onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
}}
placeholder="0" placeholder="0"
className="w-full h-14 px-4 pr-12 text-xl font-bold bg-transparent text-dark-100 placeholder:text-dark-600 focus:outline-none" className="h-14 w-full bg-transparent px-4 pr-12 text-xl font-bold text-dark-100 placeholder:text-dark-600 focus:outline-none"
autoComplete="off" autoComplete="off"
autoFocus autoFocus
/> />
@@ -349,16 +416,16 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
type="button" type="button"
onClick={handleSubmit} onClick={handleSubmit}
disabled={isPending || !amount || parseFloat(amount) <= 0} disabled={isPending || !amount || parseFloat(amount) <= 0}
className={`shrink-0 h-14 px-6 rounded-2xl text-base font-bold transition-colors duration-200 overflow-hidden flex items-center justify-center gap-2 ${ className={`flex h-14 shrink-0 items-center justify-center gap-2 overflow-hidden rounded-2xl px-6 text-base font-bold transition-colors duration-200 ${
isPending || !amount || parseFloat(amount) <= 0 isPending || !amount || parseFloat(amount) <= 0
? 'bg-dark-700 text-dark-500 cursor-not-allowed' ? 'cursor-not-allowed bg-dark-700 text-dark-500'
: isStarsMethod : isStarsMethod
? 'bg-gradient-to-r from-yellow-500 to-orange-500 text-white shadow-lg shadow-yellow-500/25 hover:from-yellow-400 hover:to-orange-400 active:from-yellow-600 active:to-orange-600' ? 'bg-gradient-to-r from-yellow-500 to-orange-500 text-white shadow-lg shadow-yellow-500/25 hover:from-yellow-400 hover:to-orange-400 active:from-yellow-600 active:to-orange-600'
: 'bg-gradient-to-r from-accent-500 to-accent-600 text-white shadow-lg shadow-accent-500/25 hover:from-accent-400 hover:to-accent-500 active:from-accent-600 active:to-accent-700' : 'bg-gradient-to-r from-accent-500 to-accent-600 text-white shadow-lg shadow-accent-500/25 hover:from-accent-400 hover:to-accent-500 active:from-accent-600 active:to-accent-700'
}`} }`}
> >
{isPending ? ( {isPending ? (
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" /> <span className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : ( ) : (
<> <>
<SparklesIcon /> <SparklesIcon />
@@ -373,39 +440,54 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
{quickAmounts.length > 0 && ( {quickAmounts.length > 0 && (
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
{quickAmounts.map((a) => { {quickAmounts.map((a) => {
const val = getQuickValue(a) const val = getQuickValue(a);
const isSelected = amount === val const isSelected = amount === val;
return ( return (
<BentoCard <BentoCard
key={a} key={a}
as="button" as="button"
type="button" type="button"
onClick={() => { setAmount(val); inputRef.current?.blur() }} onClick={() => {
setAmount(val);
inputRef.current?.blur();
}}
hover hover
glow={isSelected} glow={isSelected}
className={`flex flex-col items-center justify-center py-3 px-2 ${ className={`flex flex-col items-center justify-center px-2 py-3 ${
isSelected isSelected ? 'border-accent-500/50 bg-accent-500/10' : ''
? 'border-accent-500/50 bg-accent-500/10'
: ''
}`} }`}
> >
<span className={`text-base font-bold ${isSelected ? 'text-accent-400' : 'text-dark-200'}`}> <span
className={`text-base font-bold ${isSelected ? 'text-accent-400' : 'text-dark-200'}`}
>
{formatAmount(a, 0)} {formatAmount(a, 0)}
</span> </span>
<span className={`text-xs mt-0.5 ${isSelected ? 'text-accent-400/70' : 'text-dark-500'}`}> <span
className={`mt-0.5 text-xs ${isSelected ? 'text-accent-400/70' : 'text-dark-500'}`}
>
{currencySymbol} {currencySymbol}
</span> </span>
</BentoCard> </BentoCard>
) );
})} })}
</div> </div>
)} )}
{/* Error message */} {/* Error message */}
{error && ( {error && (
<div className="flex items-center gap-2 p-3 rounded-xl bg-error-500/10 border border-error-500/20"> <div className="flex items-center gap-2 rounded-xl border border-error-500/20 bg-error-500/10 p-3">
<svg className="w-5 h-5 text-error-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> className="h-5 w-5 shrink-0 text-error-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
<span className="text-sm text-error-400">{error}</span> <span className="text-sm text-error-400">{error}</span>
</div> </div>
@@ -413,14 +495,19 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
{/* Payment link display - shown when URL is received */} {/* Payment link display - shown when URL is received */}
{paymentUrl && ( {paymentUrl && (
<div className="space-y-3 p-4 rounded-2xl bg-success-500/10 border border-success-500/20"> <div className="space-y-3 rounded-2xl border border-success-500/20 bg-success-500/10 p-4">
<div className="flex items-center gap-2 text-success-400"> <div className="flex items-center gap-2 text-success-400">
<CheckIcon /> <CheckIcon />
<span className="font-semibold">{t('balance.paymentReady', 'Ссылка на оплату готова')}</span> <span className="font-semibold">
{t('balance.paymentReady', 'Ссылка на оплату готова')}
</span>
</div> </div>
<p className="text-sm text-dark-400"> <p className="text-sm text-dark-400">
{t('balance.clickToOpenPayment', 'Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке')} {t(
'balance.clickToOpenPayment',
'Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке',
)}
</p> </p>
{/* Main open button - NO preventDefault, let <a> work natively for iOS Safari */} {/* Main open button - NO preventDefault, let <a> work natively for iOS Safari */}
@@ -428,7 +515,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
href={paymentUrl} href={paymentUrl}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full h-12 rounded-xl bg-success-500 text-white font-bold hover:bg-success-400 active:bg-success-600 transition-colors" className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-success-500 font-bold text-white transition-colors hover:bg-success-400 active:bg-success-600"
> >
<ExternalLinkIcon /> <ExternalLinkIcon />
<span>{t('balance.openPaymentPage', 'Открыть страницу оплаты')}</span> <span>{t('balance.openPaymentPage', 'Открыть страницу оплаты')}</span>
@@ -436,13 +523,13 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
{/* Copy and link display */} {/* Copy and link display */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1 min-w-0 px-3 py-2 rounded-lg bg-dark-800/70 border border-dark-700/50"> <div className="min-w-0 flex-1 rounded-lg border border-dark-700/50 bg-dark-800/70 px-3 py-2">
<p className="text-xs text-dark-500 truncate">{paymentUrl}</p> <p className="truncate text-xs text-dark-500">{paymentUrl}</p>
</div> </div>
<button <button
type="button" type="button"
onClick={handleCopyUrl} onClick={handleCopyUrl}
className={`shrink-0 p-2.5 rounded-lg transition-colors ${ className={`shrink-0 rounded-lg p-2.5 transition-colors ${
copied copied
? 'bg-success-500/20 text-success-400' ? 'bg-success-500/20 text-success-400'
: 'bg-dark-800/70 text-dark-400 hover:bg-dark-700 hover:text-dark-200' : 'bg-dark-800/70 text-dark-400 hover:bg-dark-700 hover:text-dark-200'
@@ -455,49 +542,48 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
</div> </div>
)} )}
</div> </div>
) );
// Render modal based on screen size - NO nested components! // Render modal based on screen size - NO nested components!
const modalContent = isMobileScreen ? ( const modalContent = isMobileScreen ? (
<> <>
{/* Backdrop */} {/* Backdrop */}
<div <div className="fixed inset-0 z-[9998] bg-black/70" onClick={handleClose} />
className="fixed inset-0 z-[9998] bg-black/70"
onClick={handleClose}
/>
{/* Bottom sheet */} {/* Bottom sheet */}
<div <div
data-modal-content data-modal-content
className="fixed inset-x-0 bottom-0 z-[9999] bg-dark-900 rounded-t-3xl max-h-[90vh] flex flex-col overflow-hidden" className="fixed inset-x-0 bottom-0 z-[9999] flex max-h-[90vh] flex-col overflow-hidden rounded-t-3xl bg-dark-900"
style={{ paddingBottom: safeBottom ? `${safeBottom + 20}px` : 'max(20px, env(safe-area-inset-bottom))' }} style={{
paddingBottom: safeBottom
? `${safeBottom + 20}px`
: 'max(20px, env(safe-area-inset-bottom))',
}}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Handle bar */} {/* Handle bar */}
<div className="flex justify-center pt-3 pb-1"> <div className="flex justify-center pb-1 pt-3">
<div className="w-10 h-1 rounded-full bg-dark-600" /> <div className="h-1 w-10 rounded-full bg-dark-600" />
</div> </div>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-5 py-2"> <div className="flex items-center justify-between px-5 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<WalletIcon /> <WalletIcon />
<span className="font-bold text-dark-100 text-lg">{t('balance.topUp')}</span> <span className="text-lg font-bold text-dark-100">{t('balance.topUp')}</span>
</div> </div>
<button <button
onClick={handleClose} onClick={handleClose}
className="p-2 -mr-2 rounded-xl hover:bg-dark-800 text-dark-400 transition-colors" className="-mr-2 rounded-xl p-2 text-dark-400 transition-colors hover:bg-dark-800"
> >
<CloseIcon /> <CloseIcon />
</button> </button>
</div> </div>
{/* Divider */} {/* Divider */}
<div className="h-px bg-gradient-to-r from-transparent via-dark-700 to-transparent mx-5" /> <div className="mx-5 h-px bg-gradient-to-r from-transparent via-dark-700 to-transparent" />
{/* Content */} {/* Content */}
<div className="px-5 py-5 overflow-y-auto"> <div className="overflow-y-auto px-5 py-5">{contentJSX}</div>
{contentJSX}
</div>
</div> </div>
</> </>
) : ( ) : (
@@ -507,35 +593,33 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
> >
<div <div
data-modal-content data-modal-content
className="w-full max-w-md bg-dark-900 rounded-3xl border border-dark-700/50 shadow-2xl overflow-hidden" className="w-full max-w-md overflow-hidden rounded-3xl border border-dark-700/50 bg-dark-900 shadow-2xl"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 bg-gradient-to-r from-dark-800/80 to-dark-800/40 border-b border-dark-700/50"> <div className="flex items-center justify-between border-b border-dark-700/50 bg-gradient-to-r from-dark-800/80 to-dark-800/40 px-6 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-accent-500/10 flex items-center justify-center text-accent-400"> <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent-500/10 text-accent-400">
<WalletIcon /> <WalletIcon />
</div> </div>
<span className="font-bold text-dark-100 text-lg">{t('balance.topUp')}</span> <span className="text-lg font-bold text-dark-100">{t('balance.topUp')}</span>
</div> </div>
<button <button
onClick={handleClose} onClick={handleClose}
className="p-2 -mr-1 rounded-xl hover:bg-dark-700 text-dark-400 transition-colors" className="-mr-1 rounded-xl p-2 text-dark-400 transition-colors hover:bg-dark-700"
> >
<CloseIcon /> <CloseIcon />
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="p-6"> <div className="p-6">{contentJSX}</div>
{contentJSX}
</div> </div>
</div> </div>
</div> );
)
if (typeof document !== 'undefined') { if (typeof document !== 'undefined') {
return createPortal(modalContent, document.body) return createPortal(modalContent, document.body);
} }
return modalContent return modalContent;
} }

View File

@@ -1,97 +1,99 @@
import { useState } from 'react' 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 } from './icons';
export function AnalyticsTab() { export function AnalyticsTab() {
const { t } = useTranslation() const { t } = useTranslation();
const queryClient = useQueryClient() const queryClient = useQueryClient();
// Editing states // Editing states
const [editingYandex, setEditingYandex] = useState(false) const [editingYandex, setEditingYandex] = useState(false);
const [editingGoogleId, setEditingGoogleId] = useState(false) const [editingGoogleId, setEditingGoogleId] = useState(false);
const [editingGoogleLabel, setEditingGoogleLabel] = useState(false) const [editingGoogleLabel, setEditingGoogleLabel] = useState(false);
const [yandexValue, setYandexValue] = useState('') const [yandexValue, setYandexValue] = useState('');
const [googleIdValue, setGoogleIdValue] = useState('') const [googleIdValue, setGoogleIdValue] = useState('');
const [googleLabelValue, setGoogleLabelValue] = useState('') const [googleLabelValue, setGoogleLabelValue] = useState('');
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
// Query // Query
const { data: analytics } = useQuery({ const { data: analytics } = useQuery({
queryKey: ['analytics-counters'], queryKey: ['analytics-counters'],
queryFn: brandingApi.getAnalyticsCounters, queryFn: brandingApi.getAnalyticsCounters,
}) });
// Mutation // Mutation
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: brandingApi.updateAnalyticsCounters, mutationFn: brandingApi.updateAnalyticsCounters,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['analytics-counters'] }) queryClient.invalidateQueries({ queryKey: ['analytics-counters'] });
setError(null) setError(null);
}, },
onError: (err: unknown) => { onError: (err: unknown) => {
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
setError(detail || t('common.error')) setError(detail || t('common.error'));
}, },
}) });
const handleSaveYandex = () => { const handleSaveYandex = () => {
updateMutation.mutate( updateMutation.mutate(
{ yandex_metrika_id: yandexValue.trim() }, { yandex_metrika_id: yandexValue.trim() },
{ onSuccess: () => setEditingYandex(false) }, { onSuccess: () => setEditingYandex(false) },
) );
} };
const handleSaveGoogleId = () => { const handleSaveGoogleId = () => {
updateMutation.mutate( updateMutation.mutate(
{ google_ads_id: googleIdValue.trim() }, { google_ads_id: googleIdValue.trim() },
{ onSuccess: () => setEditingGoogleId(false) }, { onSuccess: () => setEditingGoogleId(false) },
) );
} };
const handleSaveGoogleLabel = () => { const handleSaveGoogleLabel = () => {
updateMutation.mutate( updateMutation.mutate(
{ google_ads_label: googleLabelValue.trim() }, { google_ads_label: googleLabelValue.trim() },
{ onSuccess: () => setEditingGoogleLabel(false) }, { onSuccess: () => setEditingGoogleLabel(false) },
) );
} };
const yandexActive = Boolean(analytics?.yandex_metrika_id) const yandexActive = Boolean(analytics?.yandex_metrika_id);
const googleActive = Boolean(analytics?.google_ads_id) const googleActive = Boolean(analytics?.google_ads_id);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Error message */} {/* Error message */}
{error && ( {error && (
<div className="p-4 rounded-2xl bg-error-500/10 border border-error-500/30 text-error-400 text-sm"> <div className="rounded-2xl border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
{error} {error}
</div> </div>
)} )}
{/* Yandex Metrika */} {/* Yandex Metrika */}
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50"> <div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<div className="flex items-center justify-between mb-1"> <div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-yellow-500/20 to-red-500/20 flex items-center justify-center flex-shrink-0"> <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-yellow-500/20 to-red-500/20">
<svg className="w-5 h-5 text-yellow-400" viewBox="0 0 24 24" fill="currentColor"> <svg className="h-5 w-5 text-yellow-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/> <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
</svg> </svg>
</div> </div>
<h3 className="text-lg font-semibold text-dark-100"> <h3 className="text-lg font-semibold text-dark-100">
{t('admin.settings.yandexMetrika')} {t('admin.settings.yandexMetrika')}
</h3> </h3>
</div> </div>
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${ <span
yandexActive className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
? 'bg-success-500/15 text-success-400' yandexActive ? 'bg-success-500/15 text-success-400' : 'bg-dark-700/50 text-dark-500'
: 'bg-dark-700/50 text-dark-500' }`}
}`}> >
<span className={`w-1.5 h-1.5 rounded-full ${yandexActive ? 'bg-success-400' : 'bg-dark-600'}`} /> <span
className={`h-1.5 w-1.5 rounded-full ${yandexActive ? 'bg-success-400' : 'bg-dark-600'}`}
/>
{yandexActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')} {yandexActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')}
</span> </span>
</div> </div>
<p className="text-sm text-dark-400 mb-5 ml-[52px]"> <p className="mb-5 ml-[52px] text-sm text-dark-400">
{t('admin.settings.yandexMetrikaDesc')} {t('admin.settings.yandexMetrikaDesc')}
</p> </p>
@@ -106,73 +108,84 @@ export function AnalyticsTab() {
value={yandexValue} value={yandexValue}
onChange={(e) => setYandexValue(e.target.value.replace(/\D/g, ''))} onChange={(e) => setYandexValue(e.target.value.replace(/\D/g, ''))}
placeholder={t('admin.settings.yandexIdPlaceholder')} placeholder={t('admin.settings.yandexIdPlaceholder')}
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors" className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2.5 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
autoFocus autoFocus
/> />
<button <button
onClick={handleSaveYandex} onClick={handleSaveYandex}
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
className="px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50" className="rounded-xl bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
> >
<CheckIcon /> <CheckIcon />
</button> </button>
<button <button
onClick={() => { setEditingYandex(false); setError(null) }} onClick={() => {
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors" setEditingYandex(false);
setError(null);
}}
className="rounded-xl bg-dark-700 px-4 py-2.5 text-dark-300 transition-colors hover:bg-dark-600"
> >
<CloseIcon /> <CloseIcon />
</button> </button>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`text-base ${analytics?.yandex_metrika_id ? 'text-dark-100 font-mono' : 'text-dark-500'}`}> <span
className={`text-base ${analytics?.yandex_metrika_id ? 'font-mono text-dark-100' : 'text-dark-500'}`}
>
{analytics?.yandex_metrika_id || t('admin.settings.notConfigured')} {analytics?.yandex_metrika_id || t('admin.settings.notConfigured')}
</span> </span>
<button <button
onClick={() => { onClick={() => {
setYandexValue(analytics?.yandex_metrika_id || '') setYandexValue(analytics?.yandex_metrika_id || '');
setEditingYandex(true) setEditingYandex(true);
setError(null) setError(null);
}} }}
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors" className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
<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" /> 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> </svg>
</button> </button>
</div> </div>
)} )}
<p className="text-xs text-dark-500"> <p className="text-xs text-dark-500">{t('admin.settings.yandexIdHint')}</p>
{t('admin.settings.yandexIdHint')}
</p>
</div> </div>
</div> </div>
{/* Google Ads */} {/* Google Ads */}
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50"> <div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<div className="flex items-center justify-between mb-1"> <div className="mb-1 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-green-500/20 flex items-center justify-center flex-shrink-0"> <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500/20 to-green-500/20">
<svg className="w-5 h-5 text-blue-400" viewBox="0 0 24 24" fill="currentColor"> <svg className="h-5 w-5 text-blue-400" viewBox="0 0 24 24" fill="currentColor">
<path d="M12.87 15.07l-2.54-2.51.03-.03A17.52 17.52 0 0014.07 6H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z"/> <path d="M12.87 15.07l-2.54-2.51.03-.03A17.52 17.52 0 0014.07 6H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z" />
</svg> </svg>
</div> </div>
<h3 className="text-lg font-semibold text-dark-100"> <h3 className="text-lg font-semibold text-dark-100">{t('admin.settings.googleAds')}</h3>
{t('admin.settings.googleAds')}
</h3>
</div> </div>
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${ <span
googleActive className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
? 'bg-success-500/15 text-success-400' googleActive ? 'bg-success-500/15 text-success-400' : 'bg-dark-700/50 text-dark-500'
: 'bg-dark-700/50 text-dark-500' }`}
}`}> >
<span className={`w-1.5 h-1.5 rounded-full ${googleActive ? 'bg-success-400' : 'bg-dark-600'}`} /> <span
className={`h-1.5 w-1.5 rounded-full ${googleActive ? 'bg-success-400' : 'bg-dark-600'}`}
/>
{googleActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')} {googleActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')}
</span> </span>
</div> </div>
<p className="text-sm text-dark-400 mb-5 ml-[52px]"> <p className="mb-5 ml-[52px] text-sm text-dark-400">{t('admin.settings.googleAdsDesc')}</p>
{t('admin.settings.googleAdsDesc')}
</p>
<div className="space-y-5"> <div className="space-y-5">
{/* Conversion ID */} {/* Conversion ID */}
@@ -187,45 +200,58 @@ export function AnalyticsTab() {
value={googleIdValue} value={googleIdValue}
onChange={(e) => setGoogleIdValue(e.target.value)} onChange={(e) => setGoogleIdValue(e.target.value)}
placeholder={t('admin.settings.googleIdPlaceholder')} placeholder={t('admin.settings.googleIdPlaceholder')}
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors" className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2.5 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
autoFocus autoFocus
/> />
<button <button
onClick={handleSaveGoogleId} onClick={handleSaveGoogleId}
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
className="px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50" className="rounded-xl bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
> >
<CheckIcon /> <CheckIcon />
</button> </button>
<button <button
onClick={() => { setEditingGoogleId(false); setError(null) }} onClick={() => {
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors" setEditingGoogleId(false);
setError(null);
}}
className="rounded-xl bg-dark-700 px-4 py-2.5 text-dark-300 transition-colors hover:bg-dark-600"
> >
<CloseIcon /> <CloseIcon />
</button> </button>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`text-base ${analytics?.google_ads_id ? 'text-dark-100 font-mono' : 'text-dark-500'}`}> <span
className={`text-base ${analytics?.google_ads_id ? 'font-mono text-dark-100' : 'text-dark-500'}`}
>
{analytics?.google_ads_id || t('admin.settings.notConfigured')} {analytics?.google_ads_id || t('admin.settings.notConfigured')}
</span> </span>
<button <button
onClick={() => { onClick={() => {
setGoogleIdValue(analytics?.google_ads_id || '') setGoogleIdValue(analytics?.google_ads_id || '');
setEditingGoogleId(true) setEditingGoogleId(true);
setError(null) setError(null);
}} }}
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors" className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
<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" /> 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> </svg>
</button> </button>
</div> </div>
)} )}
<p className="text-xs text-dark-500"> <p className="text-xs text-dark-500">{t('admin.settings.googleIdHint')}</p>
{t('admin.settings.googleIdHint')}
</p>
</div> </div>
{/* Conversion Label */} {/* Conversion Label */}
@@ -240,55 +266,66 @@ export function AnalyticsTab() {
value={googleLabelValue} value={googleLabelValue}
onChange={(e) => setGoogleLabelValue(e.target.value)} onChange={(e) => setGoogleLabelValue(e.target.value)}
placeholder={t('admin.settings.googleLabelPlaceholder')} placeholder={t('admin.settings.googleLabelPlaceholder')}
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors" className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2.5 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
autoFocus autoFocus
/> />
<button <button
onClick={handleSaveGoogleLabel} onClick={handleSaveGoogleLabel}
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
className="px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50" className="rounded-xl bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
> >
<CheckIcon /> <CheckIcon />
</button> </button>
<button <button
onClick={() => { setEditingGoogleLabel(false); setError(null) }} onClick={() => {
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors" setEditingGoogleLabel(false);
setError(null);
}}
className="rounded-xl bg-dark-700 px-4 py-2.5 text-dark-300 transition-colors hover:bg-dark-600"
> >
<CloseIcon /> <CloseIcon />
</button> </button>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className={`text-base ${analytics?.google_ads_label ? 'text-dark-100 font-mono' : 'text-dark-500'}`}> <span
className={`text-base ${analytics?.google_ads_label ? 'font-mono text-dark-100' : 'text-dark-500'}`}
>
{analytics?.google_ads_label || t('admin.settings.notConfigured')} {analytics?.google_ads_label || t('admin.settings.notConfigured')}
</span> </span>
<button <button
onClick={() => { onClick={() => {
setGoogleLabelValue(analytics?.google_ads_label || '') setGoogleLabelValue(analytics?.google_ads_label || '');
setEditingGoogleLabel(true) setEditingGoogleLabel(true);
setError(null) setError(null);
}} }}
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors" className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
<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" /> 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> </svg>
</button> </button>
</div> </div>
)} )}
<p className="text-xs text-dark-500"> <p className="text-xs text-dark-500">{t('admin.settings.googleLabelHint')}</p>
{t('admin.settings.googleLabelHint')}
</p>
</div> </div>
</div> </div>
</div> </div>
{/* Info block */} {/* Info block */}
<div className="p-4 rounded-2xl bg-dark-800/30 border border-dark-700/30"> <div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-4">
<p className="text-sm text-dark-500 leading-relaxed"> <p className="text-sm leading-relaxed text-dark-500">{t('admin.settings.analyticsHint')}</p>
{t('admin.settings.analyticsHint')}
</p>
</div> </div>
</div> </div>
) );
} }

View File

@@ -1,124 +1,130 @@
import { useState, useRef } from 'react' import { useState, useRef } 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, setCachedBranding } from '../../api/branding' import { brandingApi, setCachedBranding } from '../../api/branding';
import { setCachedAnimationEnabled } from '../AnimatedBackground' import { setCachedAnimationEnabled } from '../AnimatedBackground';
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp' import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp';
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons' import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons';
import { Toggle } from './Toggle' import { Toggle } from './Toggle';
interface BrandingTabProps { interface BrandingTabProps {
accentColor?: string accentColor?: string;
} }
export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) { export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
const { t } = useTranslation() const { t } = useTranslation();
const queryClient = useQueryClient() const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null);
const [editingName, setEditingName] = useState(false) const [editingName, setEditingName] = useState(false);
const [newName, setNewName] = useState('') const [newName, setNewName] = useState('');
// Queries // Queries
const { data: branding } = useQuery({ const { data: branding } = useQuery({
queryKey: ['branding'], queryKey: ['branding'],
queryFn: brandingApi.getBranding, queryFn: brandingApi.getBranding,
}) });
const { data: animationSettings } = useQuery({ const { data: animationSettings } = useQuery({
queryKey: ['animation-enabled'], queryKey: ['animation-enabled'],
queryFn: brandingApi.getAnimationEnabled, queryFn: brandingApi.getAnimationEnabled,
}) });
const { data: fullscreenSettings } = useQuery({ const { data: fullscreenSettings } = useQuery({
queryKey: ['fullscreen-enabled'], queryKey: ['fullscreen-enabled'],
queryFn: brandingApi.getFullscreenEnabled, queryFn: brandingApi.getFullscreenEnabled,
}) });
const { data: emailAuthSettings } = useQuery({ const { data: emailAuthSettings } = useQuery({
queryKey: ['email-auth-enabled'], queryKey: ['email-auth-enabled'],
queryFn: brandingApi.getEmailAuthEnabled, queryFn: brandingApi.getEmailAuthEnabled,
}) });
// Mutations // Mutations
const updateBrandingMutation = useMutation({ const updateBrandingMutation = useMutation({
mutationFn: brandingApi.updateName, mutationFn: brandingApi.updateName,
onSuccess: (data) => { onSuccess: (data) => {
setCachedBranding(data) setCachedBranding(data);
queryClient.invalidateQueries({ queryKey: ['branding'] }) queryClient.invalidateQueries({ queryKey: ['branding'] });
setEditingName(false) setEditingName(false);
}, },
}) });
const uploadLogoMutation = useMutation({ const uploadLogoMutation = useMutation({
mutationFn: brandingApi.uploadLogo, mutationFn: brandingApi.uploadLogo,
onSuccess: (data) => { onSuccess: (data) => {
setCachedBranding(data) setCachedBranding(data);
queryClient.invalidateQueries({ queryKey: ['branding'] }) queryClient.invalidateQueries({ queryKey: ['branding'] });
}, },
}) });
const deleteLogoMutation = useMutation({ const deleteLogoMutation = useMutation({
mutationFn: brandingApi.deleteLogo, mutationFn: brandingApi.deleteLogo,
onSuccess: (data) => { onSuccess: (data) => {
setCachedBranding(data) setCachedBranding(data);
queryClient.invalidateQueries({ queryKey: ['branding'] }) queryClient.invalidateQueries({ queryKey: ['branding'] });
}, },
}) });
const updateAnimationMutation = useMutation({ const updateAnimationMutation = useMutation({
mutationFn: (enabled: boolean) => brandingApi.updateAnimationEnabled(enabled), mutationFn: (enabled: boolean) => brandingApi.updateAnimationEnabled(enabled),
onSuccess: (data) => { onSuccess: (data) => {
setCachedAnimationEnabled(data.enabled) setCachedAnimationEnabled(data.enabled);
queryClient.invalidateQueries({ queryKey: ['animation-enabled'] }) queryClient.invalidateQueries({ queryKey: ['animation-enabled'] });
}, },
}) });
const updateFullscreenMutation = useMutation({ const updateFullscreenMutation = useMutation({
mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled), mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled),
onSuccess: (data) => { onSuccess: (data) => {
setCachedFullscreenEnabled(data.enabled) setCachedFullscreenEnabled(data.enabled);
queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] }) queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] });
}, },
}) });
const updateEmailAuthMutation = useMutation({ const updateEmailAuthMutation = useMutation({
mutationFn: (enabled: boolean) => brandingApi.updateEmailAuthEnabled(enabled), mutationFn: (enabled: boolean) => brandingApi.updateEmailAuthEnabled(enabled),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-auth-enabled'] }) queryClient.invalidateQueries({ queryKey: ['email-auth-enabled'] });
}, },
}) });
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0];
if (file) { if (file) {
uploadLogoMutation.mutate(file) uploadLogoMutation.mutate(file);
}
} }
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Logo & Name */} {/* Logo & Name */}
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50"> <div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.settings.logoAndName')}</h3> <h3 className="mb-4 text-lg font-semibold text-dark-100">
{t('admin.settings.logoAndName')}
</h3>
<div className="flex items-start gap-6"> <div className="flex items-start gap-6">
{/* Logo */} {/* Logo */}
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div <div
className="w-20 h-20 rounded-2xl flex items-center justify-center text-3xl font-bold text-white overflow-hidden" className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-2xl text-3xl font-bold text-white"
style={{ style={{
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}dd)` background: `linear-gradient(135deg, ${accentColor}, ${accentColor}dd)`,
}} }}
> >
{branding?.has_custom_logo ? ( {branding?.has_custom_logo ? (
<img src={brandingApi.getLogoUrl(branding) ?? undefined} alt="Logo" className="w-full h-full object-cover" /> <img
src={brandingApi.getLogoUrl(branding) ?? undefined}
alt="Logo"
className="h-full w-full object-cover"
/>
) : ( ) : (
branding?.logo_letter || 'V' branding?.logo_letter || 'V'
)} )}
</div> </div>
<div className="flex gap-2 mt-3"> <div className="mt-3 flex gap-2">
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -129,7 +135,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
<button <button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={uploadLogoMutation.isPending} disabled={uploadLogoMutation.isPending}
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 rounded-xl bg-dark-700 hover:bg-dark-600 text-dark-200 text-sm transition-colors disabled:opacity-50" className="flex flex-1 items-center justify-center gap-1 rounded-xl bg-dark-700 px-3 py-2 text-sm text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-50"
> >
<UploadIcon /> <UploadIcon />
</button> </button>
@@ -137,7 +143,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
<button <button
onClick={() => deleteLogoMutation.mutate()} onClick={() => deleteLogoMutation.mutate()}
disabled={deleteLogoMutation.isPending} disabled={deleteLogoMutation.isPending}
className="px-3 py-2 rounded-xl bg-dark-700 hover:bg-error-500/20 text-dark-400 hover:text-error-400 transition-colors disabled:opacity-50" className="rounded-xl bg-dark-700 px-3 py-2 text-dark-400 transition-colors hover:bg-error-500/20 hover:text-error-400 disabled:opacity-50"
> >
<TrashIcon /> <TrashIcon />
</button> </button>
@@ -147,39 +153,43 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
{/* Name */} {/* Name */}
<div className="flex-1"> <div className="flex-1">
<label className="block text-sm font-medium text-dark-300 mb-2">{t('admin.settings.projectName')}</label> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.settings.projectName')}
</label>
{editingName ? ( {editingName ? (
<div className="flex gap-2"> <div className="flex gap-2">
<input <input
type="text" type="text"
value={newName} value={newName}
onChange={(e) => setNewName(e.target.value)} onChange={(e) => setNewName(e.target.value)}
className="flex-1 px-4 py-2 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 focus:outline-none focus:border-accent-500" className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
maxLength={50} maxLength={50}
/> />
<button <button
onClick={() => updateBrandingMutation.mutate(newName)} onClick={() => updateBrandingMutation.mutate(newName)}
disabled={updateBrandingMutation.isPending} disabled={updateBrandingMutation.isPending}
className="px-4 py-2 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50" className="rounded-xl bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
> >
<CheckIcon /> <CheckIcon />
</button> </button>
<button <button
onClick={() => setEditingName(false)} onClick={() => setEditingName(false)}
className="px-4 py-2 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors" className="rounded-xl bg-dark-700 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-600"
> >
<CloseIcon /> <CloseIcon />
</button> </button>
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-lg text-dark-100">{branding?.name || t('admin.settings.notSpecified')}</span> <span className="text-lg text-dark-100">
{branding?.name || t('admin.settings.notSpecified')}
</span>
<button <button
onClick={() => { onClick={() => {
setNewName(branding?.name ?? '') setNewName(branding?.name ?? '');
setEditingName(true) setEditingName(true);
}} }}
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors" className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
> >
<PencilIcon /> <PencilIcon />
</button> </button>
@@ -190,13 +200,17 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
</div> </div>
{/* Animation & Fullscreen toggles */} {/* Animation & Fullscreen toggles */}
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50"> <div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.settings.interfaceOptions')}</h3> <h3 className="mb-4 text-lg font-semibold text-dark-100">
{t('admin.settings.interfaceOptions')}
</h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between p-4 rounded-xl bg-dark-700/30"> <div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
<div> <div>
<span className="font-medium text-dark-100">{t('admin.settings.animatedBackground')}</span> <span className="font-medium text-dark-100">
{t('admin.settings.animatedBackground')}
</span>
<p className="text-sm text-dark-400">{t('admin.settings.animatedBackgroundDesc')}</p> <p className="text-sm text-dark-400">{t('admin.settings.animatedBackgroundDesc')}</p>
</div> </div>
<Toggle <Toggle
@@ -206,19 +220,23 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
/> />
</div> </div>
<div className="flex items-center justify-between p-4 rounded-xl bg-dark-700/30"> <div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
<div> <div>
<span className="font-medium text-dark-100">{t('admin.settings.autoFullscreen')}</span> <span className="font-medium text-dark-100">
{t('admin.settings.autoFullscreen')}
</span>
<p className="text-sm text-dark-400">{t('admin.settings.autoFullscreenDesc')}</p> <p className="text-sm text-dark-400">{t('admin.settings.autoFullscreenDesc')}</p>
</div> </div>
<Toggle <Toggle
checked={fullscreenSettings?.enabled ?? false} checked={fullscreenSettings?.enabled ?? false}
onChange={() => updateFullscreenMutation.mutate(!(fullscreenSettings?.enabled ?? false))} onChange={() =>
updateFullscreenMutation.mutate(!(fullscreenSettings?.enabled ?? false))
}
disabled={updateFullscreenMutation.isPending} disabled={updateFullscreenMutation.isPending}
/> />
</div> </div>
<div className="flex items-center justify-between p-4 rounded-xl bg-dark-700/30"> <div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
<div> <div>
<span className="font-medium text-dark-100">{t('admin.settings.emailAuth')}</span> <span className="font-medium text-dark-100">{t('admin.settings.emailAuth')}</span>
<p className="text-sm text-dark-400">{t('admin.settings.emailAuthDesc')}</p> <p className="text-sm text-dark-400">{t('admin.settings.emailAuthDesc')}</p>
@@ -232,5 +250,5 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
</div> </div>
</div> </div>
</div> </div>
) );
} }

View File

@@ -1,47 +1,48 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query';
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings' import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
import { StarIcon } from './icons' import { StarIcon } from './icons';
import { SettingRow } from './SettingRow' import { SettingRow } from './SettingRow';
interface FavoritesTabProps { interface FavoritesTabProps {
settings: SettingDefinition[] settings: SettingDefinition[];
isFavorite: (key: string) => boolean isFavorite: (key: string) => boolean;
toggleFavorite: (key: string) => void toggleFavorite: (key: string) => void;
} }
export function FavoritesTab({ settings, isFavorite, toggleFavorite }: FavoritesTabProps) { export function FavoritesTab({ settings, isFavorite, toggleFavorite }: FavoritesTabProps) {
const { t } = useTranslation() const { t } = useTranslation();
const queryClient = useQueryClient() const queryClient = useQueryClient();
const updateSettingMutation = useMutation({ const updateSettingMutation = useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) => adminSettingsApi.updateSetting(key, value), mutationFn: ({ key, value }: { key: string; value: string }) =>
adminSettingsApi.updateSetting(key, value),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }) queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
}, },
}) });
const resetSettingMutation = useMutation({ const resetSettingMutation = useMutation({
mutationFn: (key: string) => adminSettingsApi.resetSetting(key), mutationFn: (key: string) => adminSettingsApi.resetSetting(key),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }) queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
}, },
}) });
if (settings.length === 0) { if (settings.length === 0) {
return ( return (
<div className="p-12 rounded-2xl bg-dark-800/30 border border-dark-700/30 text-center"> <div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
<div className="flex justify-center mb-4 text-dark-500"> <div className="mb-4 flex justify-center text-dark-500">
<StarIcon filled={false} /> <StarIcon filled={false} />
</div> </div>
<p className="text-dark-400">{t('admin.settings.favoritesEmpty')}</p> <p className="text-dark-400">{t('admin.settings.favoritesEmpty')}</p>
<p className="text-dark-500 text-sm mt-1">{t('admin.settings.favoritesHint')}</p> <p className="mt-1 text-sm text-dark-500">{t('admin.settings.favoritesHint')}</p>
</div> </div>
) );
} }
return ( return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{settings.map((setting) => ( {settings.map((setting) => (
<SettingRow <SettingRow
key={setting.key} key={setting.key}
@@ -55,5 +56,5 @@ export function FavoritesTab({ settings, isFavorite, toggleFavorite }: Favorites
/> />
))} ))}
</div> </div>
) );
} }

View File

@@ -1,23 +1,23 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react';
import { SettingDefinition } from '../../api/adminSettings' import { SettingDefinition } from '../../api/adminSettings';
import { CheckIcon, CloseIcon, EditIcon } from './icons' import { CheckIcon, CloseIcon, EditIcon } from './icons';
interface SettingInputProps { interface SettingInputProps {
setting: SettingDefinition setting: SettingDefinition;
onUpdate: (value: string) => void onUpdate: (value: string) => void;
disabled?: boolean disabled?: boolean;
} }
// Check if value is likely JSON or multi-line // Check if value is likely JSON or multi-line
function isLongValue(value: string | null | undefined): boolean { function isLongValue(value: string | null | undefined): boolean {
if (!value) return false if (!value) return false;
const str = String(value) const str = String(value);
return str.length > 50 || str.includes('\n') || str.startsWith('[') || str.startsWith('{') return str.length > 50 || str.includes('\n') || str.startsWith('[') || str.startsWith('{');
} }
// Check if key suggests it's a list or JSON config // Check if key suggests it's a list or JSON config
function isListOrJsonKey(key: string): boolean { function isListOrJsonKey(key: string): boolean {
const lowerKey = key.toLowerCase() const lowerKey = key.toLowerCase();
return ( return (
lowerKey.includes('_items') || lowerKey.includes('_items') ||
lowerKey.includes('_config') || lowerKey.includes('_config') ||
@@ -28,40 +28,40 @@ function isListOrJsonKey(key: string): boolean {
lowerKey.includes('_periods') || lowerKey.includes('_periods') ||
lowerKey.includes('_discounts') || lowerKey.includes('_discounts') ||
lowerKey.includes('_packages') lowerKey.includes('_packages')
) );
} }
export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps) { export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps) {
const [isEditing, setIsEditing] = useState(false) const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState('') const [value, setValue] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null);
const currentValue = String(setting.current ?? '') const currentValue = String(setting.current ?? '');
const needsTextarea = isLongValue(currentValue) || isListOrJsonKey(setting.key) const needsTextarea = isLongValue(currentValue) || isListOrJsonKey(setting.key);
// Auto-resize textarea // Auto-resize textarea
useEffect(() => { useEffect(() => {
if (textareaRef.current && isEditing) { if (textareaRef.current && isEditing) {
textareaRef.current.style.height = 'auto' textareaRef.current.style.height = 'auto';
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 300) + 'px' textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 300) + 'px';
} }
}, [value, isEditing]) }, [value, isEditing]);
const handleStart = () => { const handleStart = () => {
setValue(currentValue) setValue(currentValue);
setIsEditing(true) setIsEditing(true);
} };
const handleSave = () => { const handleSave = () => {
onUpdate(value) onUpdate(value);
setIsEditing(false) setIsEditing(false);
} };
const handleCancel = () => { const handleCancel = () => {
setIsEditing(false) setIsEditing(false);
setValue('') setValue('');
} };
// Dropdown for choices // Dropdown for choices
if (setting.choices && setting.choices.length > 0) { if (setting.choices && setting.choices.length > 0) {
@@ -70,7 +70,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
value={currentValue} value={currentValue}
onChange={(e) => onUpdate(e.target.value)} onChange={(e) => onUpdate(e.target.value)}
disabled={disabled} disabled={disabled}
className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm text-dark-100 focus:outline-none focus:border-accent-500 focus:ring-1 focus:ring-accent-500/30 disabled:opacity-50 min-w-[140px] cursor-pointer" className="min-w-[140px] cursor-pointer rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-sm text-dark-100 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500/30 disabled:opacity-50"
> >
{setting.choices.map((choice, idx) => ( {setting.choices.map((choice, idx) => (
<option key={idx} value={String(choice.value)}> <option key={idx} value={String(choice.value)}>
@@ -78,7 +78,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
</option> </option>
))} ))}
</select> </select>
) );
} }
// Editing mode - Textarea for long values // Editing mode - Textarea for long values
@@ -90,26 +90,26 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Escape') handleCancel() if (e.key === 'Escape') handleCancel();
// Ctrl+Enter to save // Ctrl+Enter to save
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSave() if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSave();
}} }}
autoFocus autoFocus
placeholder="Введите значение..." placeholder="Введите значение..."
className="w-full bg-dark-700 border border-accent-500 rounded-xl px-4 py-3 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 font-mono resize-none min-h-[100px]" className="min-h-[100px] w-full resize-none rounded-xl border border-accent-500 bg-dark-700 px-4 py-3 font-mono text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30"
/> />
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-xs text-dark-500">Ctrl+Enter для сохранения</span> <span className="text-xs text-dark-500">Ctrl+Enter для сохранения</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={handleCancel} onClick={handleCancel}
className="px-3 py-1.5 rounded-lg bg-dark-600 text-dark-300 hover:bg-dark-500 transition-colors text-sm" className="rounded-lg bg-dark-600 px-3 py-1.5 text-sm text-dark-300 transition-colors hover:bg-dark-500"
> >
Отмена Отмена
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
className="px-3 py-1.5 rounded-lg bg-accent-500 text-white hover:bg-accent-600 transition-colors text-sm flex items-center gap-1.5" className="flex items-center gap-1.5 rounded-lg bg-accent-500 px-3 py-1.5 text-sm text-white transition-colors hover:bg-accent-600"
> >
<CheckIcon /> <CheckIcon />
Сохранить Сохранить
@@ -117,7 +117,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
</div> </div>
</div> </div>
</div> </div>
) );
} }
// Editing mode - Regular input // Editing mode - Regular input
@@ -130,50 +130,51 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') handleSave() if (e.key === 'Enter') handleSave();
if (e.key === 'Escape') handleCancel() if (e.key === 'Escape') handleCancel();
}} }}
autoFocus autoFocus
placeholder="Введите значение..." placeholder="Введите значение..."
className="bg-dark-700 border border-accent-500 rounded-lg px-3 py-2 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 w-48 sm:w-56" className="w-48 rounded-lg border border-accent-500 bg-dark-700 px-3 py-2 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 sm:w-56"
/> />
<button <button
onClick={handleSave} onClick={handleSave}
className="p-2 rounded-lg bg-accent-500 text-white hover:bg-accent-600 transition-colors" className="rounded-lg bg-accent-500 p-2 text-white transition-colors hover:bg-accent-600"
title="Сохранить (Enter)" title="Сохранить (Enter)"
> >
<CheckIcon /> <CheckIcon />
</button> </button>
<button <button
onClick={handleCancel} onClick={handleCancel}
className="p-2 rounded-lg bg-dark-600 text-dark-300 hover:bg-dark-500 transition-colors" className="rounded-lg bg-dark-600 p-2 text-dark-300 transition-colors hover:bg-dark-500"
title="Отмена (Esc)" title="Отмена (Esc)"
> >
<CloseIcon /> <CloseIcon />
</button> </button>
</div> </div>
) );
} }
// Display mode - Long value preview // Display mode - Long value preview
if (needsTextarea) { if (needsTextarea) {
const displayValue = currentValue || '-' const displayValue = currentValue || '-';
const previewValue = displayValue.length > 60 ? displayValue.slice(0, 60) + '...' : displayValue const previewValue =
displayValue.length > 60 ? displayValue.slice(0, 60) + '...' : displayValue;
return ( return (
<button <button
onClick={handleStart} onClick={handleStart}
disabled={disabled} disabled={disabled}
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-sm text-dark-200 hover:border-dark-500 hover:bg-dark-700 transition-colors disabled:opacity-50 text-left font-mono group" className="group w-full rounded-xl border border-dark-600 bg-dark-700/50 px-4 py-3 text-left font-mono text-sm text-dark-200 transition-colors hover:border-dark-500 hover:bg-dark-700 disabled:opacity-50"
> >
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<span className="break-all line-clamp-2 flex-1">{previewValue}</span> <span className="line-clamp-2 flex-1 break-all">{previewValue}</span>
<span className="text-dark-500 group-hover:text-accent-400 transition-colors flex-shrink-0"> <span className="flex-shrink-0 text-dark-500 transition-colors group-hover:text-accent-400">
<EditIcon /> <EditIcon />
</span> </span>
</div> </div>
</button> </button>
) );
} }
// Display mode - Short value // Display mode - Short value
@@ -181,12 +182,12 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
<button <button
onClick={handleStart} onClick={handleStart}
disabled={disabled} disabled={disabled}
className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm text-dark-200 hover:border-dark-500 hover:bg-dark-600 transition-colors disabled:opacity-50 min-w-[100px] text-left font-mono truncate max-w-[200px] flex items-center gap-2 group" className="group flex min-w-[100px] max-w-[200px] items-center gap-2 truncate rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-left font-mono text-sm text-dark-200 transition-colors hover:border-dark-500 hover:bg-dark-600 disabled:opacity-50"
> >
<span className="truncate flex-1">{currentValue || '-'}</span> <span className="flex-1 truncate">{currentValue || '-'}</span>
<span className="text-dark-500 group-hover:text-accent-400 transition-colors opacity-0 group-hover:opacity-100"> <span className="text-dark-500 opacity-0 transition-colors group-hover:text-accent-400 group-hover:opacity-100">
<EditIcon /> <EditIcon />
</span> </span>
</button> </button>
) );
} }

View File

@@ -1,18 +1,18 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { SettingDefinition } from '../../api/adminSettings' import { SettingDefinition } from '../../api/adminSettings';
import { StarIcon, LockIcon, RefreshIcon } from './icons' import { StarIcon, LockIcon, RefreshIcon } from './icons';
import { SettingInput } from './SettingInput' import { SettingInput } from './SettingInput';
import { Toggle } from './Toggle' import { Toggle } from './Toggle';
import { formatSettingKey, stripHtml } from './utils' import { formatSettingKey, stripHtml } from './utils';
interface SettingRowProps { interface SettingRowProps {
setting: SettingDefinition setting: SettingDefinition;
isFavorite: boolean isFavorite: boolean;
onToggleFavorite: () => void onToggleFavorite: () => void;
onUpdate: (value: string) => void onUpdate: (value: string) => void;
onReset: () => void onReset: () => void;
isUpdating?: boolean isUpdating?: boolean;
isResetting?: boolean isResetting?: boolean;
} }
export function SettingRow({ export function SettingRow({
@@ -22,18 +22,18 @@ export function SettingRow({
onUpdate, onUpdate,
onReset, onReset,
isUpdating, isUpdating,
isResetting isResetting,
}: SettingRowProps) { }: SettingRowProps) {
const { t } = useTranslation() const { t } = useTranslation();
const formattedKey = formatSettingKey(setting.name || setting.key) const formattedKey = formatSettingKey(setting.name || setting.key);
const displayName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey) const displayName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
const description = setting.hint?.description ? stripHtml(setting.hint.description) : null const description = setting.hint?.description ? stripHtml(setting.hint.description) : null;
// Check if this is a long/complex value // Check if this is a long/complex value
const isLongValue = (() => { const isLongValue = (() => {
const val = String(setting.current ?? '') const val = String(setting.current ?? '');
const key = setting.key.toLowerCase() const key = setting.key.toLowerCase();
return ( return (
val.length > 50 || val.length > 50 ||
val.includes('\n') || val.includes('\n') ||
@@ -44,40 +44,40 @@ export function SettingRow({
key.includes('_keywords') || key.includes('_keywords') ||
key.includes('_template') || key.includes('_template') ||
key.includes('_packages') key.includes('_packages')
) );
})() })();
return ( return (
<div className="group p-4 sm:p-5 rounded-2xl bg-dark-800/40 border border-dark-700/40 hover:border-dark-600/60 hover:bg-dark-800/60 transition-all"> <div className="group rounded-2xl border border-dark-700/40 bg-dark-800/40 p-4 transition-all hover:border-dark-600/60 hover:bg-dark-800/60 sm:p-5">
{/* Header row - name, badges, favorite */} {/* Header row - name, badges, favorite */}
<div className="flex items-start justify-between gap-3 mb-3"> <div className="mb-3 flex items-start justify-between gap-3">
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex flex-wrap items-center gap-2">
<h3 className="font-semibold text-dark-100 text-base">{displayName}</h3> <h3 className="text-base font-semibold text-dark-100">{displayName}</h3>
{setting.has_override && ( {setting.has_override && (
<span className="px-2 py-0.5 text-xs rounded-full bg-warning-500/20 text-warning-400 font-medium"> <span className="rounded-full bg-warning-500/20 px-2 py-0.5 text-xs font-medium text-warning-400">
{t('admin.settings.modified')} {t('admin.settings.modified')}
</span> </span>
)} )}
{setting.read_only && ( {setting.read_only && (
<span className="px-2 py-0.5 text-xs rounded-full bg-dark-600/50 text-dark-400 font-medium flex items-center gap-1"> <span className="flex items-center gap-1 rounded-full bg-dark-600/50 px-2 py-0.5 text-xs font-medium text-dark-400">
<LockIcon /> <LockIcon />
{t('admin.settings.readOnly')} {t('admin.settings.readOnly')}
</span> </span>
)} )}
</div> </div>
{description && ( {description && (
<p className="text-sm text-dark-400 mt-1.5 leading-relaxed">{description}</p> <p className="mt-1.5 text-sm leading-relaxed text-dark-400">{description}</p>
)} )}
</div> </div>
{/* Favorite button */} {/* Favorite button */}
<button <button
onClick={onToggleFavorite} onClick={onToggleFavorite}
className={`p-2 rounded-xl transition-all flex-shrink-0 ${ className={`flex-shrink-0 rounded-xl p-2 transition-all ${
isFavorite isFavorite
? 'text-warning-400 bg-warning-500/15 hover:bg-warning-500/25' ? 'bg-warning-500/15 text-warning-400 hover:bg-warning-500/25'
: 'text-dark-500 hover:text-warning-400 hover:bg-dark-700/50 opacity-0 group-hover:opacity-100' : 'text-dark-500 opacity-0 hover:bg-dark-700/50 hover:text-warning-400 group-hover:opacity-100'
}`} }`}
title={isFavorite ? 'Убрать из избранного' : 'В избранное'} title={isFavorite ? 'Убрать из избранного' : 'В избранное'}
> >
@@ -87,17 +87,19 @@ export function SettingRow({
{/* Setting key (muted) */} {/* Setting key (muted) */}
<div className="mb-3"> <div className="mb-3">
<code className="text-xs text-dark-500 font-mono bg-dark-900/50 px-2 py-1 rounded"> <code className="rounded bg-dark-900/50 px-2 py-1 font-mono text-xs text-dark-500">
{setting.key} {setting.key}
</code> </code>
</div> </div>
{/* Control section */} {/* Control section */}
<div className={`${isLongValue ? '' : 'flex items-center justify-between gap-3'} pt-3 border-t border-dark-700/30`}> <div
className={`${isLongValue ? '' : 'flex items-center justify-between gap-3'} border-t border-dark-700/30 pt-3`}
>
{setting.read_only ? ( {setting.read_only ? (
// Read-only display // Read-only display
<div className="flex items-center gap-2 text-dark-300 bg-dark-700/30 rounded-lg px-4 py-2.5"> <div className="flex items-center gap-2 rounded-lg bg-dark-700/30 px-4 py-2.5 text-dark-300">
<span className="font-mono text-sm break-all">{String(setting.current ?? '-')}</span> <span className="break-all font-mono text-sm">{String(setting.current ?? '-')}</span>
</div> </div>
) : setting.type === 'bool' ? ( ) : setting.type === 'bool' ? (
// Boolean toggle // Boolean toggle
@@ -108,7 +110,11 @@ export function SettingRow({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Toggle <Toggle
checked={setting.current === true || setting.current === 'true'} checked={setting.current === true || setting.current === 'true'}
onChange={() => onUpdate(setting.current === true || setting.current === 'true' ? 'false' : 'true')} onChange={() =>
onUpdate(
setting.current === true || setting.current === 'true' ? 'false' : 'true',
)
}
disabled={isUpdating} disabled={isUpdating}
/> />
{/* Reset button for boolean */} {/* Reset button for boolean */}
@@ -116,7 +122,7 @@ export function SettingRow({
<button <button
onClick={onReset} onClick={onReset}
disabled={isResetting} disabled={isResetting}
className="p-2 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50" className="rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
title={t('admin.settings.reset')} title={t('admin.settings.reset')}
> >
<RefreshIcon /> <RefreshIcon />
@@ -126,18 +132,16 @@ export function SettingRow({
</div> </div>
) : ( ) : (
// Input field // Input field
<div className={`${isLongValue ? 'w-full' : 'flex items-center gap-2 flex-1 justify-end'}`}> <div
<SettingInput className={`${isLongValue ? 'w-full' : 'flex flex-1 items-center justify-end gap-2'}`}
setting={setting} >
onUpdate={onUpdate} <SettingInput setting={setting} onUpdate={onUpdate} disabled={isUpdating} />
disabled={isUpdating}
/>
{/* Reset button for non-long values */} {/* Reset button for non-long values */}
{!isLongValue && setting.has_override && ( {!isLongValue && setting.has_override && (
<button <button
onClick={onReset} onClick={onReset}
disabled={isResetting} disabled={isResetting}
className="p-2 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50 flex-shrink-0" className="flex-shrink-0 rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
title={t('admin.settings.reset')} title={t('admin.settings.reset')}
> >
<RefreshIcon /> <RefreshIcon />
@@ -153,7 +157,7 @@ export function SettingRow({
<button <button
onClick={onReset} onClick={onReset}
disabled={isResetting} disabled={isResetting}
className="px-3 py-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50 text-sm flex items-center gap-1.5" className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
title={t('admin.settings.reset')} title={t('admin.settings.reset')}
> >
<RefreshIcon /> <RefreshIcon />
@@ -162,5 +166,5 @@ export function SettingRow({
</div> </div>
)} )}
</div> </div>
) );
} }

View File

@@ -1,14 +1,14 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { SearchIcon, CloseIcon } from './icons' import { SearchIcon, CloseIcon } from './icons';
interface SettingsSearchProps { interface SettingsSearchProps {
searchQuery: string searchQuery: string;
setSearchQuery: (query: string) => void setSearchQuery: (query: string) => void;
resultsCount?: number resultsCount?: number;
} }
export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchProps) { export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchProps) {
const { t } = useTranslation() const { t } = useTranslation();
return ( return (
<> <>
@@ -19,7 +19,7 @@ export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchPr
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('admin.settings.searchPlaceholder')} placeholder={t('admin.settings.searchPlaceholder')}
className="w-48 lg:w-64 pl-10 pr-10 py-2 rounded-xl bg-dark-800 border border-dark-700 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 text-sm" className="w-48 rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none lg:w-64"
/> />
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500"> <div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
<SearchIcon /> <SearchIcon />
@@ -27,18 +27,21 @@ export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchPr
{searchQuery && ( {searchQuery && (
<button <button
onClick={() => setSearchQuery('')} onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 hover:text-dark-300 transition-colors" className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
> >
<CloseIcon /> <CloseIcon />
</button> </button>
)} )}
</div> </div>
</> </>
) );
} }
export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit<SettingsSearchProps, 'resultsCount'>) { export function SettingsSearchMobile({
const { t } = useTranslation() searchQuery,
setSearchQuery,
}: Omit<SettingsSearchProps, 'resultsCount'>) {
const { t } = useTranslation();
return ( return (
<div className="relative mt-3 sm:hidden"> <div className="relative mt-3 sm:hidden">
@@ -47,7 +50,7 @@ export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit<Setti
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('admin.settings.searchPlaceholder')} placeholder={t('admin.settings.searchPlaceholder')}
className="w-full pl-10 pr-10 py-2 rounded-xl bg-dark-800 border border-dark-700 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 text-sm" className="w-full rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none"
/> />
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500"> <div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
<SearchIcon /> <SearchIcon />
@@ -55,26 +58,30 @@ export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit<Setti
{searchQuery && ( {searchQuery && (
<button <button
onClick={() => setSearchQuery('')} onClick={() => setSearchQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 hover:text-dark-300 transition-colors" className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
> >
<CloseIcon /> <CloseIcon />
</button> </button>
)} )}
</div> </div>
) );
} }
export function SettingsSearchResults({ searchQuery, resultsCount }: { searchQuery: string; resultsCount: number }) { export function SettingsSearchResults({
if (!searchQuery.trim()) return null searchQuery,
resultsCount,
}: {
searchQuery: string;
resultsCount: number;
}) {
if (!searchQuery.trim()) return null;
return ( return (
<div className="mt-3 flex items-center gap-2 text-sm"> <div className="mt-3 flex items-center gap-2 text-sm">
<span className="text-dark-400"> <span className="text-dark-400">
{resultsCount > 0 ? `Найдено: ${resultsCount}` : 'Ничего не найдено'} {resultsCount > 0 ? `Найдено: ${resultsCount}` : 'Ничего не найдено'}
</span> </span>
{resultsCount > 0 && ( {resultsCount > 0 && <span className="text-dark-500">по запросу «{searchQuery}»</span>}
<span className="text-dark-500">по запросу «{searchQuery}»</span>
)}
</div> </div>
) );
} }

View File

@@ -1,13 +1,13 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom';
import { BackIcon, StarIcon, CloseIcon, MENU_SECTIONS } from './index' import { BackIcon, StarIcon, CloseIcon, MENU_SECTIONS } from './index';
interface SettingsSidebarProps { interface SettingsSidebarProps {
activeSection: string activeSection: string;
setActiveSection: (section: string) => void setActiveSection: (section: string) => void;
mobileMenuOpen: boolean mobileMenuOpen: boolean;
setMobileMenuOpen: (open: boolean) => void setMobileMenuOpen: (open: boolean) => void;
favoritesCount: number favoritesCount: number;
} }
export function SettingsSidebar({ export function SettingsSidebar({
@@ -15,27 +15,27 @@ export function SettingsSidebar({
setActiveSection, setActiveSection,
mobileMenuOpen, mobileMenuOpen,
setMobileMenuOpen, setMobileMenuOpen,
favoritesCount favoritesCount,
}: SettingsSidebarProps) { }: SettingsSidebarProps) {
const { t } = useTranslation() const { t } = useTranslation();
return ( return (
<aside className={` <aside
fixed lg:sticky lg:top-0 inset-y-0 left-0 z-50 className={`fixed inset-y-0 left-0 z-50 h-screen w-64 flex-shrink-0 transform border-r border-dark-700/50 bg-dark-900 transition-transform duration-200 ease-in-out lg:sticky lg:top-0 ${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'} `}
w-64 h-screen bg-dark-900 border-r border-dark-700/50 flex-shrink-0 >
transform transition-transform duration-200 ease-in-out
${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
`}>
{/* Header */} {/* Header */}
<div className="p-4 border-b border-dark-700/50"> <div className="border-b border-dark-700/50 p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link to="/admin" className="p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors"> <Link
to="/admin"
className="rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700"
>
<BackIcon /> <BackIcon />
</Link> </Link>
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1> <h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
<button <button
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
className="ml-auto p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors lg:hidden" className="ml-auto rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700 lg:hidden"
> >
<CloseIcon /> <CloseIcon />
</button> </button>
@@ -43,39 +43,39 @@ export function SettingsSidebar({
</div> </div>
{/* Menu */} {/* Menu */}
<nav className="p-2 space-y-1 overflow-y-auto max-h-[calc(100vh-80px)]"> <nav className="max-h-[calc(100vh-80px)] space-y-1 overflow-y-auto p-2">
{MENU_SECTIONS.map((section, sectionIdx) => ( {MENU_SECTIONS.map((section, sectionIdx) => (
<div key={section.id}> <div key={section.id}>
{sectionIdx > 0 && <div className="my-3 border-t border-dark-700/50" />} {sectionIdx > 0 && <div className="my-3 border-t border-dark-700/50" />}
{section.items.map((item) => { {section.items.map((item) => {
const isActive = activeSection === item.id const isActive = activeSection === item.id;
const hasIcon = item.iconType === 'star' const hasIcon = item.iconType === 'star';
return ( return (
<button <button
key={item.id} key={item.id}
onClick={() => { onClick={() => {
setActiveSection(item.id) setActiveSection(item.id);
setMobileMenuOpen(false) setMobileMenuOpen(false);
}} }}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all ${ className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 transition-all ${
isActive isActive
? 'bg-accent-500/10 text-accent-400' ? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:text-dark-200 hover:bg-dark-800/50' : 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200'
}`} }`}
> >
{hasIcon && <StarIcon filled={isActive && item.id === 'favorites'} />} {hasIcon && <StarIcon filled={isActive && item.id === 'favorites'} />}
<span className="font-medium">{t(`admin.settings.${item.id}`)}</span> <span className="font-medium">{t(`admin.settings.${item.id}`)}</span>
{item.id === 'favorites' && favoritesCount > 0 && ( {item.id === 'favorites' && favoritesCount > 0 && (
<span className="ml-auto px-2 py-0.5 text-xs rounded-full bg-warning-500/20 text-warning-400"> <span className="ml-auto rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
{favoritesCount} {favoritesCount}
</span> </span>
)} )}
</button> </button>
) );
})} })}
</div> </div>
))} ))}
</nav> </nav>
</aside> </aside>
) );
} }

View File

@@ -1,22 +1,22 @@
import { useState } from 'react' import { useState } from 'react';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query';
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings' import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
import { ChevronDownIcon } from './icons' import { ChevronDownIcon } from './icons';
import { SettingRow } from './SettingRow' import { SettingRow } from './SettingRow';
interface CategoryGroup { interface CategoryGroup {
key: string key: string;
label: string label: string;
settings: SettingDefinition[] settings: SettingDefinition[];
} }
interface SettingsTabProps { interface SettingsTabProps {
categories: CategoryGroup[] categories: CategoryGroup[];
searchQuery: string searchQuery: string;
filteredSettings: SettingDefinition[] filteredSettings: SettingDefinition[];
isFavorite: (key: string) => boolean isFavorite: (key: string) => boolean;
toggleFavorite: (key: string) => void toggleFavorite: (key: string) => void;
} }
export function SettingsTab({ export function SettingsTab({
@@ -24,49 +24,50 @@ export function SettingsTab({
searchQuery, searchQuery,
filteredSettings, filteredSettings,
isFavorite, isFavorite,
toggleFavorite toggleFavorite,
}: SettingsTabProps) { }: SettingsTabProps) {
const { t } = useTranslation() const { t } = useTranslation();
const queryClient = useQueryClient() const queryClient = useQueryClient();
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set()) const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
const toggleSection = (key: string) => { const toggleSection = (key: string) => {
setExpandedSections(prev => { setExpandedSections((prev) => {
const next = new Set(prev) const next = new Set(prev);
if (next.has(key)) { if (next.has(key)) {
next.delete(key) next.delete(key);
} else { } else {
next.add(key) next.add(key);
}
return next
})
} }
return next;
});
};
const updateSettingMutation = useMutation({ const updateSettingMutation = useMutation({
mutationFn: ({ key, value }: { key: string; value: string }) => adminSettingsApi.updateSetting(key, value), mutationFn: ({ key, value }: { key: string; value: string }) =>
adminSettingsApi.updateSetting(key, value),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }) queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
}, },
}) });
const resetSettingMutation = useMutation({ const resetSettingMutation = useMutation({
mutationFn: (key: string) => adminSettingsApi.resetSetting(key), mutationFn: (key: string) => adminSettingsApi.resetSetting(key),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-settings'] }) queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
}, },
}) });
// If searching, show flat list // If searching, show flat list
if (searchQuery) { if (searchQuery) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{filteredSettings.length === 0 ? ( {filteredSettings.length === 0 ? (
<div className="p-12 rounded-2xl bg-dark-800/30 border border-dark-700/30 text-center"> <div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
<p className="text-dark-400">{t('admin.settings.noSettings')}</p> <p className="text-dark-400">{t('admin.settings.noSettings')}</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{filteredSettings.map((setting) => ( {filteredSettings.map((setting) => (
<SettingRow <SettingRow
key={setting.key} key={setting.key}
@@ -82,46 +83,50 @@ export function SettingsTab({
</div> </div>
)} )}
</div> </div>
) );
} }
// Show accordion for subcategories // Show accordion for subcategories
return ( return (
<div className="space-y-3"> <div className="space-y-3">
{categories.map((cat) => { {categories.map((cat) => {
const isExpanded = expandedSections.has(cat.key) const isExpanded = expandedSections.has(cat.key);
return ( return (
<div <div
key={cat.key} key={cat.key}
className="rounded-2xl bg-dark-800/30 border border-dark-700/30 overflow-hidden" className="overflow-hidden rounded-2xl border border-dark-700/30 bg-dark-800/30"
> >
{/* Accordion header */} {/* Accordion header */}
<button <button
onClick={() => toggleSection(cat.key)} onClick={() => toggleSection(cat.key)}
className="w-full flex items-center justify-between p-4 hover:bg-dark-800/50 transition-colors" className="flex w-full items-center justify-between p-4 transition-colors hover:bg-dark-800/50"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="font-medium text-dark-100">{cat.label}</span> <span className="font-medium text-dark-100">{cat.label}</span>
<span className="px-2 py-0.5 text-xs rounded-full bg-dark-700 text-dark-400"> <span className="rounded-full bg-dark-700 px-2 py-0.5 text-xs text-dark-400">
{cat.settings.length} {cat.settings.length}
</span> </span>
</div> </div>
<div className={`transition-transform duration-200 text-dark-400 ${isExpanded ? 'rotate-180' : ''}`}> <div
className={`text-dark-400 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
>
<ChevronDownIcon /> <ChevronDownIcon />
</div> </div>
</button> </button>
{/* Accordion content */} {/* Accordion content */}
{isExpanded && ( {isExpanded && (
<div className="p-4 pt-0 border-t border-dark-700/30"> <div className="border-t border-dark-700/30 p-4 pt-0">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 pt-4"> <div className="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2">
{cat.settings.map((setting) => ( {cat.settings.map((setting) => (
<SettingRow <SettingRow
key={setting.key} key={setting.key}
setting={setting} setting={setting}
isFavorite={isFavorite(setting.key)} isFavorite={isFavorite(setting.key)}
onToggleFavorite={() => toggleFavorite(setting.key)} onToggleFavorite={() => toggleFavorite(setting.key)}
onUpdate={(value) => updateSettingMutation.mutate({ key: setting.key, value })} onUpdate={(value) =>
updateSettingMutation.mutate({ key: setting.key, value })
}
onReset={() => resetSettingMutation.mutate(setting.key)} onReset={() => resetSettingMutation.mutate(setting.key)}
isUpdating={updateSettingMutation.isPending} isUpdating={updateSettingMutation.isPending}
isResetting={resetSettingMutation.isPending} isResetting={resetSettingMutation.isPending}
@@ -131,14 +136,14 @@ export function SettingsTab({
</div> </div>
)} )}
</div> </div>
) );
})} })}
{categories.length === 0 && ( {categories.length === 0 && (
<div className="p-12 rounded-2xl bg-dark-800/30 border border-dark-700/30 text-center"> <div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
<p className="text-dark-400">{t('admin.settings.noSettings')}</p> <p className="text-dark-400">{t('admin.settings.noSettings')}</p>
</div> </div>
)} )}
</div> </div>
) );
} }

View File

@@ -1,101 +1,107 @@
import { useState } from 'react' 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 { themeColorsApi } from '../../api/themeColors' import { themeColorsApi } from '../../api/themeColors';
import { DEFAULT_THEME_COLORS } from '../../types/theme' import { DEFAULT_THEME_COLORS } from '../../types/theme';
import { ColorPicker } from '../ColorPicker' import { ColorPicker } from '../ColorPicker';
import { applyThemeColors } from '../../hooks/useThemeColors' import { applyThemeColors } from '../../hooks/useThemeColors';
import { updateEnabledThemesCache } from '../../hooks/useTheme' import { updateEnabledThemesCache } from '../../hooks/useTheme';
import { MoonIcon, SunIcon, ChevronDownIcon } from './icons' import { MoonIcon, SunIcon, ChevronDownIcon } from './icons';
import { Toggle } from './Toggle' import { Toggle } from './Toggle';
import { THEME_PRESETS } from './constants' import { THEME_PRESETS } from './constants';
export function ThemeTab() { export function ThemeTab() {
const { t } = useTranslation() const { t } = useTranslation();
const queryClient = useQueryClient() const queryClient = useQueryClient();
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['presets'])) const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['presets']));
const toggleSection = (section: string) => { const toggleSection = (section: string) => {
setExpandedSections(prev => { setExpandedSections((prev) => {
const next = new Set(prev) const next = new Set(prev);
if (next.has(section)) { if (next.has(section)) {
next.delete(section) next.delete(section);
} else { } else {
next.add(section) next.add(section);
}
return next
})
} }
return next;
});
};
// Queries // Queries
const { data: themeColors } = useQuery({ const { data: themeColors } = useQuery({
queryKey: ['theme-colors'], queryKey: ['theme-colors'],
queryFn: themeColorsApi.getColors, queryFn: themeColorsApi.getColors,
}) });
const { data: enabledThemes } = useQuery({ const { data: enabledThemes } = useQuery({
queryKey: ['enabled-themes'], queryKey: ['enabled-themes'],
queryFn: themeColorsApi.getEnabledThemes, queryFn: themeColorsApi.getEnabledThemes,
}) });
// Mutations // Mutations
const updateColorsMutation = useMutation({ const updateColorsMutation = useMutation({
mutationFn: themeColorsApi.updateColors, mutationFn: themeColorsApi.updateColors,
onSuccess: (data) => { onSuccess: (data) => {
applyThemeColors(data) applyThemeColors(data);
queryClient.invalidateQueries({ queryKey: ['theme-colors'] }) queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
}, },
}) });
const resetColorsMutation = useMutation({ const resetColorsMutation = useMutation({
mutationFn: themeColorsApi.resetColors, mutationFn: themeColorsApi.resetColors,
onSuccess: (data) => { onSuccess: (data) => {
applyThemeColors(data) applyThemeColors(data);
queryClient.invalidateQueries({ queryKey: ['theme-colors'] }) queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
}, },
}) });
const updateEnabledThemesMutation = useMutation({ const updateEnabledThemesMutation = useMutation({
mutationFn: themeColorsApi.updateEnabledThemes, mutationFn: themeColorsApi.updateEnabledThemes,
onSuccess: (data) => { onSuccess: (data) => {
updateEnabledThemesCache(data) updateEnabledThemesCache(data);
queryClient.invalidateQueries({ queryKey: ['enabled-themes'] }) queryClient.invalidateQueries({ queryKey: ['enabled-themes'] });
}, },
}) });
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Theme toggles */} {/* Theme toggles */}
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50"> <div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.settings.availableThemes')}</h3> <h3 className="mb-4 text-lg font-semibold text-dark-100">
{t('admin.settings.availableThemes')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
<div className="flex items-center justify-between p-3 sm:p-4 rounded-xl bg-dark-700/30"> <div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-3 sm:p-4">
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<MoonIcon /> <MoonIcon />
<span className="font-medium text-dark-200 text-sm sm:text-base">{t('admin.settings.darkTheme')}</span> <span className="text-sm font-medium text-dark-200 sm:text-base">
{t('admin.settings.darkTheme')}
</span>
</div> </div>
<Toggle <Toggle
checked={enabledThemes?.dark ?? true} checked={enabledThemes?.dark ?? true}
onChange={() => { onChange={() => {
if ((enabledThemes?.dark ?? true) && !(enabledThemes?.light ?? true)) return if ((enabledThemes?.dark ?? true) && !(enabledThemes?.light ?? true)) return;
updateEnabledThemesMutation.mutate({ dark: !(enabledThemes?.dark ?? true) }) updateEnabledThemesMutation.mutate({ dark: !(enabledThemes?.dark ?? true) });
}} }}
disabled={updateEnabledThemesMutation.isPending} disabled={updateEnabledThemesMutation.isPending}
/> />
</div> </div>
<div className="flex items-center justify-between p-3 sm:p-4 rounded-xl bg-dark-700/30"> <div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-3 sm:p-4">
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<SunIcon /> <SunIcon />
<span className="font-medium text-dark-200 text-sm sm:text-base">{t('admin.settings.lightTheme')}</span> <span className="text-sm font-medium text-dark-200 sm:text-base">
{t('admin.settings.lightTheme')}
</span>
</div> </div>
<Toggle <Toggle
checked={enabledThemes?.light ?? true} checked={enabledThemes?.light ?? true}
onChange={() => { onChange={() => {
if ((enabledThemes?.light ?? true) && !(enabledThemes?.dark ?? true)) return if ((enabledThemes?.light ?? true) && !(enabledThemes?.dark ?? true)) return;
updateEnabledThemesMutation.mutate({ light: !(enabledThemes?.light ?? true) }) updateEnabledThemesMutation.mutate({ light: !(enabledThemes?.light ?? true) });
}} }}
disabled={updateEnabledThemesMutation.isPending} disabled={updateEnabledThemesMutation.isPending}
/> />
@@ -104,30 +110,34 @@ export function ThemeTab() {
</div> </div>
{/* Quick Presets */} {/* Quick Presets */}
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50"> <div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<button <button
onClick={() => toggleSection('presets')} onClick={() => toggleSection('presets')}
className="w-full flex items-center justify-between" className="flex w-full items-center justify-between"
>
<h3 className="text-lg font-semibold text-dark-100">
{t('admin.settings.quickPresets')}
</h3>
<div
className={`transition-transform ${expandedSections.has('presets') ? 'rotate-180' : ''}`}
> >
<h3 className="text-lg font-semibold text-dark-100">{t('admin.settings.quickPresets')}</h3>
<div className={`transition-transform ${expandedSections.has('presets') ? 'rotate-180' : ''}`}>
<ChevronDownIcon /> <ChevronDownIcon />
</div> </div>
</button> </button>
{expandedSections.has('presets') && ( {expandedSections.has('presets') && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4"> <div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
{THEME_PRESETS.map((preset) => ( {THEME_PRESETS.map((preset) => (
<button <button
key={preset.id} key={preset.id}
onClick={() => updateColorsMutation.mutate(preset.colors)} onClick={() => updateColorsMutation.mutate(preset.colors)}
disabled={updateColorsMutation.isPending} disabled={updateColorsMutation.isPending}
className="p-3 rounded-xl border border-dark-600 hover:border-dark-500 transition-all hover:scale-[1.02]" className="rounded-xl border border-dark-600 p-3 transition-all hover:scale-[1.02] hover:border-dark-500"
style={{ backgroundColor: preset.colors.darkBackground }} style={{ backgroundColor: preset.colors.darkBackground }}
> >
<div className="flex items-center gap-2 mb-2"> <div className="mb-2 flex items-center gap-2">
<div <div
className="w-4 h-4 rounded-full ring-2 ring-white/20" className="h-4 w-4 rounded-full ring-2 ring-white/20"
style={{ backgroundColor: preset.colors.accent }} style={{ backgroundColor: preset.colors.accent }}
/> />
<span className="text-xs font-medium" style={{ color: preset.colors.darkText }}> <span className="text-xs font-medium" style={{ color: preset.colors.darkText }}>
@@ -135,9 +145,18 @@ export function ThemeTab() {
</span> </span>
</div> </div>
<div className="flex gap-1"> <div className="flex gap-1">
<div className="w-3 h-3 rounded" style={{ backgroundColor: preset.colors.success }} /> <div
<div className="w-3 h-3 rounded" style={{ backgroundColor: preset.colors.warning }} /> className="h-3 w-3 rounded"
<div className="w-3 h-3 rounded" style={{ backgroundColor: preset.colors.error }} /> style={{ backgroundColor: preset.colors.success }}
/>
<div
className="h-3 w-3 rounded"
style={{ backgroundColor: preset.colors.warning }}
/>
<div
className="h-3 w-3 rounded"
style={{ backgroundColor: preset.colors.error }}
/>
</div> </div>
</button> </button>
))} ))}
@@ -146,13 +165,17 @@ export function ThemeTab() {
</div> </div>
{/* Custom Colors */} {/* Custom Colors */}
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50"> <div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<button <button
onClick={() => toggleSection('colors')} onClick={() => toggleSection('colors')}
className="w-full flex items-center justify-between" className="flex w-full items-center justify-between"
>
<h3 className="text-lg font-semibold text-dark-100">
{t('admin.settings.customColors')}
</h3>
<div
className={`transition-transform ${expandedSections.has('colors') ? 'rotate-180' : ''}`}
> >
<h3 className="text-lg font-semibold text-dark-100">{t('admin.settings.customColors')}</h3>
<div className={`transition-transform ${expandedSections.has('colors') ? 'rotate-180' : ''}`}>
<ChevronDownIcon /> <ChevronDownIcon />
</div> </div>
</button> </button>
@@ -161,7 +184,9 @@ export function ThemeTab() {
<div className="mt-4 space-y-6"> <div className="mt-4 space-y-6">
{/* Accent */} {/* Accent */}
<div> <div>
<h4 className="text-sm font-medium text-dark-300 mb-3">{t('admin.settings.accentColor')}</h4> <h4 className="mb-3 text-sm font-medium text-dark-300">
{t('admin.settings.accentColor')}
</h4>
<ColorPicker <ColorPicker
label={t('theme.accent')} label={t('theme.accent')}
value={themeColors?.accent || DEFAULT_THEME_COLORS.accent} value={themeColors?.accent || DEFAULT_THEME_COLORS.accent}
@@ -172,10 +197,10 @@ export function ThemeTab() {
{/* Dark theme */} {/* Dark theme */}
<div> <div>
<h4 className="text-sm font-medium text-dark-300 mb-3 flex items-center gap-2"> <h4 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
<MoonIcon /> {t('admin.settings.darkTheme')} <MoonIcon /> {t('admin.settings.darkTheme')}
</h4> </h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ColorPicker <ColorPicker
label={t('admin.settings.colors.background')} label={t('admin.settings.colors.background')}
value={themeColors?.darkBackground || DEFAULT_THEME_COLORS.darkBackground} value={themeColors?.darkBackground || DEFAULT_THEME_COLORS.darkBackground}
@@ -205,10 +230,10 @@ export function ThemeTab() {
{/* Light theme */} {/* Light theme */}
<div> <div>
<h4 className="text-sm font-medium text-dark-300 mb-3 flex items-center gap-2"> <h4 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
<SunIcon /> {t('admin.settings.lightTheme')} <SunIcon /> {t('admin.settings.lightTheme')}
</h4> </h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<ColorPicker <ColorPicker
label={t('admin.settings.colors.background')} label={t('admin.settings.colors.background')}
value={themeColors?.lightBackground || DEFAULT_THEME_COLORS.lightBackground} value={themeColors?.lightBackground || DEFAULT_THEME_COLORS.lightBackground}
@@ -238,8 +263,10 @@ export function ThemeTab() {
{/* Status colors */} {/* Status colors */}
<div> <div>
<h4 className="text-sm font-medium text-dark-300 mb-3">{t('admin.settings.statusColors')}</h4> <h4 className="mb-3 text-sm font-medium text-dark-300">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> {t('admin.settings.statusColors')}
</h4>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<ColorPicker <ColorPicker
label={t('admin.settings.colors.success')} label={t('admin.settings.colors.success')}
value={themeColors?.success || DEFAULT_THEME_COLORS.success} value={themeColors?.success || DEFAULT_THEME_COLORS.success}
@@ -265,7 +292,7 @@ export function ThemeTab() {
<button <button
onClick={() => resetColorsMutation.mutate()} onClick={() => resetColorsMutation.mutate()}
disabled={resetColorsMutation.isPending} disabled={resetColorsMutation.isPending}
className="px-4 py-2 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors disabled:opacity-50" className="rounded-xl bg-dark-700 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
> >
{t('admin.settings.resetAllColors')} {t('admin.settings.resetAllColors')}
</button> </button>
@@ -273,5 +300,5 @@ export function ThemeTab() {
)} )}
</div> </div>
</div> </div>
) );
} }

View File

@@ -1,7 +1,7 @@
interface ToggleProps { interface ToggleProps {
checked: boolean checked: boolean;
onChange: () => void onChange: () => void;
disabled?: boolean disabled?: boolean;
} }
export function Toggle({ checked, onChange, disabled }: ToggleProps) { export function Toggle({ checked, onChange, disabled }: ToggleProps) {
@@ -9,13 +9,15 @@ export function Toggle({ checked, onChange, disabled }: ToggleProps) {
<button <button
onClick={onChange} onClick={onChange}
disabled={disabled} disabled={disabled}
className={`relative w-12 h-6 rounded-full transition-colors ${ className={`relative h-6 w-12 rounded-full transition-colors ${
checked ? 'bg-accent-500' : 'bg-dark-600' checked ? 'bg-accent-500' : 'bg-dark-600'
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`} } ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
> >
<div className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-200 ${ <div
className={`absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition-transform duration-200 ${
checked ? 'translate-x-6' : 'translate-x-0' checked ? 'translate-x-6' : 'translate-x-0'
}`} /> }`}
/>
</button> </button>
) );
} }

View File

@@ -1,15 +1,15 @@
import { ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme' import { ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme';
// Menu item types // Menu item types
export interface MenuItem { export interface MenuItem {
id: string id: string;
iconType?: 'star' | null iconType?: 'star' | null;
categories?: string[] categories?: string[];
} }
export interface MenuSection { export interface MenuSection {
id: string id: string;
items: MenuItem[] items: MenuItem[];
} }
// Sidebar menu configuration // Sidebar menu configuration
@@ -21,36 +21,215 @@ export const MENU_SECTIONS: MenuSection[] = [
{ id: 'branding', iconType: null }, { id: 'branding', iconType: null },
{ id: 'theme', iconType: null }, { id: 'theme', iconType: null },
{ id: 'analytics', iconType: null }, { id: 'analytics', iconType: null },
] ],
}, },
{ {
id: 'settings', id: 'settings',
items: [ items: [
{ id: 'payments', iconType: null, categories: ['PAYMENT', 'PAYMENT_VERIFICATION', 'YOOKASSA', 'CRYPTOBOT', 'HELEKET', 'PLATEGA', 'TRIBUTE', 'MULENPAY', 'PAL24', 'WATA', 'TELEGRAM'] }, {
{ id: 'subscriptions', iconType: null, categories: ['SUBSCRIPTIONS_CORE', 'SIMPLE_SUBSCRIPTION', 'PERIODS', 'SUBSCRIPTION_PRICES', 'TRAFFIC', 'TRAFFIC_PACKAGES', 'TRIAL', 'AUTOPAY'] }, id: 'payments',
{ id: 'interface', iconType: null, categories: ['INTERFACE', 'INTERFACE_BRANDING', 'INTERFACE_SUBSCRIPTION', 'CONNECT_BUTTON', 'MINIAPP', 'HAPP', 'SKIP', 'ADDITIONAL'] }, iconType: null,
{ id: 'notifications', iconType: null, categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'] }, categories: [
'PAYMENT',
'PAYMENT_VERIFICATION',
'YOOKASSA',
'CRYPTOBOT',
'HELEKET',
'PLATEGA',
'TRIBUTE',
'MULENPAY',
'PAL24',
'WATA',
'TELEGRAM',
],
},
{
id: 'subscriptions',
iconType: null,
categories: [
'SUBSCRIPTIONS_CORE',
'SIMPLE_SUBSCRIPTION',
'PERIODS',
'SUBSCRIPTION_PRICES',
'TRAFFIC',
'TRAFFIC_PACKAGES',
'TRIAL',
'AUTOPAY',
],
},
{
id: 'interface',
iconType: null,
categories: [
'INTERFACE',
'INTERFACE_BRANDING',
'INTERFACE_SUBSCRIPTION',
'CONNECT_BUTTON',
'MINIAPP',
'HAPP',
'SKIP',
'ADDITIONAL',
],
},
{
id: 'notifications',
iconType: null,
categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'],
},
{ id: 'database', iconType: null, categories: ['DATABASE', 'POSTGRES', 'SQLITE', 'REDIS'] }, { id: 'database', iconType: null, categories: ['DATABASE', 'POSTGRES', 'SQLITE', 'REDIS'] },
{ id: 'system', iconType: null, categories: ['CORE', 'REMNAWAVE', 'SERVER_STATUS', 'MONITORING', 'MAINTENANCE', 'BACKUP', 'VERSION', 'WEB_API', 'WEBHOOK', 'LOG', 'DEBUG', 'EXTERNAL_ADMIN'] }, {
{ id: 'users', iconType: null, categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'] }, id: 'system',
] iconType: null,
} categories: [
] 'CORE',
'REMNAWAVE',
'SERVER_STATUS',
'MONITORING',
'MAINTENANCE',
'BACKUP',
'VERSION',
'WEB_API',
'WEBHOOK',
'LOG',
'DEBUG',
'EXTERNAL_ADMIN',
],
},
{
id: 'users',
iconType: null,
categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'],
},
],
},
];
// Theme preset type // Theme preset type
export interface ThemePreset { export interface ThemePreset {
id: string id: string;
colors: ThemeColors colors: ThemeColors;
} }
// Theme presets // Theme presets
export const THEME_PRESETS: ThemePreset[] = [ export const THEME_PRESETS: ThemePreset[] = [
{ id: 'standard', colors: DEFAULT_THEME_COLORS }, { id: 'standard', colors: DEFAULT_THEME_COLORS },
{ id: 'ocean', colors: { accent: '#0ea5e9', darkBackground: '#0c1222', darkSurface: '#1e293b', darkText: '#f1f5f9', darkTextSecondary: '#94a3b8', lightBackground: '#e0f2fe', lightSurface: '#f0f9ff', lightText: '#0c4a6e', lightTextSecondary: '#0369a1', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } }, {
{ id: 'forest', colors: { accent: '#22c55e', darkBackground: '#0a1a0f', darkSurface: '#14532d', darkText: '#f0fdf4', darkTextSecondary: '#86efac', lightBackground: '#dcfce7', lightSurface: '#f0fdf4', lightText: '#14532d', lightTextSecondary: '#166534', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } }, id: 'ocean',
{ id: 'sunset', colors: { accent: '#f97316', darkBackground: '#1c1009', darkSurface: '#2d1a0e', darkText: '#fff7ed', darkTextSecondary: '#fdba74', lightBackground: '#ffedd5', lightSurface: '#fff7ed', lightText: '#7c2d12', lightTextSecondary: '#c2410c', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } }, colors: {
{ id: 'violet', colors: { accent: '#a855f7', darkBackground: '#0f0a1a', darkSurface: '#1e1b2e', darkText: '#faf5ff', darkTextSecondary: '#c4b5fd', lightBackground: '#f3e8ff', lightSurface: '#faf5ff', lightText: '#581c87', lightTextSecondary: '#7e22ce', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } }, accent: '#0ea5e9',
{ id: 'rose', colors: { accent: '#f43f5e', darkBackground: '#1a0a10', darkSurface: '#2d1520', darkText: '#fff1f2', darkTextSecondary: '#fda4af', lightBackground: '#ffe4e6', lightSurface: '#fff1f2', lightText: '#881337', lightTextSecondary: '#be123c', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } }, darkBackground: '#0c1222',
{ id: 'midnight', colors: { accent: '#6366f1', darkBackground: '#030712', darkSurface: '#111827', darkText: '#f9fafb', darkTextSecondary: '#9ca3af', lightBackground: '#e5e7eb', lightSurface: '#f3f4f6', lightText: '#111827', lightTextSecondary: '#4b5563', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } }, darkSurface: '#1e293b',
{ id: 'turquoise', colors: { accent: '#14b8a6', darkBackground: '#0a1614', darkSurface: '#134e4a', darkText: '#f0fdfa', darkTextSecondary: '#5eead4', lightBackground: '#ccfbf1', lightSurface: '#f0fdfa', lightText: '#134e4a', lightTextSecondary: '#0f766e', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } }, darkText: '#f1f5f9',
] darkTextSecondary: '#94a3b8',
lightBackground: '#e0f2fe',
lightSurface: '#f0f9ff',
lightText: '#0c4a6e',
lightTextSecondary: '#0369a1',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
},
},
{
id: 'forest',
colors: {
accent: '#22c55e',
darkBackground: '#0a1a0f',
darkSurface: '#14532d',
darkText: '#f0fdf4',
darkTextSecondary: '#86efac',
lightBackground: '#dcfce7',
lightSurface: '#f0fdf4',
lightText: '#14532d',
lightTextSecondary: '#166534',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
},
},
{
id: 'sunset',
colors: {
accent: '#f97316',
darkBackground: '#1c1009',
darkSurface: '#2d1a0e',
darkText: '#fff7ed',
darkTextSecondary: '#fdba74',
lightBackground: '#ffedd5',
lightSurface: '#fff7ed',
lightText: '#7c2d12',
lightTextSecondary: '#c2410c',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
},
},
{
id: 'violet',
colors: {
accent: '#a855f7',
darkBackground: '#0f0a1a',
darkSurface: '#1e1b2e',
darkText: '#faf5ff',
darkTextSecondary: '#c4b5fd',
lightBackground: '#f3e8ff',
lightSurface: '#faf5ff',
lightText: '#581c87',
lightTextSecondary: '#7e22ce',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
},
},
{
id: 'rose',
colors: {
accent: '#f43f5e',
darkBackground: '#1a0a10',
darkSurface: '#2d1520',
darkText: '#fff1f2',
darkTextSecondary: '#fda4af',
lightBackground: '#ffe4e6',
lightSurface: '#fff1f2',
lightText: '#881337',
lightTextSecondary: '#be123c',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
},
},
{
id: 'midnight',
colors: {
accent: '#6366f1',
darkBackground: '#030712',
darkSurface: '#111827',
darkText: '#f9fafb',
darkTextSecondary: '#9ca3af',
lightBackground: '#e5e7eb',
lightSurface: '#f3f4f6',
lightText: '#111827',
lightTextSecondary: '#4b5563',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
},
},
{
id: 'turquoise',
colors: {
accent: '#14b8a6',
darkBackground: '#0a1614',
darkSurface: '#134e4a',
darkText: '#f0fdfa',
darkTextSecondary: '#5eead4',
lightBackground: '#ccfbf1',
lightSurface: '#f0fdfa',
lightText: '#134e4a',
lightTextSecondary: '#0f766e',
success: '#22c55e',
warning: '#f59e0b',
error: '#ef4444',
},
},
];

View File

@@ -1,91 +1,141 @@
// Admin Settings Icons // Admin Settings Icons
export const BackIcon = () => ( export const BackIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg> </svg>
) );
export const SearchIcon = () => ( export const SearchIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <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> </svg>
) );
export const StarIcon = ({ filled }: { filled?: boolean }) => ( export const StarIcon = ({ filled }: { filled?: boolean }) => (
<svg className="w-5 h-5" fill={filled ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg
<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" /> className="h-5 w-5"
fill={filled ? 'currentColor' : 'none'}
viewBox="0 0 24 24"
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> </svg>
) );
export const ChevronDownIcon = () => ( export const ChevronDownIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg> </svg>
) );
export const UploadIcon = () => ( export const UploadIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.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> </svg>
) );
export const TrashIcon = () => ( export const TrashIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-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" /> <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> </svg>
) );
export const PencilIcon = () => ( export const PencilIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <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> </svg>
) );
export const RefreshIcon = () => ( export const RefreshIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <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> </svg>
) );
export const LockIcon = () => ( export const LockIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" 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" /> <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> </svg>
) );
export const CheckIcon = () => ( export const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg> </svg>
) );
export const CloseIcon = () => ( export const CloseIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
) );
export const SunIcon = () => ( export const SunIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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" /> <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> </svg>
) );
export const MoonIcon = () => ( export const MoonIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<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" /> <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> </svg>
) );
export const MenuIcon = () => ( export const MenuIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg> </svg>
) );
export const EditIcon = () => ( export const EditIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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.125" /> <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> </svg>
) );

View File

@@ -1,16 +1,16 @@
// Components // Components
export * from './icons' export * from './icons';
export * from './Toggle' export * from './Toggle';
export * from './SettingInput' export * from './SettingInput';
export * from './SettingRow' export * from './SettingRow';
export * from './AnalyticsTab' export * from './AnalyticsTab';
export * from './BrandingTab' export * from './BrandingTab';
export * from './ThemeTab' export * from './ThemeTab';
export * from './FavoritesTab' export * from './FavoritesTab';
export * from './SettingsTab' export * from './SettingsTab';
export * from './SettingsSidebar' export * from './SettingsSidebar';
export * from './SettingsSearch' export * from './SettingsSearch';
// Constants and utils // Constants and utils
export * from './constants' export * from './constants';
export * from './utils' export * from './utils';

View File

@@ -1,8 +1,9 @@
// Format setting key from Snake_Case / CamelCase to readable text // Format setting key from Snake_Case / CamelCase to readable text
export function formatSettingKey(name: string): string { export function formatSettingKey(name: string): string {
if (!name) return '' if (!name) return '';
return name return (
name
// CamelCase -> spaces // CamelCase -> spaces
.replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/([a-z])([A-Z])/g, '$1 $2')
// snake_case -> spaces // snake_case -> spaces
@@ -11,12 +12,13 @@ export function formatSettingKey(name: string): string {
.replace(/\s+/g, ' ') .replace(/\s+/g, ' ')
.trim() .trim()
// Capitalize first letter // Capitalize first letter
.replace(/^./, c => c.toUpperCase()) .replace(/^./, (c) => c.toUpperCase())
);
} }
// Strip HTML tags and template descriptions from setting descriptions // Strip HTML tags and template descriptions from setting descriptions
export function stripHtml(html: string): string { export function stripHtml(html: string): string {
if (!html) return '' if (!html) return '';
const cleaned = html const cleaned = html
.replace(/<[^>]*>/g, '') .replace(/<[^>]*>/g, '')
.replace(/&nbsp;/g, ' ') .replace(/&nbsp;/g, ' ')
@@ -24,12 +26,12 @@ export function stripHtml(html: string): string {
.replace(/&lt;/g, '<') .replace(/&lt;/g, '<')
.replace(/&gt;/g, '>') .replace(/&gt;/g, '>')
.replace(/&quot;/g, '"') .replace(/&quot;/g, '"')
.trim() .trim();
// Remove template descriptions like "Параметр X управляет категорией Y" // Remove template descriptions like "Параметр X управляет категорией Y"
if (cleaned.match(/^Параметр .+ управляет категорией/)) { if (cleaned.match(/^Параметр .+ управляет категорией/)) {
return '' return '';
} }
return cleaned return cleaned;
} }

View File

@@ -1,97 +1,99 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking' import { useBlockingStore } from '../../store/blocking';
import { apiClient } from '../../api/client' import { apiClient } from '../../api/client';
const CHECK_COOLDOWN_SECONDS = 5 const CHECK_COOLDOWN_SECONDS = 5;
export default function ChannelSubscriptionScreen() { export default function ChannelSubscriptionScreen() {
const { t } = useTranslation() const { t } = useTranslation();
const { channelInfo, clearBlocking } = useBlockingStore() const { channelInfo, clearBlocking } = useBlockingStore();
const [isChecking, setIsChecking] = useState(false) const [isChecking, setIsChecking] = useState(false);
const [cooldown, setCooldown] = useState(0) const [cooldown, setCooldown] = useState(0);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
// Cooldown timer // Cooldown timer
useEffect(() => { useEffect(() => {
if (cooldown <= 0) return if (cooldown <= 0) return;
const timer = setInterval(() => { const timer = setInterval(() => {
setCooldown((prev) => { setCooldown((prev) => {
if (prev <= 1) { if (prev <= 1) {
clearInterval(timer) clearInterval(timer);
return 0 return 0;
} }
return prev - 1 return prev - 1;
}) });
}, 1000) }, 1000);
return () => clearInterval(timer) return () => clearInterval(timer);
}, [cooldown]) }, [cooldown]);
const openChannel = useCallback(() => { const openChannel = useCallback(() => {
if (channelInfo?.channel_link) { if (channelInfo?.channel_link) {
window.open(channelInfo.channel_link, '_blank') window.open(channelInfo.channel_link, '_blank');
} }
}, [channelInfo?.channel_link]) }, [channelInfo?.channel_link]);
const checkSubscription = useCallback(async () => { const checkSubscription = useCallback(async () => {
if (isChecking || cooldown > 0) return if (isChecking || cooldown > 0) return;
setIsChecking(true) setIsChecking(true);
setError(null) setError(null);
try { try {
// Make any authenticated request - if channel check passes, it will succeed // Make any authenticated request - if channel check passes, it will succeed
await apiClient.get('/cabinet/auth/me') await apiClient.get('/cabinet/auth/me');
// If we get here, subscription is valid - reload page // If we get here, subscription is valid - reload page
clearBlocking() clearBlocking();
window.location.reload() window.location.reload();
} catch (err: unknown) { } catch (err: unknown) {
// Check if it's still a channel subscription error // Check if it's still a channel subscription error
const error = err as { response?: { status?: number; data?: { detail?: { code?: string } } } } const error = err as {
if (error.response?.status === 403 && error.response?.data?.detail?.code === 'channel_subscription_required') { response?: { status?: number; data?: { detail?: { code?: string } } };
setError(t('blocking.channel.notSubscribed', 'Вы ещё не подписались на канал')) };
if (
error.response?.status === 403 &&
error.response?.data?.detail?.code === 'channel_subscription_required'
) {
setError(t('blocking.channel.notSubscribed', 'Вы ещё не подписались на канал'));
} else { } else {
// Other error - might be network issue // Other error - might be network issue
setError(t('blocking.channel.checkError', 'Ошибка проверки. Попробуйте позже.')) setError(t('blocking.channel.checkError', 'Ошибка проверки. Попробуйте позже.'));
} }
} finally { } finally {
setIsChecking(false) setIsChecking(false);
setCooldown(CHECK_COOLDOWN_SECONDS) setCooldown(CHECK_COOLDOWN_SECONDS);
} }
}, [isChecking, cooldown, clearBlocking, t]) }, [isChecking, cooldown, clearBlocking, t]);
return ( return (
<div className="fixed inset-0 z-[100] bg-dark-950 flex flex-col items-center justify-center p-6"> <div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
<div className="w-full max-w-md text-center"> <div className="w-full max-w-md text-center">
{/* Icon */} {/* Icon */}
<div className="mb-8"> <div className="mb-8">
<div className="w-24 h-24 mx-auto rounded-full bg-gradient-to-br from-blue-500/20 to-cyan-500/20 flex items-center justify-center"> <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 <svg className="h-12 w-12 text-blue-400" fill="currentColor" viewBox="0 0 24 24">
className="w-12 h-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" />
fill="currentColor"
viewBox="0 0 24 24"
>
<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> </svg>
</div> </div>
</div> </div>
{/* Title */} {/* Title */}
<h1 className="text-2xl font-bold text-white mb-4"> <h1 className="mb-4 text-2xl font-bold text-white">
{t('blocking.channel.title', 'Подписка на канал')} {t('blocking.channel.title', 'Подписка на канал')}
</h1> </h1>
{/* Message */} {/* Message */}
<p className="text-gray-400 mb-8 text-lg"> <p className="mb-8 text-lg text-gray-400">
{channelInfo?.message || t('blocking.channel.defaultMessage', 'Для продолжения работы подпишитесь на наш канал')} {channelInfo?.message ||
t('blocking.channel.defaultMessage', 'Для продолжения работы подпишитесь на наш канал')}
</p> </p>
{/* Error message */} {/* Error message */}
{error && ( {error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4 mb-6"> <div className="mb-6 rounded-xl border border-red-500/30 bg-red-500/10 p-4">
<p className="text-red-400 text-sm">{error}</p> <p className="text-sm text-red-400">{error}</p>
</div> </div>
)} )}
@@ -101,14 +103,10 @@ export default function ChannelSubscriptionScreen() {
<button <button
onClick={openChannel} onClick={openChannel}
disabled={!channelInfo?.channel_link} disabled={!channelInfo?.channel_link}
className="w-full py-4 px-6 bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600 disabled:from-gray-600 disabled:to-gray-600 text-white font-semibold rounded-xl transition-all duration-200 flex items-center justify-center gap-3" className="flex w-full items-center justify-center gap-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 px-6 py-4 font-semibold text-white transition-all duration-200 hover:from-blue-600 hover:to-cyan-600 disabled:from-gray-600 disabled:to-gray-600"
> >
<svg <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
className="w-5 h-5" <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" />
fill="currentColor"
viewBox="0 0 24 24"
>
<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> </svg>
{t('blocking.channel.openChannel', 'Открыть канал')} {t('blocking.channel.openChannel', 'Открыть канал')}
</button> </button>
@@ -117,27 +115,60 @@ export default function ChannelSubscriptionScreen() {
<button <button
onClick={checkSubscription} onClick={checkSubscription}
disabled={isChecking || cooldown > 0} disabled={isChecking || cooldown > 0}
className="w-full py-4 px-6 bg-dark-800 hover:bg-dark-700 disabled:bg-dark-800 disabled:opacity-60 text-white font-semibold rounded-xl transition-all duration-200 flex items-center justify-center gap-3" className="flex w-full items-center justify-center gap-3 rounded-xl bg-dark-800 px-6 py-4 font-semibold text-white transition-all duration-200 hover:bg-dark-700 disabled:bg-dark-800 disabled:opacity-60"
> >
{isChecking ? ( {isChecking ? (
<> <>
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> className="h-5 w-5 animate-spin"
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg> </svg>
{t('blocking.channel.checking', 'Проверяем...')} {t('blocking.channel.checking', 'Проверяем...')}
</> </>
) : cooldown > 0 ? ( ) : cooldown > 0 ? (
<> <>
<svg className="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> className="h-5 w-5 text-gray-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> </svg>
{t('blocking.channel.waitSeconds', 'Подождите {{seconds}} сек.', { seconds: cooldown })} {t('blocking.channel.waitSeconds', 'Подождите {{seconds}} сек.', {
seconds: cooldown,
})}
</> </>
) : ( ) : (
<> <>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg> </svg>
{t('blocking.channel.checkSubscription', 'Проверить подписку')} {t('blocking.channel.checkSubscription', 'Проверить подписку')}
</> </>
@@ -146,10 +177,10 @@ export default function ChannelSubscriptionScreen() {
</div> </div>
{/* Hint */} {/* Hint */}
<p className="text-gray-500 text-sm mt-6"> <p className="mt-6 text-sm text-gray-500">
{t('blocking.channel.hint', 'После подписки нажмите кнопку проверки')} {t('blocking.channel.hint', 'После подписки нажмите кнопку проверки')}
</p> </p>
</div> </div>
</div> </div>
) );
} }

View File

@@ -1,18 +1,18 @@
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking' import { useBlockingStore } from '../../store/blocking';
export default function MaintenanceScreen() { export default function MaintenanceScreen() {
const { t } = useTranslation() const { t } = useTranslation();
const { maintenanceInfo } = useBlockingStore() const { maintenanceInfo } = useBlockingStore();
return ( return (
<div className="fixed inset-0 z-[100] bg-dark-950 flex flex-col items-center justify-center p-6"> <div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
<div className="w-full max-w-md text-center"> <div className="w-full max-w-md text-center">
{/* Icon */} {/* Icon */}
<div className="mb-8"> <div className="mb-8">
<div className="w-24 h-24 mx-auto rounded-full bg-dark-800 flex items-center justify-center"> <div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
<svg <svg
className="w-12 h-12 text-amber-500" className="h-12 w-12 text-amber-500"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -28,38 +28,49 @@ export default function MaintenanceScreen() {
</div> </div>
{/* Title */} {/* Title */}
<h1 className="text-2xl font-bold text-white mb-4"> <h1 className="mb-4 text-2xl font-bold text-white">
{t('blocking.maintenance.title', 'Технические работы')} {t('blocking.maintenance.title', 'Технические работы')}
</h1> </h1>
{/* Message */} {/* Message */}
<p className="text-gray-400 mb-6 text-lg"> <p className="mb-6 text-lg text-gray-400">
{maintenanceInfo?.message || t('blocking.maintenance.defaultMessage', 'Сервис временно недоступен. Проводятся технические работы.')} {maintenanceInfo?.message ||
t(
'blocking.maintenance.defaultMessage',
'Сервис временно недоступен. Проводятся технические работы.',
)}
</p> </p>
{/* Reason */} {/* Reason */}
{maintenanceInfo?.reason && ( {maintenanceInfo?.reason && (
<div className="bg-dark-800/50 rounded-xl p-4 mb-6"> <div className="mb-6 rounded-xl bg-dark-800/50 p-4">
<p className="text-gray-500 text-sm mb-1"> <p className="mb-1 text-sm text-gray-500">
{t('blocking.maintenance.reason', 'Причина')}: {t('blocking.maintenance.reason', 'Причина')}:
</p> </p>
<p className="text-gray-300"> <p className="text-gray-300">{maintenanceInfo.reason}</p>
{maintenanceInfo.reason}
</p>
</div> </div>
)} )}
{/* Decorative dots */} {/* Decorative dots */}
<div className="flex items-center justify-center gap-2 mt-8"> <div className="mt-8 flex items-center justify-center gap-2">
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" style={{ animationDelay: '0ms' }} /> <div
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" style={{ animationDelay: '300ms' }} /> className="h-2 w-2 animate-pulse rounded-full bg-amber-500"
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" style={{ animationDelay: '600ms' }} /> style={{ animationDelay: '0ms' }}
/>
<div
className="h-2 w-2 animate-pulse rounded-full bg-amber-500"
style={{ animationDelay: '300ms' }}
/>
<div
className="h-2 w-2 animate-pulse rounded-full bg-amber-500"
style={{ animationDelay: '600ms' }}
/>
</div> </div>
<p className="text-gray-500 text-sm mt-4"> <p className="mt-4 text-sm text-gray-500">
{t('blocking.maintenance.waitMessage', 'Пожалуйста, подождите...')} {t('blocking.maintenance.waitMessage', 'Пожалуйста, подождите...')}
</p> </p>
</div> </div>
</div> </div>
) );
} }

View File

@@ -1,2 +1,2 @@
export { default as MaintenanceScreen } from './MaintenanceScreen' export { default as MaintenanceScreen } from './MaintenanceScreen';
export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen' export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen';

View File

@@ -1,14 +1,16 @@
interface PageLoaderProps { interface PageLoaderProps {
variant?: 'dark' | 'light' variant?: 'dark' | 'light';
} }
export default function PageLoader({ variant = 'dark' }: PageLoaderProps) { export default function PageLoader({ variant = 'dark' }: PageLoaderProps) {
const bgClass = variant === 'dark' ? 'bg-dark-950' : 'bg-gray-50' const bgClass = variant === 'dark' ? 'bg-dark-950' : 'bg-gray-50';
const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500' const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500';
return ( return (
<div className={`min-h-screen flex items-center justify-center ${bgClass}`}> <div className={`flex min-h-screen items-center justify-center ${bgClass}`}>
<div className={`w-10 h-10 border-[3px] ${spinnerColor} border-t-transparent rounded-full animate-spin`} /> <div
className={`h-10 w-10 border-[3px] ${spinnerColor} animate-spin rounded-full border-t-transparent`}
/>
</div> </div>
) );
} }

View File

@@ -1,274 +1,346 @@
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../../store/auth' import { useAuthStore } from '../../store/auth';
import LanguageSwitcher from '../LanguageSwitcher' import LanguageSwitcher from '../LanguageSwitcher';
import PromoDiscountBadge from '../PromoDiscountBadge' import PromoDiscountBadge from '../PromoDiscountBadge';
import TicketNotificationBell from '../TicketNotificationBell' import TicketNotificationBell from '../TicketNotificationBell';
import AnimatedBackground from '../AnimatedBackground' import AnimatedBackground from '../AnimatedBackground';
import { contestsApi } from '../../api/contests' import { contestsApi } from '../../api/contests';
import { pollsApi } from '../../api/polls' import { pollsApi } from '../../api/polls';
import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo, isLogoPreloaded } from '../../api/branding' import {
import { wheelApi } from '../../api/wheel' brandingApi,
import { themeColorsApi } from '../../api/themeColors' getCachedBranding,
import { promoApi } from '../../api/promo' setCachedBranding,
import { referralApi } from '../../api/referral' preloadLogo,
import { useTheme } from '../../hooks/useTheme' isLogoPreloaded,
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp' } from '../../api/branding';
import { usePullToRefresh } from '../../hooks/usePullToRefresh' import { wheelApi } from '../../api/wheel';
import { themeColorsApi } from '../../api/themeColors';
import { promoApi } from '../../api/promo';
import { referralApi } from '../../api/referral';
import { useTheme } from '../../hooks/useTheme';
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp';
import { usePullToRefresh } from '../../hooks/usePullToRefresh';
// Fallback branding from environment variables // Fallback branding from environment variables
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet' const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V' const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
interface LayoutProps { interface LayoutProps {
children: React.ReactNode children: React.ReactNode;
} }
// Icons as simple SVG components // Icons as simple SVG components
const HomeIcon = () => ( const HomeIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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 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" /> <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> </svg>
) );
const SubscriptionIcon = () => ( const SubscriptionIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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" /> <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> </svg>
) );
const WalletIcon = () => ( const WalletIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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" /> <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> </svg>
) );
const UsersIcon = () => ( const UsersIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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" /> <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> </svg>
) );
const ChatIcon = () => ( const ChatIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" 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" /> <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> </svg>
) );
const UserIcon = () => ( const UserIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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.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" /> <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> </svg>
) );
const LogoutIcon = () => ( const LogoutIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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.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" /> <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> </svg>
) );
// Theme toggle icons // Theme toggle icons
const SunIcon = () => ( const SunIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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 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" /> <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> </svg>
) );
const MoonIcon = () => ( const MoonIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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.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" /> <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> </svg>
) );
const MenuIcon = () => ( const MenuIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg> </svg>
) );
const CloseIcon = () => ( const CloseIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.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>
) );
const GamepadIcon = () => ( const GamepadIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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.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" /> <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> </svg>
) );
const ClipboardIcon = () => ( const ClipboardIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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" /> <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> </svg>
) );
const InfoIcon = () => ( const InfoIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" 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" /> <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> </svg>
) );
const CogIcon = () => ( const CogIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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="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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
) );
const WheelIcon = () => ( const WheelIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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 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" /> <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> </svg>
) );
export default function Layout({ children }: LayoutProps) { export default function Layout({ children }: LayoutProps) {
const { t } = useTranslation() const { t } = useTranslation();
const location = useLocation() const location = useLocation();
const { user, logout, isAdmin, isAuthenticated } = useAuthStore() const { user, logout, isAdmin, isAuthenticated } = useAuthStore();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false) const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
const { toggleTheme, isDark } = useTheme() const { toggleTheme, isDark } = useTheme();
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null) const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null);
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
// Pull to refresh (disabled when mobile menu is open) // Pull to refresh (disabled when mobile menu is open)
const { isPulling, pullDistance, isRefreshing, progress } = usePullToRefresh({ const { isPulling, pullDistance, isRefreshing, progress } = usePullToRefresh({
disabled: mobileMenuOpen, disabled: mobileMenuOpen,
threshold: 80, threshold: 80,
}) });
// Fetch enabled themes from API - same source of truth as AdminSettings // Fetch enabled themes from API - same source of truth as AdminSettings
const { data: enabledThemes } = useQuery({ const { data: enabledThemes } = useQuery({
queryKey: ['enabled-themes'], queryKey: ['enabled-themes'],
queryFn: themeColorsApi.getEnabledThemes, queryFn: themeColorsApi.getEnabledThemes,
staleTime: 1000 * 60 * 5, // 5 minutes staleTime: 1000 * 60 * 5, // 5 minutes
}) });
// Only show theme toggle if both themes are enabled // Only show theme toggle if both themes are enabled
const canToggle = enabledThemes?.dark && enabledThemes?.light const canToggle = enabledThemes?.dark && enabledThemes?.light;
// Get user photo from Telegram WebApp // Get user photo from Telegram WebApp
useEffect(() => { useEffect(() => {
try { try {
const tg = (window as any).Telegram?.WebApp const tg = (
const photoUrl = tg?.initDataUnsafe?.user?.photo_url window as unknown as {
Telegram?: { WebApp?: { initDataUnsafe?: { user?: { photo_url?: string } } } };
}
).Telegram?.WebApp;
const photoUrl = tg?.initDataUnsafe?.user?.photo_url;
if (photoUrl) { if (photoUrl) {
setUserPhotoUrl(photoUrl) setUserPhotoUrl(photoUrl);
} }
} catch (e) { } catch (e) {
console.warn('Failed to get Telegram user photo:', e) console.warn('Failed to get Telegram user photo:', e);
} }
}, []) }, []);
// Lock body scroll when mobile menu is open (cross-platform) // Lock body scroll when mobile menu is open (cross-platform)
// Note: We avoid using body position:fixed with top:-scrollY as it causes issues // Note: We avoid using body position:fixed with top:-scrollY as it causes issues
// in Telegram Mini App where the menu disappears when opened from scrolled position // in Telegram Mini App where the menu disappears when opened from scrolled position
useEffect(() => { useEffect(() => {
if (!mobileMenuOpen) return if (!mobileMenuOpen) return;
const body = document.body const body = document.body;
const html = document.documentElement const html = document.documentElement;
// Save original styles // Save original styles
const originalStyles = { const originalStyles = {
bodyOverflow: body.style.overflow, bodyOverflow: body.style.overflow,
htmlOverflow: html.style.overflow, htmlOverflow: html.style.overflow,
} };
// Lock scroll - simple approach without body position manipulation // Lock scroll - simple approach without body position manipulation
body.style.overflow = 'hidden' body.style.overflow = 'hidden';
html.style.overflow = 'hidden' html.style.overflow = 'hidden';
// Prevent touchmove on body (critical for mobile, especially Telegram Mini App) // Prevent touchmove on body (critical for mobile, especially Telegram Mini App)
const preventScroll = (e: TouchEvent) => { const preventScroll = (e: TouchEvent) => {
const target = e.target as HTMLElement const target = e.target as HTMLElement;
// Allow scroll inside menu content // Allow scroll inside menu content
if (target.closest('.mobile-menu-content')) return if (target.closest('.mobile-menu-content')) return;
e.preventDefault() e.preventDefault();
} };
document.addEventListener('touchmove', preventScroll, { passive: false }) document.addEventListener('touchmove', preventScroll, { passive: false });
// Also prevent wheel scroll on desktop // Also prevent wheel scroll on desktop
const preventWheel = (e: WheelEvent) => { const preventWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement const target = e.target as HTMLElement;
if (target.closest('.mobile-menu-content')) return if (target.closest('.mobile-menu-content')) return;
e.preventDefault() e.preventDefault();
} };
document.addEventListener('wheel', preventWheel, { passive: false }) document.addEventListener('wheel', preventWheel, { passive: false });
return () => { return () => {
// Restore original styles // Restore original styles
body.style.overflow = originalStyles.bodyOverflow body.style.overflow = originalStyles.bodyOverflow;
html.style.overflow = originalStyles.htmlOverflow html.style.overflow = originalStyles.htmlOverflow;
// Remove listeners // Remove listeners
document.removeEventListener('touchmove', preventScroll) document.removeEventListener('touchmove', preventScroll);
document.removeEventListener('wheel', preventWheel) document.removeEventListener('wheel', preventWheel);
} };
}, [mobileMenuOpen]) }, [mobileMenuOpen]);
// Detect virtual keyboard by tracking focus on input elements // Detect virtual keyboard by tracking focus on input elements
useEffect(() => { useEffect(() => {
const handleFocusIn = (e: FocusEvent) => { const handleFocusIn = (e: FocusEvent) => {
const target = e.target as HTMLElement const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
setIsKeyboardOpen(true) setIsKeyboardOpen(true);
}
} }
};
const handleFocusOut = (e: FocusEvent) => { const handleFocusOut = (e: FocusEvent) => {
const relatedTarget = e.relatedTarget as HTMLElement | null const relatedTarget = e.relatedTarget as HTMLElement | null;
// Only close if not focusing another input // Only close if not focusing another input
if (!relatedTarget || if (
!relatedTarget ||
(relatedTarget.tagName !== 'INPUT' && (relatedTarget.tagName !== 'INPUT' &&
relatedTarget.tagName !== 'TEXTAREA' && relatedTarget.tagName !== 'TEXTAREA' &&
!relatedTarget.isContentEditable)) { !relatedTarget.isContentEditable)
setIsKeyboardOpen(false) ) {
} setIsKeyboardOpen(false);
} }
};
document.addEventListener('focusin', handleFocusIn) document.addEventListener('focusin', handleFocusIn);
document.addEventListener('focusout', handleFocusOut) document.addEventListener('focusout', handleFocusOut);
return () => { return () => {
document.removeEventListener('focusin', handleFocusIn) document.removeEventListener('focusin', handleFocusIn);
document.removeEventListener('focusout', handleFocusOut) document.removeEventListener('focusout', handleFocusOut);
} };
}, []) }, []);
// State to track if logo image has loaded - start with true if already preloaded // State to track if logo image has loaded - start with true if already preloaded
const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded()) const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded());
// Fetch branding settings with localStorage cache for instant load // Fetch branding settings with localStorage cache for instant load
const { data: branding } = useQuery({ const { data: branding } = useQuery({
queryKey: ['branding'], queryKey: ['branding'],
queryFn: async () => { queryFn: async () => {
const data = await brandingApi.getBranding() const data = await brandingApi.getBranding();
setCachedBranding(data) // Update cache setCachedBranding(data); // Update cache
// Preload logo in background // Preload logo in background
preloadLogo(data) preloadLogo(data);
return data return data;
}, },
initialData: getCachedBranding() ?? undefined, // Use cached data immediately initialData: getCachedBranding() ?? undefined, // Use cached data immediately
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
retry: 1, retry: 1,
}) });
// Computed branding values - use fallback only if no branding and no cache // Computed branding values - use fallback only if no branding and no cache
const appName = branding ? branding.name : FALLBACK_NAME // Empty string is valid (logo-only mode) const appName = branding ? branding.name : FALLBACK_NAME; // Empty string is valid (logo-only mode)
const logoLetter = branding?.logo_letter || FALLBACK_LOGO const logoLetter = branding?.logo_letter || FALLBACK_LOGO;
const hasCustomLogo = branding?.has_custom_logo || false const hasCustomLogo = branding?.has_custom_logo || false;
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
// Set document title // Set document title
useEffect(() => { useEffect(() => {
document.title = appName || 'VPN' // Fallback title if name is empty document.title = appName || 'VPN'; // Fallback title if name is empty
}, [appName]) }, [appName]);
// Fetch contests and polls counts to determine if they should be shown // Fetch contests and polls counts to determine if they should be shown
const { data: contestsCount } = useQuery({ const { data: contestsCount } = useQuery({
@@ -277,7 +349,7 @@ export default function Layout({ children }: LayoutProps) {
enabled: isAuthenticated, enabled: isAuthenticated,
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
retry: false, retry: false,
}) });
const { data: pollsCount } = useQuery({ const { data: pollsCount } = useQuery({
queryKey: ['polls-count'], queryKey: ['polls-count'],
@@ -285,7 +357,7 @@ export default function Layout({ children }: LayoutProps) {
enabled: isAuthenticated, enabled: isAuthenticated,
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
retry: false, retry: false,
}) });
// Fetch wheel config to check if enabled // Fetch wheel config to check if enabled
const { data: wheelConfig } = useQuery({ const { data: wheelConfig } = useQuery({
@@ -294,7 +366,7 @@ export default function Layout({ children }: LayoutProps) {
enabled: isAuthenticated, enabled: isAuthenticated,
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
retry: false, retry: false,
}) });
// Fetch referral terms to check if enabled // Fetch referral terms to check if enabled
const { data: referralTerms } = useQuery({ const { data: referralTerms } = useQuery({
@@ -303,7 +375,7 @@ export default function Layout({ children }: LayoutProps) {
enabled: isAuthenticated, enabled: isAuthenticated,
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
retry: false, retry: false,
}) });
// Fetch active discount to determine mobile layout // Fetch active discount to determine mobile layout
const { data: activeDiscount } = useQuery({ const { data: activeDiscount } = useQuery({
@@ -311,86 +383,90 @@ export default function Layout({ children }: LayoutProps) {
queryFn: promoApi.getActiveDiscount, queryFn: promoApi.getActiveDiscount,
enabled: isAuthenticated, enabled: isAuthenticated,
staleTime: 30000, staleTime: 30000,
}) });
// Check if promo is active (to hide language switcher on mobile) // Check if promo is active (to hide language switcher on mobile)
const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent;
const navItems = useMemo(() => { const navItems = useMemo(() => {
const items = [ const items = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon }, { path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon }, { path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon }, { path: '/balance', label: t('nav.balance'), icon: WalletIcon },
] ];
// Only show referral if program is enabled // Only show referral if program is enabled
if (referralTerms?.is_enabled) { if (referralTerms?.is_enabled) {
items.push({ path: '/referral', label: t('nav.referral'), icon: UsersIcon }) items.push({ path: '/referral', label: t('nav.referral'), icon: UsersIcon });
} }
items.push({ path: '/support', label: t('nav.support'), icon: ChatIcon }) items.push({ path: '/support', label: t('nav.support'), icon: ChatIcon });
// Only show contests if there are available contests // Only show contests if there are available contests
if (contestsCount && contestsCount.count > 0) { if (contestsCount && contestsCount.count > 0) {
items.push({ path: '/contests', label: t('nav.contests'), icon: GamepadIcon }) items.push({ path: '/contests', label: t('nav.contests'), icon: GamepadIcon });
} }
// Only show polls if there are available polls // Only show polls if there are available polls
if (pollsCount && pollsCount.count > 0) { if (pollsCount && pollsCount.count > 0) {
items.push({ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon }) items.push({ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon });
} }
items.push({ path: '/info', label: t('nav.info'), icon: InfoIcon }) items.push({ path: '/info', label: t('nav.info'), icon: InfoIcon });
return items return items;
}, [t, contestsCount, pollsCount, referralTerms]) }, [t, contestsCount, pollsCount, referralTerms]);
// Separate navItems for desktop that includes wheel (if enabled) // Separate navItems for desktop that includes wheel (if enabled)
const desktopNavItems = useMemo(() => { const desktopNavItems = useMemo(() => {
const items = [...navItems] const items = [...navItems];
// Add wheel before info if enabled // Add wheel before info if enabled
if (wheelConfig?.is_enabled) { if (wheelConfig?.is_enabled) {
const infoIndex = items.findIndex(item => item.path === '/info') const infoIndex = items.findIndex((item) => item.path === '/info');
if (infoIndex !== -1) { if (infoIndex !== -1) {
items.splice(infoIndex, 0, { path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }) items.splice(infoIndex, 0, { path: '/wheel', label: t('nav.wheel'), icon: WheelIcon });
} else { } else {
items.push({ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }) items.push({ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon });
} }
} }
return items return items;
}, [navItems, wheelConfig, t]) }, [navItems, wheelConfig, t]);
const adminNavItems = [ const adminNavItems = [{ path: '/admin', label: t('admin.nav.title'), icon: CogIcon }];
{ path: '/admin', label: t('admin.nav.title'), icon: CogIcon },
]
const isActive = (path: string) => location.pathname === path const isActive = (path: string) => location.pathname === path;
const isAdminActive = () => location.pathname.startsWith('/admin') const isAdminActive = () => location.pathname.startsWith('/admin');
return ( return (
<div className="min-h-screen flex flex-col"> <div className="flex min-h-screen flex-col">
{/* Animated Background */} {/* Animated Background */}
<AnimatedBackground /> <AnimatedBackground />
{/* Pull to refresh indicator */} {/* Pull to refresh indicator */}
{(isPulling || isRefreshing) && ( {(isPulling || isRefreshing) && (
<div <div
className="fixed left-1/2 -translate-x-1/2 z-[100] flex items-center justify-center transition-all duration-200" className="fixed left-1/2 z-[100] flex -translate-x-1/2 items-center justify-center transition-all duration-200"
style={{ style={{
top: `calc(${Math.max(pullDistance, isRefreshing ? 40 : 0)}px + env(safe-area-inset-top, 0px) + 0.5rem)`, top: `calc(${Math.max(pullDistance, isRefreshing ? 40 : 0)}px + env(safe-area-inset-top, 0px) + 0.5rem)`,
opacity: isRefreshing ? 1 : progress, opacity: isRefreshing ? 1 : progress,
}} }}
> >
<div className={`w-10 h-10 rounded-full bg-dark-800 border border-dark-700 shadow-lg flex items-center justify-center ${isRefreshing ? 'animate-pulse' : ''}`}> <div
className={`flex h-10 w-10 items-center justify-center rounded-full border border-dark-700 bg-dark-800 shadow-lg ${isRefreshing ? 'animate-pulse' : ''}`}
>
<svg <svg
className={`w-5 h-5 text-accent-400 transition-transform duration-200 ${isRefreshing ? 'animate-spin' : ''}`} className={`h-5 w-5 text-accent-400 transition-transform duration-200 ${isRefreshing ? 'animate-spin' : ''}`}
style={{ transform: isRefreshing ? undefined : `rotate(${progress * 360}deg)` }} style={{ transform: isRefreshing ? undefined : `rotate(${progress * 360}deg)` }}
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
strokeWidth={2} strokeWidth={2}
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg> </svg>
</div> </div>
</div> </div>
@@ -398,19 +474,30 @@ export default function Layout({ children }: LayoutProps) {
{/* Header */} {/* Header */}
<header <header
className="fixed top-0 left-0 right-0 z-50 glass shadow-lg shadow-black/10" className="glass fixed left-0 right-0 top-0 z-50 shadow-lg shadow-black/10"
style={{ style={{
// In fullscreen mode, add padding for safe area + Telegram native controls (close/menu buttons in corners) // In fullscreen mode, add padding for safe area + Telegram native controls (close/menu buttons in corners)
paddingTop: isFullscreen ? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` : undefined, paddingTop: isFullscreen
? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px`
: undefined,
}} }}
> >
<div className="w-full mx-auto px-4 sm:px-6" onClick={() => mobileMenuOpen && setMobileMenuOpen(false)}> <div
<div className="flex justify-between items-center h-16 lg:h-20"> className="mx-auto w-full px-4 sm:px-6"
onClick={() => mobileMenuOpen && setMobileMenuOpen(false)}
>
<div className="flex h-16 items-center justify-between lg:h-20">
{/* Logo */} {/* Logo */}
<Link to="/" onClick={() => setMobileMenuOpen(false)} className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}> <Link
<div className="w-10 h-10 sm:w-11 sm:h-11 lg:w-12 lg:h-12 rounded-xl bg-dark-800/80 dark:bg-dark-800/80 border border-dark-700/50 flex items-center justify-center overflow-hidden shadow-md flex-shrink-0 relative"> to="/"
onClick={() => setMobileMenuOpen(false)}
className={`flex flex-shrink-0 items-center gap-2.5 ${!appName ? 'lg:mr-4' : ''}`}
>
<div className="relative flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-dark-700/50 bg-dark-800/80 shadow-md dark:bg-dark-800/80 sm:h-11 sm:w-11 lg:h-12 lg:w-12">
{/* Always show letter as fallback */} {/* Always show letter as fallback */}
<span className={`text-accent-400 font-bold text-lg sm:text-xl absolute transition-opacity duration-200 ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}> <span
className={`absolute text-lg font-bold text-accent-400 transition-opacity duration-200 sm:text-xl ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}
>
{logoLetter} {logoLetter}
</span> </span>
{/* Logo image with smooth fade-in */} {/* Logo image with smooth fade-in */}
@@ -418,28 +505,28 @@ export default function Layout({ children }: LayoutProps) {
<img <img
src={logoUrl} src={logoUrl}
alt={appName || 'Logo'} alt={appName || 'Logo'}
className={`w-full h-full object-contain absolute transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`} className={`absolute h-full w-full object-contain transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setLogoLoaded(true)} onLoad={() => setLogoLoaded(true)}
/> />
)} )}
</div> </div>
{appName && ( {appName && (
<span className="text-base lg:text-lg font-semibold text-dark-100 whitespace-nowrap"> <span className="whitespace-nowrap text-base font-semibold text-dark-100 lg:text-lg">
{appName} {appName}
</span> </span>
)} )}
</Link> </Link>
{/* Desktop Navigation */} {/* Desktop Navigation */}
<nav className="hidden lg:flex items-center gap-1"> <nav className="hidden items-center gap-1 lg:flex">
{desktopNavItems.map((item) => ( {desktopNavItems.map((item) => (
<Link <Link
key={item.path} key={item.path}
to={item.path} to={item.path}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${ className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 ${
isActive(item.path) isActive(item.path)
? 'text-accent-400 bg-accent-500/10' ? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:text-dark-100 hover:bg-dark-800/50' : 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-100'
}`} }`}
> >
<item.icon /> <item.icon />
@@ -448,15 +535,15 @@ export default function Layout({ children }: LayoutProps) {
))} ))}
{isAdmin && ( {isAdmin && (
<> <>
<div className="w-px h-6 bg-dark-700 mx-2" /> <div className="mx-2 h-6 w-px bg-dark-700" />
{adminNavItems.map((item) => ( {adminNavItems.map((item) => (
<Link <Link
key={item.path} key={item.path}
to={item.path} to={item.path}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${ className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 ${
isAdminActive() isAdminActive()
? 'text-warning-400 bg-warning-500/10' ? 'bg-warning-500/10 text-warning-400'
: 'text-warning-500/70 hover:text-warning-400 hover:bg-warning-500/10' : 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400'
}`} }`}
> >
<item.icon /> <item.icon />
@@ -473,21 +560,26 @@ export default function Layout({ children }: LayoutProps) {
{canToggle && ( {canToggle && (
<button <button
onClick={() => { onClick={() => {
toggleTheme() toggleTheme();
setMobileMenuOpen(false) setMobileMenuOpen(false);
}} }}
className="relative p-2 rounded-xl transition-all duration-200 className="relative rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-champagne-500 transition-all duration-200 hover:bg-dark-700 hover:text-champagne-800 dark:text-dark-400 dark:hover:text-accent-400"
bg-dark-800/50 hover:bg-dark-700 border border-dark-700/50
dark:text-dark-400 dark:hover:text-accent-400
text-champagne-500 hover:text-champagne-800"
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'} title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
aria-label={isDark ? t('theme.light') || 'Switch to light mode' : t('theme.dark') || 'Switch to dark mode'} aria-label={
isDark
? t('theme.light') || 'Switch to light mode'
: t('theme.dark') || 'Switch to dark mode'
}
>
<div className="relative h-5 w-5">
<div
className={`absolute inset-0 transition-all duration-300 ${isDark ? 'rotate-0 opacity-100' : 'rotate-90 opacity-0'}`}
> >
<div className="relative w-5 h-5">
<div className={`absolute inset-0 transition-all duration-300 ${isDark ? 'opacity-100 rotate-0' : 'opacity-0 rotate-90'}`}>
<MoonIcon /> <MoonIcon />
</div> </div>
<div className={`absolute inset-0 transition-all duration-300 ${isDark ? 'opacity-0 -rotate-90' : 'opacity-100 rotate-0'}`}> <div
className={`absolute inset-0 transition-all duration-300 ${isDark ? '-rotate-90 opacity-0' : 'rotate-0 opacity-100'}`}
>
<SunIcon /> <SunIcon />
</div> </div>
</div> </div>
@@ -501,17 +593,20 @@ export default function Layout({ children }: LayoutProps) {
<TicketNotificationBell isAdmin={isAdminActive()} /> <TicketNotificationBell isAdmin={isAdminActive()} />
</div> </div>
{/* Hide language switcher on mobile when promo is active */} {/* Hide language switcher on mobile when promo is active */}
<div className={isPromoActive ? 'hidden sm:block' : ''} onClick={() => setMobileMenuOpen(false)}> <div
className={isPromoActive ? 'hidden sm:block' : ''}
onClick={() => setMobileMenuOpen(false)}
>
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
{/* Profile - Desktop */} {/* Profile - Desktop */}
<div className="hidden sm:flex items-center gap-3"> <div className="hidden items-center gap-3 sm:flex">
<Link <Link
to="/profile" to="/profile"
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-dark-800/50 transition-colors" className="flex items-center gap-2 rounded-lg px-3 py-1.5 transition-colors hover:bg-dark-800/50"
> >
<div className="w-8 h-8 rounded-full bg-dark-700 flex items-center justify-center"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-dark-700">
<UserIcon /> <UserIcon />
</div> </div>
<span className="text-sm text-dark-300"> <span className="text-sm text-dark-300">
@@ -531,11 +626,13 @@ export default function Layout({ children }: LayoutProps) {
{/* Mobile menu button */} {/* Mobile menu button */}
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
setMobileMenuOpen(!mobileMenuOpen) setMobileMenuOpen(!mobileMenuOpen);
}} }}
className="lg:hidden btn-icon" className="btn-icon lg:hidden"
aria-label={mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'} aria-label={
mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'
}
aria-expanded={mobileMenuOpen} aria-expanded={mobileMenuOpen}
> >
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />} {mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
@@ -543,7 +640,6 @@ export default function Layout({ children }: LayoutProps) {
</div> </div>
</div> </div>
</div> </div>
</header> </header>
{/* Spacer for fixed header - matches header height */} {/* Spacer for fixed header - matches header height */}
@@ -553,17 +649,17 @@ export default function Layout({ children }: LayoutProps) {
style={{ height: `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` }} style={{ height: `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` }}
/> />
) : ( ) : (
<div className="flex-shrink-0 h-16 lg:h-20" /> <div className="h-16 flex-shrink-0 lg:h-20" />
)} )}
{/* Mobile menu - fixed overlay below header */} {/* Mobile menu - fixed overlay below header */}
{mobileMenuOpen && ( {mobileMenuOpen && (
<div <div
className="lg:hidden fixed inset-x-0 bottom-0 z-40 animate-fade-in" className="fixed inset-x-0 bottom-0 z-40 animate-fade-in lg:hidden"
style={{ style={{
top: isFullscreen top: isFullscreen
? `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` ? `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px`
: '64px' : '64px',
}} }}
> >
{/* Backdrop */} {/* Backdrop */}
@@ -573,23 +669,28 @@ export default function Layout({ children }: LayoutProps) {
/> />
{/* Menu content */} {/* Menu content */}
<div className="mobile-menu-content absolute inset-x-0 top-0 bottom-0 bg-dark-900 border-t border-dark-800/50 overflow-y-auto overscroll-contain pb-[calc(5rem+env(safe-area-inset-bottom,0px))]" style={{ WebkitOverflowScrolling: 'touch' }}> <div
<div className="max-w-6xl mx-auto px-4 py-4"> className="mobile-menu-content absolute inset-x-0 bottom-0 top-0 overflow-y-auto overscroll-contain border-t border-dark-800/50 bg-dark-900 pb-[calc(5rem+env(safe-area-inset-bottom,0px))]"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<div className="mx-auto max-w-6xl px-4 py-4">
{/* User info */} {/* User info */}
<div className="flex items-center justify-between pb-4 mb-4 border-b border-dark-800/50"> <div className="mb-4 flex items-center justify-between border-b border-dark-800/50 pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{userPhotoUrl ? ( {userPhotoUrl ? (
<img <img
src={userPhotoUrl} src={userPhotoUrl}
alt="Avatar" alt="Avatar"
className="w-10 h-10 rounded-full object-cover" className="h-10 w-10 rounded-full object-cover"
onError={(e) => { onError={(e) => {
e.currentTarget.style.display = 'none' e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden') e.currentTarget.nextElementSibling?.classList.remove('hidden');
}} }}
/> />
) : null} ) : null}
<div className={`w-10 h-10 rounded-full bg-dark-700 flex items-center justify-center ${userPhotoUrl ? 'hidden' : ''}`}> <div
className={`flex h-10 w-10 items-center justify-center rounded-full bg-dark-700 ${userPhotoUrl ? 'hidden' : ''}`}
>
<UserIcon /> <UserIcon />
</div> </div>
<div> <div>
@@ -622,7 +723,7 @@ export default function Layout({ children }: LayoutProps) {
{isAdmin && ( {isAdmin && (
<> <>
<div className="divider my-3" /> <div className="divider my-3" />
<div className="px-4 py-1 text-xs font-medium text-dark-500 uppercase tracking-wider"> <div className="px-4 py-1 text-xs font-medium uppercase tracking-wider text-dark-500">
{t('admin.nav.title')} {t('admin.nav.title')}
</div> </div>
{adminNavItems.map((item) => ( {adminNavItems.map((item) => (
@@ -630,7 +731,7 @@ export default function Layout({ children }: LayoutProps) {
key={item.path} key={item.path}
to={item.path} to={item.path}
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
className={`nav-item ${isAdminActive() ? 'text-warning-400 bg-warning-500/10' : 'text-warning-500/70'}`} className={`nav-item ${isAdminActive() ? 'bg-warning-500/10 text-warning-400' : 'text-warning-500/70'}`}
> >
<item.icon /> <item.icon />
{item.label} {item.label}
@@ -652,8 +753,8 @@ export default function Layout({ children }: LayoutProps) {
<button <button
onClick={() => { onClick={() => {
setMobileMenuOpen(false) setMobileMenuOpen(false);
logout() logout();
}} }}
className="nav-item w-full text-error-400" className="nav-item w-full text-error-400"
> >
@@ -667,19 +768,20 @@ export default function Layout({ children }: LayoutProps) {
)} )}
{/* Main Content */} {/* Main Content */}
<main className="flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 py-6 pb-24 lg:pb-8"> <main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6 pb-24 sm:px-6 lg:pb-8">
<div className="animate-fade-in"> <div className="animate-fade-in">{children}</div>
{children}
</div>
</main> </main>
{/* Mobile Bottom Navigation - only core items, hidden when keyboard is open */} {/* Mobile Bottom Navigation - only core items, hidden when keyboard is open */}
<nav className={`bottom-nav lg:hidden transition-opacity duration-200 ${isKeyboardOpen ? 'opacity-0 pointer-events-none' : 'opacity-100'}`}> <nav
className={`bottom-nav transition-opacity duration-200 lg:hidden ${isKeyboardOpen ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
>
<div className="flex justify-around"> <div className="flex justify-around">
{navItems.filter(item => {navItems
['/', '/subscription', '/balance', '/referral', '/support'].includes(item.path) .filter((item) =>
).map((item) => ( ['/', '/subscription', '/balance', '/referral', '/support'].includes(item.path),
)
.map((item) => (
<Link <Link
key={item.path} key={item.path}
to={item.path} to={item.path}
@@ -687,11 +789,11 @@ export default function Layout({ children }: LayoutProps) {
className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'} className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'}
> >
<item.icon /> <item.icon />
<span className="text-2xs mt-1 whitespace-nowrap">{item.label}</span> <span className="mt-1 whitespace-nowrap text-2xs">{item.label}</span>
</Link> </Link>
))} ))}
</div> </div>
</nav> </nav>
</div> </div>
) );
} }

View File

@@ -1,42 +1,42 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom';
import { forwardRef } from 'react' import { forwardRef } from 'react';
export type BentoSize = 'sm' | 'md' | 'lg' | 'xl' export type BentoSize = 'sm' | 'md' | 'lg' | 'xl';
interface BentoCardBaseProps { interface BentoCardBaseProps {
size?: BentoSize size?: BentoSize;
children: React.ReactNode children: React.ReactNode;
className?: string className?: string;
hover?: boolean hover?: boolean;
glow?: boolean glow?: boolean;
} }
interface BentoCardDivProps extends BentoCardBaseProps { interface BentoCardDivProps extends BentoCardBaseProps {
as?: 'div' as?: 'div';
onClick?: () => void onClick?: () => void;
} }
interface BentoCardLinkProps extends BentoCardBaseProps { interface BentoCardLinkProps extends BentoCardBaseProps {
as: 'link' as: 'link';
to: string to: string;
state?: unknown state?: unknown;
} }
interface BentoCardButtonProps extends BentoCardBaseProps { interface BentoCardButtonProps extends BentoCardBaseProps {
as: 'button' as: 'button';
onClick?: () => void onClick?: () => void;
disabled?: boolean disabled?: boolean;
type?: 'button' | 'submit' type?: 'button' | 'submit';
} }
export type BentoCardProps = BentoCardDivProps | BentoCardLinkProps | BentoCardButtonProps export type BentoCardProps = BentoCardDivProps | BentoCardLinkProps | BentoCardButtonProps;
const sizeClasses: Record<BentoSize, string> = { const sizeClasses: Record<BentoSize, string> = {
sm: '', sm: '',
md: 'col-span-2', md: 'col-span-2',
lg: 'row-span-2', lg: 'row-span-2',
xl: 'col-span-2 row-span-2', xl: 'col-span-2 row-span-2',
} };
const baseClasses = ` const baseClasses = `
bento-card bento-card
@@ -45,7 +45,7 @@ const baseClasses = `
bg-dark-900/70 bg-dark-900/70
border border-dark-700/40 border border-dark-700/40
transition-all duration-300 ease-smooth transition-all duration-300 ease-smooth
` `;
const hoverClasses = ` const hoverClasses = `
cursor-pointer cursor-pointer
@@ -54,21 +54,15 @@ const hoverClasses = `
hover:shadow-lg hover:shadow-lg
hover:scale-[1.01] hover:scale-[1.01]
active:scale-[0.99] active:scale-[0.99]
` `;
const glowClasses = ` const glowClasses = `
hover:shadow-glow hover:shadow-glow
hover:border-accent-500/30 hover:border-accent-500/30
` `;
export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref) => { export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref) => {
const { const { size = 'sm', children, className = '', hover = false, glow = false } = props;
size = 'sm',
children,
className = '',
hover = false,
glow = false,
} = props
const classes = [ const classes = [
baseClasses, baseClasses,
@@ -76,19 +70,21 @@ export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref)
hover && hoverClasses, hover && hoverClasses,
glow && glowClasses, glow && glowClasses,
className, className,
].filter(Boolean).join(' ') ]
.filter(Boolean)
.join(' ');
if (props.as === 'link') { if (props.as === 'link') {
const { to, state } = props as BentoCardLinkProps const { to, state } = props as BentoCardLinkProps;
return ( return (
<Link to={to} state={state} className={classes}> <Link to={to} state={state} className={classes}>
{children} {children}
</Link> </Link>
) );
} }
if (props.as === 'button') { if (props.as === 'button') {
const { onClick, disabled, type = 'button' } = props as BentoCardButtonProps const { onClick, disabled, type = 'button' } = props as BentoCardButtonProps;
return ( return (
<button <button
ref={ref as React.Ref<HTMLButtonElement>} ref={ref as React.Ref<HTMLButtonElement>}
@@ -99,17 +95,17 @@ export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref)
> >
{children} {children}
</button> </button>
) );
} }
const { onClick } = props as BentoCardDivProps const { onClick } = props as BentoCardDivProps;
return ( return (
<div ref={ref} onClick={onClick} className={classes}> <div ref={ref} onClick={onClick} className={classes}>
{children} {children}
</div> </div>
) );
}) });
BentoCard.displayName = 'BentoCard' BentoCard.displayName = 'BentoCard';
export default BentoCard export default BentoCard;

View File

@@ -1,28 +1,20 @@
interface BentoSkeletonProps { interface BentoSkeletonProps {
className?: string className?: string;
count?: number count?: number;
} }
export default function BentoSkeleton({ className = '', count = 1 }: BentoSkeletonProps) { export default function BentoSkeleton({ className = '', count = 1 }: BentoSkeletonProps) {
const baseClasses = `animate-pulse bg-dark-800/50 border border-dark-700/30 rounded-[var(--bento-radius,24px)] min-h-[160px] w-full ${className}` const baseClasses = `animate-pulse bg-dark-800/50 border border-dark-700/30 rounded-[var(--bento-radius,24px)] min-h-[160px] w-full ${className}`;
if (count > 1) { if (count > 1) {
return ( return (
<> <>
{Array.from({ length: count }).map((_, i) => ( {Array.from({ length: count }).map((_, i) => (
<div <div key={i} className={baseClasses} style={{ '--stagger': i } as React.CSSProperties} />
key={i}
className={baseClasses}
style={{ '--stagger': i } as React.CSSProperties}
/>
))} ))}
</> </>
) );
} }
return ( return <div className={baseClasses} />;
<div
className={baseClasses}
/>
)
} }

View File

@@ -1,19 +1,19 @@
import { useEffect, useRef, useState, useMemo, memo } from 'react' import { useEffect, useRef, useState, useMemo, memo } from 'react';
import type { WheelPrize } from '../../api/wheel' import type { WheelPrize } from '../../api/wheel';
interface FortuneWheelProps { interface FortuneWheelProps {
prizes: WheelPrize[] prizes: WheelPrize[];
isSpinning: boolean isSpinning: boolean;
targetRotation: number | null targetRotation: number | null;
onSpinComplete: () => void onSpinComplete: () => void;
} }
// Pre-generate sparkle positions to avoid recalculating on each render // Pre-generate sparkle positions to avoid recalculating on each render
const SPARKLE_POSITIONS = Array.from({ length: 8 }, (_, i) => ({ const SPARKLE_POSITIONS = Array.from({ length: 8 }, (_, i) => ({
top: `${20 + (i * 10) % 60}%`, top: `${20 + ((i * 10) % 60)}%`,
left: `${15 + (i * 13) % 70}%`, left: `${15 + ((i * 13) % 70)}%`,
delay: `${i * 0.15}s`, delay: `${i * 0.15}s`,
})) }));
const FortuneWheel = memo(function FortuneWheel({ const FortuneWheel = memo(function FortuneWheel({
prizes, prizes,
@@ -21,141 +21,150 @@ const FortuneWheel = memo(function FortuneWheel({
targetRotation, targetRotation,
onSpinComplete, onSpinComplete,
}: FortuneWheelProps) { }: FortuneWheelProps) {
const wheelRef = useRef<SVGGElement>(null) const wheelRef = useRef<SVGGElement>(null);
const [currentRotation, setCurrentRotation] = useState(0) const [currentRotation, setCurrentRotation] = useState(0);
const [lightPhase, setLightPhase] = useState(0) const [lightPhase, setLightPhase] = useState(0);
// Animated lights effect - use phase instead of random array (less re-renders) // Animated lights effect - use phase instead of random array (less re-renders)
useEffect(() => { useEffect(() => {
if (isSpinning) { if (isSpinning) {
const interval = setInterval(() => { const interval = setInterval(() => {
setLightPhase(p => (p + 1) % 3) // Just toggle phase 0-1-2 setLightPhase((p) => (p + 1) % 3); // Just toggle phase 0-1-2
}, 250) // Slower interval = better performance }, 250); // Slower interval = better performance
return () => clearInterval(interval) return () => clearInterval(interval);
} else { } else {
setLightPhase(0) setLightPhase(0);
} }
}, [isSpinning]) }, [isSpinning]);
useEffect(() => { useEffect(() => {
if (isSpinning && targetRotation !== null && wheelRef.current) { if (isSpinning && targetRotation !== null && wheelRef.current) {
setCurrentRotation(targetRotation) setCurrentRotation(targetRotation);
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
onSpinComplete() onSpinComplete();
}, 5000) }, 5000);
return () => clearTimeout(timeout) return () => clearTimeout(timeout);
} }
}, [isSpinning, targetRotation, onSpinComplete]) }, [isSpinning, targetRotation, onSpinComplete]);
// Memoize light pattern calculation // Memoize light pattern calculation
const lightPattern = useMemo(() => { const lightPattern = useMemo(() => {
return Array.from({ length: 20 }, (_, i) => { return Array.from({ length: 20 }, (_, i) => {
if (!isSpinning) return i % 2 === 0 if (!isSpinning) return i % 2 === 0;
return (i + lightPhase) % 3 !== 0 return (i + lightPhase) % 3 !== 0;
}) });
}, [isSpinning, lightPhase]) }, [isSpinning, lightPhase]);
if (prizes.length === 0) { if (prizes.length === 0) {
return ( return (
<div className="w-full max-w-md mx-auto aspect-square flex items-center justify-center"> <div className="mx-auto flex aspect-square w-full max-w-md items-center justify-center">
<p className="text-dark-400">No prizes configured</p> <p className="text-dark-400">No prizes configured</p>
</div> </div>
) );
} }
const size = 400 const size = 400;
const center = size / 2 const center = size / 2;
const outerRadius = size / 2 - 20 const outerRadius = size / 2 - 20;
const innerRadius = outerRadius - 15 const innerRadius = outerRadius - 15;
const prizeRadius = innerRadius - 5 const prizeRadius = innerRadius - 5;
const sectorAngle = 360 / prizes.length const sectorAngle = 360 / prizes.length;
const hubRadius = 45 const hubRadius = 45;
const createSectorPath = (index: number) => { const createSectorPath = (index: number) => {
const startAngle = (index * sectorAngle - 90) * (Math.PI / 180) const startAngle = (index * sectorAngle - 90) * (Math.PI / 180);
const endAngle = ((index + 1) * sectorAngle - 90) * (Math.PI / 180) const endAngle = ((index + 1) * sectorAngle - 90) * (Math.PI / 180);
const x1 = center + prizeRadius * Math.cos(startAngle) const x1 = center + prizeRadius * Math.cos(startAngle);
const y1 = center + prizeRadius * Math.sin(startAngle) const y1 = center + prizeRadius * Math.sin(startAngle);
const x2 = center + prizeRadius * Math.cos(endAngle) const x2 = center + prizeRadius * Math.cos(endAngle);
const y2 = center + prizeRadius * Math.sin(endAngle) const y2 = center + prizeRadius * Math.sin(endAngle);
const x1Inner = center + hubRadius * Math.cos(startAngle) const x1Inner = center + hubRadius * Math.cos(startAngle);
const y1Inner = center + hubRadius * Math.sin(startAngle) const y1Inner = center + hubRadius * Math.sin(startAngle);
const x2Inner = center + hubRadius * Math.cos(endAngle) const x2Inner = center + hubRadius * Math.cos(endAngle);
const y2Inner = center + hubRadius * Math.sin(endAngle) const y2Inner = center + hubRadius * Math.sin(endAngle);
const largeArc = sectorAngle > 180 ? 1 : 0 const largeArc = sectorAngle > 180 ? 1 : 0;
return `M ${x1Inner} ${y1Inner} return `M ${x1Inner} ${y1Inner}
L ${x1} ${y1} L ${x1} ${y1}
A ${prizeRadius} ${prizeRadius} 0 ${largeArc} 1 ${x2} ${y2} A ${prizeRadius} ${prizeRadius} 0 ${largeArc} 1 ${x2} ${y2}
L ${x2Inner} ${y2Inner} L ${x2Inner} ${y2Inner}
A ${hubRadius} ${hubRadius} 0 ${largeArc} 0 ${x1Inner} ${y1Inner} Z` A ${hubRadius} ${hubRadius} 0 ${largeArc} 0 ${x1Inner} ${y1Inner} Z`;
} };
// Position for emoji - closer to outer edge // Position for emoji - closer to outer edge
const getEmojiPosition = (index: number) => { const getEmojiPosition = (index: number) => {
const angle = ((index * sectorAngle + sectorAngle / 2) - 90) * (Math.PI / 180) const angle = (index * sectorAngle + sectorAngle / 2 - 90) * (Math.PI / 180);
const emojiRadius = prizeRadius * 0.75 const emojiRadius = prizeRadius * 0.75;
return { return {
x: center + emojiRadius * Math.cos(angle), x: center + emojiRadius * Math.cos(angle),
y: center + emojiRadius * Math.sin(angle), y: center + emojiRadius * Math.sin(angle),
rotation: index * sectorAngle + sectorAngle / 2, rotation: index * sectorAngle + sectorAngle / 2,
} };
} };
// Position for text - between hub and emoji // Position for text - between hub and emoji
const getTextPosition = (index: number) => { const getTextPosition = (index: number) => {
const angle = ((index * sectorAngle + sectorAngle / 2) - 90) * (Math.PI / 180) const angle = (index * sectorAngle + sectorAngle / 2 - 90) * (Math.PI / 180);
const textRadius = prizeRadius * 0.45 const textRadius = prizeRadius * 0.45;
return { return {
x: center + textRadius * Math.cos(angle), x: center + textRadius * Math.cos(angle),
y: center + textRadius * Math.sin(angle), y: center + textRadius * Math.sin(angle),
rotation: index * sectorAngle + sectorAngle / 2, rotation: index * sectorAngle + sectorAngle / 2,
} };
} };
// Alternate colors for sectors // Alternate colors for sectors
const getSectorColors = (index: number, baseColor?: string) => { const getSectorColors = (index: number, baseColor?: string) => {
if (baseColor) return baseColor if (baseColor) return baseColor;
const colors = [ const colors = [
'#8B5CF6', '#EC4899', '#3B82F6', '#10B981', '#8B5CF6',
'#F59E0B', '#EF4444', '#6366F1', '#14B8A6' '#EC4899',
] '#3B82F6',
return colors[index % colors.length] '#10B981',
} '#F59E0B',
'#EF4444',
'#6366F1',
'#14B8A6',
];
return colors[index % colors.length];
};
// Truncate text intelligently // Truncate text intelligently
const truncateText = (text: string, maxLen: number) => { const truncateText = (text: string, maxLen: number) => {
if (text.length <= maxLen) return text if (text.length <= maxLen) return text;
return text.substring(0, maxLen - 1) + '..' return text.substring(0, maxLen - 1) + '..';
} };
// Calculate max text length based on number of sectors // Calculate max text length based on number of sectors
const maxTextLength = prizes.length <= 4 ? 12 : prizes.length <= 6 ? 10 : 8 const maxTextLength = prizes.length <= 4 ? 12 : prizes.length <= 6 ? 10 : 8;
return ( return (
<div className="relative w-full max-w-[380px] mx-auto select-none"> <div className="relative mx-auto w-full max-w-[380px] select-none">
{/* Outer glow effect */} {/* Outer glow effect */}
<div <div
className={`absolute inset-[-30px] rounded-full transition-all duration-500 ${ className={`absolute inset-[-30px] rounded-full transition-all duration-500 ${
isSpinning ? 'opacity-100 scale-105' : 'opacity-60' isSpinning ? 'scale-105 opacity-100' : 'opacity-60'
}`} }`}
style={{ style={{
background: 'radial-gradient(circle, rgba(139, 92, 246, 0.4) 0%, rgba(236, 72, 153, 0.2) 40%, transparent 70%)', background:
'radial-gradient(circle, rgba(139, 92, 246, 0.4) 0%, rgba(236, 72, 153, 0.2) 40%, transparent 70%)',
filter: 'blur(25px)', filter: 'blur(25px)',
}} }}
/> />
{/* Pointer */} {/* Pointer */}
<div className="absolute top-[-12px] left-1/2 -translate-x-1/2 z-20"> <div className="absolute left-1/2 top-[-12px] z-20 -translate-x-1/2">
<div className="relative"> <div className="relative">
<div <div
className={`absolute inset-[-10px] blur-lg transition-opacity ${isSpinning ? 'opacity-100' : 'opacity-70'}`} className={`absolute inset-[-10px] blur-lg transition-opacity ${isSpinning ? 'opacity-100' : 'opacity-70'}`}
style={{ background: 'radial-gradient(circle, rgba(251, 191, 36, 0.9) 0%, transparent 60%)' }} style={{
background: 'radial-gradient(circle, rgba(251, 191, 36, 0.9) 0%, transparent 60%)',
}}
/> />
<svg width="44" height="56" viewBox="0 0 44 56" className="relative drop-shadow-2xl"> <svg width="44" height="56" viewBox="0 0 44 56" className="relative drop-shadow-2xl">
<defs> <defs>
@@ -166,7 +175,13 @@ const FortuneWheel = memo(function FortuneWheel({
<stop offset="100%" stopColor="#D97706" /> <stop offset="100%" stopColor="#D97706" />
</linearGradient> </linearGradient>
<filter id="pointerGlow"> <filter id="pointerGlow">
<feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="#F59E0B" floodOpacity="0.6"/> <feDropShadow
dx="0"
dy="2"
stdDeviation="3"
floodColor="#F59E0B"
floodOpacity="0.6"
/>
</filter> </filter>
</defs> </defs>
<polygon <polygon
@@ -174,35 +189,35 @@ const FortuneWheel = memo(function FortuneWheel({
fill="url(#pointerGold)" fill="url(#pointerGold)"
filter="url(#pointerGlow)" filter="url(#pointerGlow)"
/> />
<polygon <polygon points="22,50 6,16 22,4" fill="rgba(255,255,255,0.3)" />
points="22,50 6,16 22,4" <circle cx="22" cy="24" r="8" fill="#FEF3C7" />
fill="rgba(255,255,255,0.3)" <circle cx="22" cy="24" r="5" fill="#FBBF24" />
/> <circle cx="19" cy="21" r="2" fill="white" opacity="0.8" />
<circle cx="22" cy="24" r="8" fill="#FEF3C7"/>
<circle cx="22" cy="24" r="5" fill="#FBBF24"/>
<circle cx="19" cy="21" r="2" fill="white" opacity="0.8"/>
</svg> </svg>
</div> </div>
</div> </div>
{/* Main Wheel */} {/* Main Wheel */}
<div className="relative aspect-square"> <div className="relative aspect-square">
<svg viewBox={`0 0 ${size} ${size}`} className="w-full h-full"> <svg viewBox={`0 0 ${size} ${size}`} className="h-full w-full">
<defs> <defs>
{/* Sector gradients */} {/* Sector gradients */}
{prizes.map((prize, index) => { {prizes.map((prize, index) => {
const color = getSectorColors(index, prize.color) const color = getSectorColors(index, prize.color);
return ( return (
<linearGradient <linearGradient
key={`grad-${index}`} key={`grad-${index}`}
id={`sectorGrad-${index}`} id={`sectorGrad-${index}`}
x1="0%" y1="0%" x2="100%" y2="100%" x1="0%"
y1="0%"
x2="100%"
y2="100%"
> >
<stop offset="0%" stopColor={color} stopOpacity="1" /> <stop offset="0%" stopColor={color} stopOpacity="1" />
<stop offset="50%" stopColor={color} stopOpacity="0.85" /> <stop offset="50%" stopColor={color} stopOpacity="0.85" />
<stop offset="100%" stopColor={color} stopOpacity="0.7" /> <stop offset="100%" stopColor={color} stopOpacity="0.7" />
</linearGradient> </linearGradient>
) );
})} })}
{/* Outer ring gradient */} {/* Outer ring gradient */}
@@ -223,7 +238,7 @@ const FortuneWheel = memo(function FortuneWheel({
{/* Text shadow filter */} {/* Text shadow filter */}
<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>
</defs> </defs>
@@ -252,10 +267,10 @@ const FortuneWheel = memo(function FortuneWheel({
{/* LED lights on outer ring */} {/* LED lights on outer ring */}
{Array.from({ length: 20 }).map((_, i) => { {Array.from({ length: 20 }).map((_, i) => {
const angle = (i * 18 - 90) * (Math.PI / 180) const angle = (i * 18 - 90) * (Math.PI / 180);
const dotX = center + outerRadius * Math.cos(angle) const dotX = center + outerRadius * Math.cos(angle);
const dotY = center + outerRadius * Math.sin(angle) const dotY = center + outerRadius * Math.sin(angle);
const isLit = lightPattern[i] ?? (i % 2 === 0) const isLit = lightPattern[i] ?? i % 2 === 0;
return ( return (
<g key={`led-${i}`}> <g key={`led-${i}`}>
{isLit && ( {isLit && (
@@ -277,7 +292,7 @@ const FortuneWheel = memo(function FortuneWheel({
strokeWidth="1" strokeWidth="1"
/> />
</g> </g>
) );
})} })}
{/* Rotating wheel group */} {/* Rotating wheel group */}
@@ -286,9 +301,7 @@ const FortuneWheel = memo(function FortuneWheel({
style={{ style={{
transformOrigin: `${center}px ${center}px`, transformOrigin: `${center}px ${center}px`,
transform: `rotate(${currentRotation}deg)`, transform: `rotate(${currentRotation}deg)`,
transition: isSpinning transition: isSpinning ? 'transform 5s cubic-bezier(0.15, 0.6, 0.1, 1)' : 'none',
? 'transform 5s cubic-bezier(0.15, 0.6, 0.1, 1)'
: 'none',
}} }}
> >
{/* Sectors */} {/* Sectors */}
@@ -304,11 +317,11 @@ const FortuneWheel = memo(function FortuneWheel({
{/* Sector dividers */} {/* Sector dividers */}
{prizes.map((_, index) => { {prizes.map((_, index) => {
const angle = (index * sectorAngle - 90) * (Math.PI / 180) const angle = (index * sectorAngle - 90) * (Math.PI / 180);
const x1 = center + hubRadius * Math.cos(angle) const x1 = center + hubRadius * Math.cos(angle);
const y1 = center + hubRadius * Math.sin(angle) const y1 = center + hubRadius * Math.sin(angle);
const x2 = center + prizeRadius * Math.cos(angle) const x2 = center + prizeRadius * Math.cos(angle);
const y2 = center + prizeRadius * Math.sin(angle) const y2 = center + prizeRadius * Math.sin(angle);
return ( return (
<line <line
key={`divider-${index}`} key={`divider-${index}`}
@@ -319,12 +332,12 @@ const FortuneWheel = memo(function FortuneWheel({
stroke="rgba(255,255,255,0.25)" stroke="rgba(255,255,255,0.25)"
strokeWidth="2" strokeWidth="2"
/> />
) );
})} })}
{/* Prize content - Emoji */} {/* Prize content - Emoji */}
{prizes.map((prize, index) => { {prizes.map((prize, index) => {
const pos = getEmojiPosition(index) const pos = getEmojiPosition(index);
return ( return (
<text <text
key={`emoji-${prize.id}`} key={`emoji-${prize.id}`}
@@ -332,19 +345,19 @@ const FortuneWheel = memo(function FortuneWheel({
y={pos.y} y={pos.y}
textAnchor="middle" textAnchor="middle"
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))' }} style={{ filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.5))' }}
> >
{prize.emoji} {prize.emoji}
</text> </text>
) );
})} })}
{/* Prize content - Text */} {/* Prize content - Text */}
{prizes.map((prize, index) => { {prizes.map((prize, index) => {
const pos = getTextPosition(index) const pos = getTextPosition(index);
const displayText = truncateText(prize.display_name, maxTextLength) const displayText = truncateText(prize.display_name, maxTextLength);
return ( return (
<text <text
key={`text-${prize.id}`} key={`text-${prize.id}`}
@@ -352,7 +365,7 @@ const FortuneWheel = memo(function FortuneWheel({
y={pos.y} y={pos.y}
textAnchor="middle" textAnchor="middle"
dominantBaseline="middle" dominantBaseline="middle"
fontSize={prizes.length <= 4 ? "13" : prizes.length <= 6 ? "11" : "9"} fontSize={prizes.length <= 4 ? '13' : prizes.length <= 6 ? '11' : '9'}
fontWeight="700" fontWeight="700"
fill="#FFFFFF" fill="#FFFFFF"
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`} transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
@@ -361,7 +374,7 @@ const FortuneWheel = memo(function FortuneWheel({
> >
{displayText} {displayText}
</text> </text>
) );
})} })}
</g> </g>
@@ -386,13 +399,7 @@ const FortuneWheel = memo(function FortuneWheel({
/> />
{/* Hub shine */} {/* Hub shine */}
<ellipse <ellipse cx={center - 10} cy={center - 12} rx={15} ry={10} fill="rgba(255,255,255,0.2)" />
cx={center - 10}
cy={center - 12}
rx={15}
ry={10}
fill="rgba(255,255,255,0.2)"
/>
{/* Center button */} {/* Center button */}
<circle <circle
@@ -422,7 +429,7 @@ const FortuneWheel = memo(function FortuneWheel({
{/* Spinning overlay glow */} {/* Spinning overlay glow */}
{isSpinning && ( {isSpinning && (
<div <div
className="absolute inset-0 rounded-full pointer-events-none" className="pointer-events-none absolute inset-0 rounded-full"
style={{ style={{
background: 'radial-gradient(circle, rgba(168, 85, 247, 0.25) 0%, transparent 50%)', background: 'radial-gradient(circle, rgba(168, 85, 247, 0.25) 0%, transparent 50%)',
animation: 'pulse 0.5s ease-in-out infinite', animation: 'pulse 0.5s ease-in-out infinite',
@@ -433,11 +440,11 @@ const FortuneWheel = memo(function FortuneWheel({
{/* Sparkle effects when spinning - optimized with pre-calculated positions */} {/* Sparkle effects when spinning - optimized with pre-calculated positions */}
{isSpinning && ( {isSpinning && (
<div className="absolute inset-0 pointer-events-none overflow-hidden"> <div className="pointer-events-none absolute inset-0 overflow-hidden">
{SPARKLE_POSITIONS.map((pos, i) => ( {SPARKLE_POSITIONS.map((pos, i) => (
<div <div
key={`sparkle-${i}`} key={`sparkle-${i}`}
className="absolute w-2 h-2 bg-yellow-300 rounded-full animate-ping" className="absolute h-2 w-2 animate-ping rounded-full bg-yellow-300"
style={{ style={{
top: pos.top, top: pos.top,
left: pos.left, left: pos.left,
@@ -450,7 +457,7 @@ const FortuneWheel = memo(function FortuneWheel({
</div> </div>
)} )}
</div> </div>
) );
}) });
export default FortuneWheel export default FortuneWheel;

View File

@@ -1,17 +1,17 @@
import { ThemeColors } from '../types/theme' import { ThemeColors } from '../types/theme';
export interface ColorPreset { export interface ColorPreset {
id: string id: string;
name: string name: string;
nameRu: string nameRu: string;
description: string description: string;
descriptionRu: string descriptionRu: string;
colors: ThemeColors colors: ThemeColors;
preview: { preview: {
background: string background: string;
accent: string accent: string;
text: string text: string;
} };
} }
export const COLOR_PRESETS: ColorPreset[] = [ export const COLOR_PRESETS: ColorPreset[] = [
@@ -275,8 +275,8 @@ export const COLOR_PRESETS: ColorPreset[] = [
text: '#f1f5f9', text: '#f1f5f9',
}, },
}, },
] ];
export function getPresetById(id: string): ColorPreset | undefined { export function getPresetById(id: string): ColorPreset | undefined {
return COLOR_PRESETS.find((preset) => preset.id === id) return COLOR_PRESETS.find((preset) => preset.id === id);
} }

View File

@@ -1,21 +1,21 @@
import { useEffect } from 'react' import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import { brandingApi } from '../api/branding' import { brandingApi } from '../api/branding';
const YM_SCRIPT_ID = 'ym-counter-script' const YM_SCRIPT_ID = 'ym-counter-script';
const GTAG_LOADER_ID = 'gtag-loader-script' const GTAG_LOADER_ID = 'gtag-loader-script';
const GTAG_INIT_ID = 'gtag-init-script' const GTAG_INIT_ID = 'gtag-init-script';
function removeElement(id: string) { function removeElement(id: string) {
document.getElementById(id)?.remove() document.getElementById(id)?.remove();
} }
function injectYandexMetrika(counterId: string) { function injectYandexMetrika(counterId: string) {
if (document.getElementById(YM_SCRIPT_ID)) return if (document.getElementById(YM_SCRIPT_ID)) return;
const script = document.createElement('script') const script = document.createElement('script');
script.id = YM_SCRIPT_ID script.id = YM_SCRIPT_ID;
script.type = 'text/javascript' script.type = 'text/javascript';
script.textContent = ` script.textContent = `
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)}; (function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date(); m[i].l=1*new Date();
@@ -26,30 +26,30 @@ function injectYandexMetrika(counterId: string) {
trackLinks:true, trackLinks:true,
accurateTrackBounce:true accurateTrackBounce:true
}); });
` `;
document.head.appendChild(script) document.head.appendChild(script);
} }
function injectGoogleAds(conversionId: string) { function injectGoogleAds(conversionId: string) {
if (document.getElementById(GTAG_LOADER_ID)) return if (document.getElementById(GTAG_LOADER_ID)) return;
// External gtag.js loader // External gtag.js loader
const loader = document.createElement('script') const loader = document.createElement('script');
loader.id = GTAG_LOADER_ID loader.id = GTAG_LOADER_ID;
loader.async = true loader.async = true;
loader.src = `https://www.googletagmanager.com/gtag/js?id=${conversionId}` loader.src = `https://www.googletagmanager.com/gtag/js?id=${conversionId}`;
document.head.appendChild(loader) document.head.appendChild(loader);
// Init script // Init script
const init = document.createElement('script') const init = document.createElement('script');
init.id = GTAG_INIT_ID init.id = GTAG_INIT_ID;
init.textContent = ` init.textContent = `
window.dataLayer = window.dataLayer || []; window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);} function gtag(){dataLayer.push(arguments);}
gtag('js', new Date()); gtag('js', new Date());
gtag('config', '${conversionId}'); gtag('config', '${conversionId}');
` `;
document.head.appendChild(init) document.head.appendChild(init);
} }
/** /**
@@ -61,24 +61,24 @@ export function useAnalyticsCounters() {
queryKey: ['analytics-counters'], queryKey: ['analytics-counters'],
queryFn: brandingApi.getAnalyticsCounters, queryFn: brandingApi.getAnalyticsCounters,
staleTime: 5 * 60 * 1000, // 5 min staleTime: 5 * 60 * 1000, // 5 min
}) });
useEffect(() => { useEffect(() => {
if (!data) return if (!data) return;
// Yandex Metrika // Yandex Metrika
if (data.yandex_metrika_id) { if (data.yandex_metrika_id) {
injectYandexMetrika(data.yandex_metrika_id) injectYandexMetrika(data.yandex_metrika_id);
} else { } else {
removeElement(YM_SCRIPT_ID) removeElement(YM_SCRIPT_ID);
} }
// Google Ads // Google Ads
if (data.google_ads_id) { if (data.google_ads_id) {
injectGoogleAds(data.google_ads_id) injectGoogleAds(data.google_ads_id);
} else { } else {
removeElement(GTAG_LOADER_ID) removeElement(GTAG_LOADER_ID);
removeElement(GTAG_INIT_ID) removeElement(GTAG_INIT_ID);
} }
}, [data]) }, [data]);
} }

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { currencyApi, type ExchangeRates } from '../api/currency' import { currencyApi, type ExchangeRates } from '../api/currency';
// Map language to currency // Map language to currency
const LANGUAGE_CURRENCY_MAP: Record<string, keyof ExchangeRates | 'RUB'> = { const LANGUAGE_CURRENCY_MAP: Record<string, keyof ExchangeRates | 'RUB'> = {
@@ -8,17 +8,17 @@ const LANGUAGE_CURRENCY_MAP: Record<string, keyof ExchangeRates | 'RUB'> = {
en: 'USD', en: 'USD',
zh: 'CNY', zh: 'CNY',
fa: 'IRR', fa: 'IRR',
} };
// Default rates for fallback // Default rates for fallback
const DEFAULT_RATES: ExchangeRates = { const DEFAULT_RATES: ExchangeRates = {
USD: 100, USD: 100,
CNY: 14, CNY: 14,
IRR: 0.0024, IRR: 0.0024,
} };
export function useCurrency() { export function useCurrency() {
const { i18n, t } = useTranslation() const { i18n, t } = useTranslation();
// Fetch exchange rates // Fetch exchange rates
const { data: exchangeRates = DEFAULT_RATES } = useQuery({ const { data: exchangeRates = DEFAULT_RATES } = useQuery({
@@ -27,72 +27,68 @@ export function useCurrency() {
staleTime: 60 * 60 * 1000, // 1 hour staleTime: 60 * 60 * 1000, // 1 hour
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: 2, retry: 2,
}) });
// Get current language and currency // Get current language and currency
const currentLanguage = i18n.language const currentLanguage = i18n.language;
const targetCurrency = LANGUAGE_CURRENCY_MAP[currentLanguage] || 'USD' const targetCurrency = LANGUAGE_CURRENCY_MAP[currentLanguage] || 'USD';
// Check if current language is Russian (no conversion needed) // Check if current language is Russian (no conversion needed)
const isRussian = currentLanguage === 'ru' const isRussian = currentLanguage === 'ru';
// Get currency symbol from translations // Get currency symbol from translations
const currencySymbol = t('common.currency') const currencySymbol = t('common.currency');
// Format amount with currency conversion // Format amount with currency conversion
const formatAmount = (rubAmount: number, decimals: number = 2): string => { const formatAmount = (rubAmount: number, decimals: number = 2): string => {
if (isRussian) { if (isRussian) {
return rubAmount.toFixed(decimals) return rubAmount.toFixed(decimals);
} }
// Convert to target currency // Convert to target currency
const convertedAmount = currencyApi.convertFromRub( const convertedAmount = currencyApi.convertFromRub(
rubAmount, rubAmount,
targetCurrency as keyof ExchangeRates, targetCurrency as keyof ExchangeRates,
exchangeRates exchangeRates,
) );
// For IRR (Iranian Toman), use no decimals as amounts are large // For IRR (Iranian Toman), use no decimals as amounts are large
if (targetCurrency === 'IRR') { if (targetCurrency === 'IRR') {
return Math.round(convertedAmount).toLocaleString('fa-IR') return Math.round(convertedAmount).toLocaleString('fa-IR');
} }
return convertedAmount.toFixed(decimals) return convertedAmount.toFixed(decimals);
} };
// Format amount with currency symbol // Format amount with currency symbol
const formatWithCurrency = (rubAmount: number, decimals: number = 2): string => { const formatWithCurrency = (rubAmount: number, decimals: number = 2): string => {
return `${formatAmount(rubAmount, decimals)} ${currencySymbol}` return `${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
} };
// Format amount with + sign (for earnings/bonuses) // Format amount with + sign (for earnings/bonuses)
const formatPositive = (rubAmount: number, decimals: number = 2): string => { const formatPositive = (rubAmount: number, decimals: number = 2): string => {
return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}` return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
} };
// Get raw converted amount (for calculations) // Get raw converted amount (for calculations)
const convertAmount = (rubAmount: number): number => { const convertAmount = (rubAmount: number): number => {
if (isRussian) { if (isRussian) {
return rubAmount return rubAmount;
} }
return currencyApi.convertFromRub( return currencyApi.convertFromRub(
rubAmount, rubAmount,
targetCurrency as keyof ExchangeRates, targetCurrency as keyof ExchangeRates,
exchangeRates exchangeRates,
) );
} };
// Convert from user's currency back to rubles // Convert from user's currency back to rubles
const convertToRub = (amount: number): number => { const convertToRub = (amount: number): number => {
if (isRussian) { if (isRussian) {
return amount return amount;
}
return currencyApi.convertToRub(
amount,
targetCurrency as keyof ExchangeRates,
exchangeRates
)
} }
return currencyApi.convertToRub(amount, targetCurrency as keyof ExchangeRates, exchangeRates);
};
return { return {
exchangeRates, exchangeRates,
@@ -104,5 +100,5 @@ export function useCurrency() {
formatPositive, formatPositive,
convertAmount, convertAmount,
convertToRub, convertToRub,
} };
} }

View File

@@ -1,49 +1,52 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react';
const STORAGE_KEY = 'admin_favorite_settings' const STORAGE_KEY = 'admin_favorite_settings';
export function useFavoriteSettings() { export function useFavoriteSettings() {
const [favorites, setFavorites] = useState<string[]>(() => { const [favorites, setFavorites] = useState<string[]>(() => {
try { try {
const stored = localStorage.getItem(STORAGE_KEY) const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [] return stored ? JSON.parse(stored) : [];
} catch { } catch {
return [] return [];
} }
}) });
// Sync to localStorage // Sync to localStorage
useEffect(() => { useEffect(() => {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites)) localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites));
} catch { } catch {
// Ignore storage errors // Ignore storage errors
} }
}, [favorites]) }, [favorites]);
const toggleFavorite = useCallback((settingKey: string) => { const toggleFavorite = useCallback((settingKey: string) => {
setFavorites(prev => { setFavorites((prev) => {
if (prev.includes(settingKey)) { if (prev.includes(settingKey)) {
return prev.filter(key => key !== settingKey) return prev.filter((key) => key !== settingKey);
} else { } else {
return [...prev, settingKey] return [...prev, settingKey];
} }
}) });
}, []) }, []);
const isFavorite = useCallback((settingKey: string) => { const isFavorite = useCallback(
return favorites.includes(settingKey) (settingKey: string) => {
}, [favorites]) return favorites.includes(settingKey);
},
[favorites],
);
const clearFavorites = useCallback(() => { const clearFavorites = useCallback(() => {
setFavorites([]) setFavorites([]);
}, []) }, []);
return { return {
favorites, favorites,
toggleFavorite, toggleFavorite,
isFavorite, isFavorite,
clearFavorites, clearFavorites,
count: favorites.length count: favorites.length,
} };
} }

View File

@@ -1,9 +1,9 @@
import { useEffect, useRef, useState, useCallback } from 'react' import { useEffect, useRef, useState, useCallback } from 'react';
interface UsePullToRefreshOptions { interface UsePullToRefreshOptions {
onRefresh?: () => void | Promise<void> onRefresh?: () => void | Promise<void>;
threshold?: number // How far to pull before triggering refresh (px) threshold?: number; // How far to pull before triggering refresh (px)
disabled?: boolean disabled?: boolean;
} }
export function usePullToRefresh({ export function usePullToRefresh({
@@ -11,150 +11,152 @@ export function usePullToRefresh({
threshold = 80, threshold = 80,
disabled = false, disabled = false,
}: UsePullToRefreshOptions = {}) { }: UsePullToRefreshOptions = {}) {
const [isPulling, setIsPulling] = useState(false) const [isPulling, setIsPulling] = useState(false);
const [pullDistance, setPullDistance] = useState(0) const [pullDistance, setPullDistance] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false);
const startY = useRef(0) const startY = useRef(0);
const currentY = useRef(0) const currentY = useRef(0);
const handleRefresh = useCallback(async () => { const handleRefresh = useCallback(async () => {
if (onRefresh) { if (onRefresh) {
setIsRefreshing(true) setIsRefreshing(true);
try { try {
await onRefresh() await onRefresh();
} finally { } finally {
setIsRefreshing(false) setIsRefreshing(false);
} }
} else { } else {
// Default: reload the page // Default: reload the page
window.location.reload() window.location.reload();
} }
}, [onRefresh]) }, [onRefresh]);
useEffect(() => { useEffect(() => {
if (disabled) return if (disabled) return;
// Check if a modal/overlay is currently open // Check if a modal/overlay is currently open
const isModalOpen = (): boolean => { const isModalOpen = (): boolean => {
// Check if body scroll is locked (common pattern for modals) // Check if body scroll is locked (common pattern for modals)
if (document.body.style.overflow === 'hidden') return true if (document.body.style.overflow === 'hidden') return true;
// Check for high z-index fixed elements (modals typically use z-index > 50) // Check for high z-index fixed elements (modals typically use z-index > 50)
const fixedElements = document.querySelectorAll('[style*="position: fixed"], [style*="position:fixed"]') const fixedElements = document.querySelectorAll(
'[style*="position: fixed"], [style*="position:fixed"]',
);
for (const el of fixedElements) { for (const el of fixedElements) {
const style = window.getComputedStyle(el) const style = window.getComputedStyle(el);
const zIndex = parseInt(style.zIndex, 10) const zIndex = parseInt(style.zIndex, 10);
if (zIndex >= 50 && el.clientHeight > window.innerHeight * 0.5) { if (zIndex >= 50 && el.clientHeight > window.innerHeight * 0.5) {
return true return true;
} }
} }
return false return false;
} };
// Check if element or any parent is scrollable and not at top // Check if element or any parent is scrollable and not at top
const isInsideScrollableContainer = (element: HTMLElement | null): boolean => { const isInsideScrollableContainer = (element: HTMLElement | null): boolean => {
while (element && element !== document.body) { while (element && element !== document.body) {
const style = window.getComputedStyle(element) const style = window.getComputedStyle(element);
const overflowY = style.overflowY const overflowY = style.overflowY;
const isScrollable = overflowY === 'auto' || overflowY === 'scroll' const isScrollable = overflowY === 'auto' || overflowY === 'scroll';
if (isScrollable && element.scrollHeight > element.clientHeight) { if (isScrollable && element.scrollHeight > element.clientHeight) {
// Element is scrollable - check if it's at the top // Element is scrollable - check if it's at the top
if (element.scrollTop > 0) { if (element.scrollTop > 0) {
return true // Not at top, don't trigger pull-to-refresh return true; // Not at top, don't trigger pull-to-refresh
} }
} }
element = element.parentElement element = element.parentElement;
}
return false
} }
return false;
};
const handleTouchStart = (e: TouchEvent) => { const handleTouchStart = (e: TouchEvent) => {
// Don't trigger if a modal is open // Don't trigger if a modal is open
if (isModalOpen()) return if (isModalOpen()) return;
// Only trigger if at top of page // Only trigger if at top of page
if (window.scrollY > 5) return if (window.scrollY > 5) return;
// Don't trigger if touch started inside a scrollable container that's not at top // Don't trigger if touch started inside a scrollable container that's not at top
const target = e.target as HTMLElement const target = e.target as HTMLElement;
if (isInsideScrollableContainer(target)) return if (isInsideScrollableContainer(target)) return;
startY.current = e.touches[0].clientY startY.current = e.touches[0].clientY;
currentY.current = e.touches[0].clientY currentY.current = e.touches[0].clientY;
} };
const handleTouchMove = (e: TouchEvent) => { const handleTouchMove = (e: TouchEvent) => {
if (startY.current === 0) return if (startY.current === 0) return;
// Cancel if modal opened during gesture // Cancel if modal opened during gesture
if (isModalOpen()) { if (isModalOpen()) {
startY.current = 0 startY.current = 0;
setPullDistance(0) setPullDistance(0);
setIsPulling(false) setIsPulling(false);
return return;
} }
if (window.scrollY > 5) { if (window.scrollY > 5) {
// User scrolled down, reset // User scrolled down, reset
startY.current = 0 startY.current = 0;
setPullDistance(0) setPullDistance(0);
setIsPulling(false) setIsPulling(false);
return return;
} }
// Also check during move - user might start at top then scroll inside container // Also check during move - user might start at top then scroll inside container
const target = e.target as HTMLElement const target = e.target as HTMLElement;
if (isInsideScrollableContainer(target)) { if (isInsideScrollableContainer(target)) {
startY.current = 0 startY.current = 0;
setPullDistance(0) setPullDistance(0);
setIsPulling(false) setIsPulling(false);
return return;
} }
currentY.current = e.touches[0].clientY currentY.current = e.touches[0].clientY;
const diff = currentY.current - startY.current const diff = currentY.current - startY.current;
// Only track downward pulls // Only track downward pulls
if (diff > 0) { if (diff > 0) {
// Apply resistance - pull distance is less than actual finger movement // Apply resistance - pull distance is less than actual finger movement
const resistance = 0.4 const resistance = 0.4;
const distance = Math.min(diff * resistance, threshold * 1.5) const distance = Math.min(diff * resistance, threshold * 1.5);
setPullDistance(distance) setPullDistance(distance);
setIsPulling(true) setIsPulling(true);
// Prevent default scroll if we're pulling // Prevent default scroll if we're pulling
if (distance > 10) { if (distance > 10) {
e.preventDefault() e.preventDefault();
}
} }
} }
};
const handleTouchEnd = () => { const handleTouchEnd = () => {
if (pullDistance >= threshold && !isRefreshing) { if (pullDistance >= threshold && !isRefreshing) {
handleRefresh() handleRefresh();
} }
startY.current = 0 startY.current = 0;
setPullDistance(0) setPullDistance(0);
setIsPulling(false) setIsPulling(false);
} };
document.addEventListener('touchstart', handleTouchStart, { passive: true }) document.addEventListener('touchstart', handleTouchStart, { passive: true });
document.addEventListener('touchmove', handleTouchMove, { passive: false }) document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd, { passive: true }) document.addEventListener('touchend', handleTouchEnd, { passive: true });
return () => { return () => {
document.removeEventListener('touchstart', handleTouchStart) document.removeEventListener('touchstart', handleTouchStart);
document.removeEventListener('touchmove', handleTouchMove) document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd) document.removeEventListener('touchend', handleTouchEnd);
} };
}, [disabled, threshold, pullDistance, isRefreshing, handleRefresh]) }, [disabled, threshold, pullDistance, isRefreshing, handleRefresh]);
return { return {
isPulling, isPulling,
pullDistance, pullDistance,
isRefreshing, isRefreshing,
progress: Math.min(pullDistance / threshold, 1), progress: Math.min(pullDistance / threshold, 1),
} };
} }

View File

@@ -1,24 +1,24 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react';
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled' const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
// Get cached fullscreen setting // Get cached fullscreen setting
export const getCachedFullscreenEnabled = (): boolean => { export const getCachedFullscreenEnabled = (): boolean => {
try { try {
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true' return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true';
} catch { } catch {
return false return false;
} }
} };
// Set cached fullscreen setting // Set cached fullscreen setting
export const setCachedFullscreenEnabled = (enabled: boolean) => { export const setCachedFullscreenEnabled = (enabled: boolean) => {
try { try {
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled)) localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled));
} catch { } catch {
// localStorage not available // localStorage not available
} }
} };
/** /**
* Hook for Telegram WebApp API integration * Hook for Telegram WebApp API integration
@@ -26,102 +26,112 @@ export const setCachedFullscreenEnabled = (enabled: boolean) => {
*/ */
export function useTelegramWebApp() { export function useTelegramWebApp() {
// Initialize synchronously to avoid flash/flicker on first render // Initialize synchronously to avoid flash/flicker on first render
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined;
const [isFullscreen, setIsFullscreen] = useState(() => webApp?.isFullscreen || false) const [isFullscreen, setIsFullscreen] = useState(() => webApp?.isFullscreen || false);
const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => !!webApp) const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => !!webApp);
const [safeAreaInset, setSafeAreaInset] = useState(() => webApp?.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) const [safeAreaInset, setSafeAreaInset] = useState(
const [contentSafeAreaInset, setContentSafeAreaInset] = useState(() => webApp?.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) () => webApp?.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
);
const [contentSafeAreaInset, setContentSafeAreaInset] = useState(
() => webApp?.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
);
useEffect(() => { useEffect(() => {
if (!webApp) { if (!webApp) {
setIsTelegramWebApp(false) setIsTelegramWebApp(false);
return return;
} }
setIsTelegramWebApp(true) setIsTelegramWebApp(true);
setIsFullscreen(webApp.isFullscreen || false) setIsFullscreen(webApp.isFullscreen || false);
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) setContentSafeAreaInset(
webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
);
// Expand WebApp to full height // Expand WebApp to full height
webApp.expand() webApp.expand();
webApp.ready() webApp.ready();
// Listen for fullscreen changes // Listen for fullscreen changes
const handleFullscreenChanged = () => { const handleFullscreenChanged = () => {
setIsFullscreen(webApp.isFullscreen || false) setIsFullscreen(webApp.isFullscreen || false);
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) setContentSafeAreaInset(
} webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
);
};
// Listen for safe area changes // Listen for safe area changes
const handleSafeAreaChanged = () => { const handleSafeAreaChanged = () => {
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 }) setContentSafeAreaInset(
} webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
);
};
webApp.onEvent('fullscreenChanged', handleFullscreenChanged) webApp.onEvent('fullscreenChanged', handleFullscreenChanged);
webApp.onEvent('safeAreaChanged', handleSafeAreaChanged) webApp.onEvent('safeAreaChanged', handleSafeAreaChanged);
webApp.onEvent('contentSafeAreaChanged', handleSafeAreaChanged) webApp.onEvent('contentSafeAreaChanged', handleSafeAreaChanged);
return () => { return () => {
webApp.offEvent('fullscreenChanged', handleFullscreenChanged) webApp.offEvent('fullscreenChanged', handleFullscreenChanged);
webApp.offEvent('safeAreaChanged', handleSafeAreaChanged) webApp.offEvent('safeAreaChanged', handleSafeAreaChanged);
webApp.offEvent('contentSafeAreaChanged', handleSafeAreaChanged) webApp.offEvent('contentSafeAreaChanged', handleSafeAreaChanged);
} };
}, [webApp]) }, [webApp]);
const requestFullscreen = useCallback(() => { const requestFullscreen = useCallback(() => {
if (webApp?.requestFullscreen) { if (webApp?.requestFullscreen) {
try { try {
webApp.requestFullscreen() webApp.requestFullscreen();
} catch (e) { } catch (e) {
console.warn('Fullscreen not supported:', e) console.warn('Fullscreen not supported:', e);
} }
} }
}, [webApp]) }, [webApp]);
const exitFullscreen = useCallback(() => { const exitFullscreen = useCallback(() => {
if (webApp?.exitFullscreen) { if (webApp?.exitFullscreen) {
try { try {
webApp.exitFullscreen() webApp.exitFullscreen();
} catch (e) { } catch (e) {
console.warn('Exit fullscreen failed:', e) console.warn('Exit fullscreen failed:', e);
} }
} }
}, [webApp]) }, [webApp]);
const toggleFullscreen = useCallback(() => { const toggleFullscreen = useCallback(() => {
if (isFullscreen) { if (isFullscreen) {
exitFullscreen() exitFullscreen();
} else { } else {
requestFullscreen() requestFullscreen();
} }
}, [isFullscreen, requestFullscreen, exitFullscreen]) }, [isFullscreen, requestFullscreen, exitFullscreen]);
const disableVerticalSwipes = useCallback(() => { const disableVerticalSwipes = useCallback(() => {
try { try {
if (webApp?.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') { if (webApp?.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
webApp.disableVerticalSwipes() webApp.disableVerticalSwipes();
} }
} catch { } catch {
// Not supported in this version // Not supported in this version
} }
}, [webApp]) }, [webApp]);
const enableVerticalSwipes = useCallback(() => { const enableVerticalSwipes = useCallback(() => {
try { try {
if (webApp?.enableVerticalSwipes && webApp.version && webApp.version >= '7.7') { if (webApp?.enableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
webApp.enableVerticalSwipes() webApp.enableVerticalSwipes();
} }
} catch { } catch {
// Not supported in this version // Not supported in this version
} }
}, [webApp]) }, [webApp]);
// Check if fullscreen is supported (Bot API 8.0+) // Check if fullscreen is supported (Bot API 8.0+)
const isFullscreenSupported = Boolean(webApp?.requestFullscreen) const isFullscreenSupported = Boolean(webApp?.requestFullscreen);
return { return {
isTelegramWebApp, isTelegramWebApp,
@@ -135,7 +145,7 @@ export function useTelegramWebApp() {
disableVerticalSwipes, disableVerticalSwipes,
enableVerticalSwipes, enableVerticalSwipes,
webApp, webApp,
} };
} }
/** /**
@@ -143,27 +153,27 @@ export function useTelegramWebApp() {
* Call this in main.tsx or App.tsx * Call this in main.tsx or App.tsx
*/ */
export function initTelegramWebApp() { export function initTelegramWebApp() {
const webApp = window.Telegram?.WebApp const webApp = window.Telegram?.WebApp;
if (webApp) { if (webApp) {
webApp.ready() webApp.ready();
webApp.expand() webApp.expand();
// Disable vertical swipes to prevent accidental closing (requires Bot API 7.7+) // Disable vertical swipes to prevent accidental closing (requires Bot API 7.7+)
try { try {
if (webApp.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') { if (webApp.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
webApp.disableVerticalSwipes() webApp.disableVerticalSwipes();
} }
} catch { } catch {
// Swipe control not supported in this version // Swipe control not supported in this version
} }
// Auto-enter fullscreen if enabled in settings (use cached value for instant response) // Auto-enter fullscreen if enabled in settings (use cached value for instant response)
const fullscreenEnabled = getCachedFullscreenEnabled() const fullscreenEnabled = getCachedFullscreenEnabled();
if (fullscreenEnabled && webApp.requestFullscreen && !webApp.isFullscreen) { if (fullscreenEnabled && webApp.requestFullscreen && !webApp.isFullscreen) {
try { try {
webApp.requestFullscreen() webApp.requestFullscreen();
} catch (e) { } catch (e) {
console.warn('Auto-fullscreen failed:', e) console.warn('Auto-fullscreen failed:', e);
} }
} }
} }

View File

@@ -1,214 +1,224 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react';
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme' import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme';
import { themeColorsApi } from '../api/themeColors' import { themeColorsApi } from '../api/themeColors';
type Theme = 'dark' | 'light' type Theme = 'dark' | 'light';
const THEME_KEY = 'cabinet-theme' const THEME_KEY = 'cabinet-theme';
const ENABLED_THEMES_KEY = 'cabinet-enabled-themes' const ENABLED_THEMES_KEY = 'cabinet-enabled-themes';
// Fetch enabled themes from API // Fetch enabled themes from API
async function fetchEnabledThemes(): Promise<EnabledThemes> { async function fetchEnabledThemes(): Promise<EnabledThemes> {
try { try {
const data = await themeColorsApi.getEnabledThemes() const data = await themeColorsApi.getEnabledThemes();
// Cache in localStorage for faster subsequent loads // Cache in localStorage for faster subsequent loads
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(data)) localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(data));
return data return data;
} catch { } catch {
// Ignore errors, use cached or default // Ignore errors, use cached or default
} }
// Try to get from cache // Try to get from cache
const cached = localStorage.getItem(ENABLED_THEMES_KEY) const cached = localStorage.getItem(ENABLED_THEMES_KEY);
if (cached) { if (cached) {
try { try {
return JSON.parse(cached) return JSON.parse(cached);
} catch { } catch {
// Ignore parse errors // Ignore parse errors
} }
} }
return DEFAULT_ENABLED_THEMES return DEFAULT_ENABLED_THEMES;
} }
// Get cached enabled themes synchronously // Get cached enabled themes synchronously
function getCachedEnabledThemes(): EnabledThemes { function getCachedEnabledThemes(): EnabledThemes {
const cached = localStorage.getItem(ENABLED_THEMES_KEY) const cached = localStorage.getItem(ENABLED_THEMES_KEY);
if (cached) { if (cached) {
try { try {
return JSON.parse(cached) return JSON.parse(cached);
} catch { } catch {
// Ignore parse errors // Ignore parse errors
} }
} }
return DEFAULT_ENABLED_THEMES return DEFAULT_ENABLED_THEMES;
} }
// Custom event for same-tab updates // Custom event for same-tab updates
const ENABLED_THEMES_CHANGED_EVENT = 'enabledThemesChanged' const ENABLED_THEMES_CHANGED_EVENT = 'enabledThemesChanged';
// Update cache (called from admin settings) // Update cache (called from admin settings)
export function updateEnabledThemesCache(themes: EnabledThemes) { export function updateEnabledThemesCache(themes: EnabledThemes) {
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(themes)) localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(themes));
// Dispatch custom event for same-tab updates // Dispatch custom event for same-tab updates
window.dispatchEvent(new CustomEvent(ENABLED_THEMES_CHANGED_EVENT, { detail: themes })) window.dispatchEvent(new CustomEvent(ENABLED_THEMES_CHANGED_EVENT, { detail: themes }));
} }
export function useTheme() { export function useTheme() {
const [enabledThemes, setEnabledThemes] = useState<EnabledThemes>(getCachedEnabledThemes) const [enabledThemes, setEnabledThemes] = useState<EnabledThemes>(getCachedEnabledThemes);
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true);
const [theme, setThemeState] = useState<Theme>(() => { const [theme, setThemeState] = useState<Theme>(() => {
const enabled = getCachedEnabledThemes() const enabled = getCachedEnabledThemes();
// Check localStorage first // Check localStorage first
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const stored = localStorage.getItem(THEME_KEY) as Theme | null const stored = localStorage.getItem(THEME_KEY) as Theme | null;
if (stored === 'light' && enabled.light) { if (stored === 'light' && enabled.light) {
return 'light' return 'light';
} }
if (stored === 'dark' && enabled.dark) { if (stored === 'dark' && enabled.dark) {
return 'dark' return 'dark';
} }
// If stored theme is disabled, use the enabled one // If stored theme is disabled, use the enabled one
if (stored && !enabled[stored]) { if (stored && !enabled[stored]) {
return enabled.dark ? 'dark' : 'light' return enabled.dark ? 'dark' : 'light';
} }
// Check system preference // Check system preference
if (window.matchMedia('(prefers-color-scheme: light)').matches && enabled.light) { if (window.matchMedia('(prefers-color-scheme: light)').matches && enabled.light) {
return 'light' return 'light';
} }
} }
// Default to dark if enabled, otherwise light // Default to dark if enabled, otherwise light
return enabled.dark ? 'dark' : 'light' return enabled.dark ? 'dark' : 'light';
}) });
// Fetch enabled themes on mount // Fetch enabled themes on mount
useEffect(() => { useEffect(() => {
fetchEnabledThemes().then((data) => { fetchEnabledThemes().then((data) => {
setEnabledThemes(data) setEnabledThemes(data);
setIsLoading(false) setIsLoading(false);
// If current theme is disabled, switch to enabled one // If current theme is disabled, switch to enabled one
if (!data[theme]) { if (!data[theme]) {
const newTheme = data.dark ? 'dark' : 'light' const newTheme = data.dark ? 'dark' : 'light';
setThemeState(newTheme) setThemeState(newTheme);
} }
}) });
}, []) // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
// Listen for localStorage changes (when admin updates enabled themes from other tabs) // Listen for localStorage changes (when admin updates enabled themes from other tabs)
useEffect(() => { useEffect(() => {
const handleStorageChange = (e: StorageEvent) => { const handleStorageChange = (e: StorageEvent) => {
if (e.key === ENABLED_THEMES_KEY && e.newValue) { if (e.key === ENABLED_THEMES_KEY && e.newValue) {
try { try {
const data = JSON.parse(e.newValue) as EnabledThemes const data = JSON.parse(e.newValue) as EnabledThemes;
setEnabledThemes(data) setEnabledThemes(data);
// If current theme is now disabled, switch to enabled one // If current theme is now disabled, switch to enabled one
if (!data[theme]) { if (!data[theme]) {
const newTheme = data.dark ? 'dark' : 'light' const newTheme = data.dark ? 'dark' : 'light';
setThemeState(newTheme) setThemeState(newTheme);
} }
} catch { } catch {
// Ignore parse errors // Ignore parse errors
} }
} }
} };
window.addEventListener('storage', handleStorageChange) window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange) return () => window.removeEventListener('storage', handleStorageChange);
}, [theme]) }, [theme]);
// Listen for same-tab enabled themes changes (from admin settings) // Listen for same-tab enabled themes changes (from admin settings)
useEffect(() => { useEffect(() => {
const handleEnabledThemesChange = (e: CustomEvent<EnabledThemes>) => { const handleEnabledThemesChange = (e: CustomEvent<EnabledThemes>) => {
const data = e.detail const data = e.detail;
setEnabledThemes(data) setEnabledThemes(data);
// If current theme is now disabled, switch to enabled one // If current theme is now disabled, switch to enabled one
if (!data[theme]) { if (!data[theme]) {
const newTheme = data.dark ? 'dark' : 'light' const newTheme = data.dark ? 'dark' : 'light';
setThemeState(newTheme) setThemeState(newTheme);
}
} }
};
window.addEventListener(ENABLED_THEMES_CHANGED_EVENT, handleEnabledThemesChange as EventListener) window.addEventListener(
return () => window.removeEventListener(ENABLED_THEMES_CHANGED_EVENT, handleEnabledThemesChange as EventListener) ENABLED_THEMES_CHANGED_EVENT,
}, [theme]) handleEnabledThemesChange as EventListener,
);
return () =>
window.removeEventListener(
ENABLED_THEMES_CHANGED_EVENT,
handleEnabledThemesChange as EventListener,
);
}, [theme]);
// Apply theme to document - also check if theme is disabled and switch // Apply theme to document - also check if theme is disabled and switch
useEffect(() => { useEffect(() => {
const root = document.documentElement const root = document.documentElement;
// If current theme is disabled, switch to the enabled one // If current theme is disabled, switch to the enabled one
if (!enabledThemes[theme]) { if (!enabledThemes[theme]) {
const newTheme = enabledThemes.dark ? 'dark' : 'light' const newTheme = enabledThemes.dark ? 'dark' : 'light';
if (newTheme !== theme) { if (newTheme !== theme) {
setThemeState(newTheme) setThemeState(newTheme);
return // Will re-run with correct theme return; // Will re-run with correct theme
} }
} }
if (theme === 'light') { if (theme === 'light') {
root.classList.remove('dark') root.classList.remove('dark');
root.classList.add('light') root.classList.add('light');
} else { } else {
root.classList.remove('light') root.classList.remove('light');
root.classList.add('dark') root.classList.add('dark');
} }
localStorage.setItem(THEME_KEY, theme) localStorage.setItem(THEME_KEY, theme);
}, [theme, enabledThemes]) }, [theme, enabledThemes]);
// Listen for system theme changes // Listen for system theme changes
useEffect(() => { useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)') const mediaQuery = window.matchMedia('(prefers-color-scheme: light)');
const handleChange = (e: MediaQueryListEvent) => { const handleChange = (e: MediaQueryListEvent) => {
const stored = localStorage.getItem(THEME_KEY) const stored = localStorage.getItem(THEME_KEY);
// Only auto-switch if user hasn't set a preference and theme is enabled // Only auto-switch if user hasn't set a preference and theme is enabled
if (!stored) { if (!stored) {
const newTheme = e.matches ? 'light' : 'dark' const newTheme = e.matches ? 'light' : 'dark';
if (enabledThemes[newTheme]) { if (enabledThemes[newTheme]) {
setThemeState(newTheme) setThemeState(newTheme);
}
} }
} }
};
mediaQuery.addEventListener('change', handleChange) mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange) return () => mediaQuery.removeEventListener('change', handleChange);
}, [enabledThemes]) }, [enabledThemes]);
const setTheme = useCallback((newTheme: Theme) => { const setTheme = useCallback(
(newTheme: Theme) => {
// Only allow setting if theme is enabled // Only allow setting if theme is enabled
if (enabledThemes[newTheme]) { if (enabledThemes[newTheme]) {
setThemeState(newTheme) setThemeState(newTheme);
} }
}, [enabledThemes]) },
[enabledThemes],
);
const toggleTheme = useCallback(() => { const toggleTheme = useCallback(() => {
setThemeState((prev) => { setThemeState((prev) => {
const newTheme = prev === 'dark' ? 'light' : 'dark' const newTheme = prev === 'dark' ? 'light' : 'dark';
// Only toggle if the new theme is enabled // Only toggle if the new theme is enabled
if (enabledThemes[newTheme]) { if (enabledThemes[newTheme]) {
return newTheme return newTheme;
} }
return prev return prev;
}) });
}, [enabledThemes]) }, [enabledThemes]);
const isDark = theme === 'dark' const isDark = theme === 'dark';
const isLight = theme === 'light' const isLight = theme === 'light';
// Check if theme switching is available (both themes enabled and loaded) // Check if theme switching is available (both themes enabled and loaded)
const canToggle = !isLoading && enabledThemes.dark && enabledThemes.light const canToggle = !isLoading && enabledThemes.dark && enabledThemes.light;
// Refresh enabled themes from API // Refresh enabled themes from API
const refreshEnabledThemes = useCallback(() => { const refreshEnabledThemes = useCallback(() => {
fetchEnabledThemes().then((data) => { fetchEnabledThemes().then((data) => {
setEnabledThemes(data) setEnabledThemes(data);
if (!data[theme]) { if (!data[theme]) {
const newTheme = data.dark ? 'dark' : 'light' const newTheme = data.dark ? 'dark' : 'light';
setThemeState(newTheme) setThemeState(newTheme);
} }
}) });
}, [theme]) }, [theme]);
return { return {
theme, theme,
@@ -220,5 +230,5 @@ export function useTheme() {
canToggle, canToggle,
isLoading, isLoading,
refreshEnabledThemes, refreshEnabledThemes,
} };
} }

View File

@@ -1,76 +1,76 @@
import { useEffect } from 'react' import { useEffect } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query';
import { themeColorsApi } from '../api/themeColors' import { themeColorsApi } from '../api/themeColors';
import { ThemeColors, DEFAULT_THEME_COLORS, SHADE_LEVELS, ColorPalette } from '../types/theme' import { ThemeColors, DEFAULT_THEME_COLORS, SHADE_LEVELS, ColorPalette } from '../types/theme';
// Convert hex to RGB values // Convert hex to RGB values
function hexToRgb(hex: string): { r: number; g: number; b: number } { function hexToRgb(hex: string): { r: number; g: number; b: number } {
// Handle shorthand hex // Handle shorthand hex
if (hex.length === 4) { if (hex.length === 4) {
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3] hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];
} }
const r = parseInt(hex.slice(1, 3), 16) const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16) const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16) const b = parseInt(hex.slice(5, 7), 16);
return { r, g, b } return { r, g, b };
} }
// Convert hex to HSL // Convert hex to HSL
function hexToHsl(hex: string): { h: number; s: number; l: number } { function hexToHsl(hex: string): { h: number; s: number; l: number } {
const { r, g, b } = hexToRgb(hex) const { r, g, b } = hexToRgb(hex);
const rNorm = r / 255 const rNorm = r / 255;
const gNorm = g / 255 const gNorm = g / 255;
const bNorm = b / 255 const bNorm = b / 255;
const max = Math.max(rNorm, gNorm, bNorm) const max = Math.max(rNorm, gNorm, bNorm);
const min = Math.min(rNorm, gNorm, bNorm) const min = Math.min(rNorm, gNorm, bNorm);
let h = 0 let h = 0;
let s = 0 let s = 0;
const l = (max + min) / 2 const l = (max + min) / 2;
if (max !== min) { if (max !== min) {
const d = max - min const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min) s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) { switch (max) {
case rNorm: case rNorm:
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6 h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
break break;
case gNorm: case gNorm:
h = ((bNorm - rNorm) / d + 2) / 6 h = ((bNorm - rNorm) / d + 2) / 6;
break break;
case bNorm: case bNorm:
h = ((rNorm - gNorm) / d + 4) / 6 h = ((rNorm - gNorm) / d + 4) / 6;
break break;
} }
} }
return { h: h * 360, s: s * 100, l: l * 100 } return { h: h * 360, s: s * 100, l: l * 100 };
} }
// Convert HSL to RGB values // Convert HSL to RGB values
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } { function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
s /= 100 s /= 100;
l /= 100 l /= 100;
const a = s * Math.min(l, 1 - l) const a = s * Math.min(l, 1 - l);
const f = (n: number) => { const f = (n: number) => {
const k = (n + h / 30) % 12 const k = (n + h / 30) % 12;
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1) const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color) return Math.round(255 * color);
} };
return { r: f(0), g: f(8), b: f(4) } return { r: f(0), g: f(8), b: f(4) };
} }
// Convert RGB to string format for CSS variable // Convert RGB to string format for CSS variable
function rgbToString(r: number, g: number, b: number): string { function rgbToString(r: number, g: number, b: number): string {
return `${r}, ${g}, ${b}` return `${r}, ${g}, ${b}`;
} }
// Generate color palette from base color (returns RGB strings) // Generate color palette from base color (returns RGB strings)
function generatePalette(baseHex: string): ColorPalette { function generatePalette(baseHex: string): ColorPalette {
const { h, s } = hexToHsl(baseHex) const { h, s } = hexToHsl(baseHex);
// Lightness values for each shade level (from light to dark) // Lightness values for each shade level (from light to dark)
const lightnessMap: Record<number, number> = { const lightnessMap: Record<number, number> = {
@@ -85,118 +85,151 @@ function generatePalette(baseHex: string): ColorPalette {
800: 26, 800: 26,
900: 18, 900: 18,
950: 10, 950: 10,
} };
const palette: Partial<ColorPalette> = {} const palette: Partial<ColorPalette> = {};
for (const shade of SHADE_LEVELS) { for (const shade of SHADE_LEVELS) {
const lightness = lightnessMap[shade] const lightness = lightnessMap[shade];
// Adjust saturation slightly for very light/dark shades // Adjust saturation slightly for very light/dark shades
const adjustedS = shade <= 100 ? s * 0.7 : shade >= 900 ? s * 0.8 : s const adjustedS = shade <= 100 ? s * 0.7 : shade >= 900 ? s * 0.8 : s;
const { r, g, b } = hslToRgb(h, adjustedS, lightness) const { r, g, b } = hslToRgb(h, adjustedS, lightness);
palette[shade] = rgbToString(r, g, b) palette[shade] = rgbToString(r, g, b);
} }
return palette as ColorPalette return palette as ColorPalette;
} }
// Interpolate between two RGB colors // Interpolate between two RGB colors
function interpolateRgb( function interpolateRgb(
rgb1: { r: number; g: number; b: number }, rgb1: { r: number; g: number; b: number },
rgb2: { r: number; g: number; b: number }, rgb2: { r: number; g: number; b: number },
factor: number factor: number,
): string { ): string {
return rgbToString( return rgbToString(
Math.round(rgb1.r + (rgb2.r - rgb1.r) * factor), Math.round(rgb1.r + (rgb2.r - rgb1.r) * factor),
Math.round(rgb1.g + (rgb2.g - rgb1.g) * factor), Math.round(rgb1.g + (rgb2.g - rgb1.g) * factor),
Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor) Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor),
) );
} }
// Apply theme colors as CSS variables (RGB format for Tailwind opacity support) // Apply theme colors as CSS variables (RGB format for Tailwind opacity support)
export function applyThemeColors(colors: ThemeColors): void { export function applyThemeColors(colors: ThemeColors): void {
const root = document.documentElement const root = document.documentElement;
// Generate palettes from status colors // Generate palettes from status colors
const accentPalette = generatePalette(colors.accent) const accentPalette = generatePalette(colors.accent);
const successPalette = generatePalette(colors.success) const successPalette = generatePalette(colors.success);
const warningPalette = generatePalette(colors.warning) const warningPalette = generatePalette(colors.warning);
const errorPalette = generatePalette(colors.error) const errorPalette = generatePalette(colors.error);
// === DARK THEME PALETTE === // === DARK THEME PALETTE ===
// Convert hex colors to RGB // Convert hex colors to RGB
const darkBgRgb = hexToRgb(colors.darkBackground) const darkBgRgb = hexToRgb(colors.darkBackground);
const darkSurfaceRgb = hexToRgb(colors.darkSurface) const darkSurfaceRgb = hexToRgb(colors.darkSurface);
const darkTextRgb = hexToRgb(colors.darkText) const darkTextRgb = hexToRgb(colors.darkText);
const darkTextSecRgb = hexToRgb(colors.darkTextSecondary) const darkTextSecRgb = hexToRgb(colors.darkTextSecondary);
// Apply dark palette with actual user colors: // Apply dark palette with actual user colors:
// Text colors (light shades): 50-100 = primary text, 200-300 = mixed, 400 = secondary text // Text colors (light shades): 50-100 = primary text, 200-300 = mixed, 400 = secondary text
root.style.setProperty('--color-dark-50', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b)) root.style.setProperty(
root.style.setProperty('--color-dark-100', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b)) '--color-dark-50',
root.style.setProperty('--color-dark-200', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.33)) rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b),
root.style.setProperty('--color-dark-300', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.66)) );
root.style.setProperty('--color-dark-400', rgbToString(darkTextSecRgb.r, darkTextSecRgb.g, darkTextSecRgb.b)) root.style.setProperty(
'--color-dark-100',
rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b),
);
root.style.setProperty('--color-dark-200', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.33));
root.style.setProperty('--color-dark-300', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.66));
root.style.setProperty(
'--color-dark-400',
rgbToString(darkTextSecRgb.r, darkTextSecRgb.g, darkTextSecRgb.b),
);
// Transition colors (500-700): interpolate between secondary text and surface // Transition colors (500-700): interpolate between secondary text and surface
root.style.setProperty('--color-dark-500', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.4)) root.style.setProperty('--color-dark-500', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.4));
root.style.setProperty('--color-dark-600', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.6)) root.style.setProperty('--color-dark-600', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.6));
root.style.setProperty('--color-dark-700', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.8)) root.style.setProperty('--color-dark-700', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.8));
// Surface/card colors (800-850): surface color // Surface/card colors (800-850): surface color
root.style.setProperty('--color-dark-800', rgbToString(darkSurfaceRgb.r, darkSurfaceRgb.g, darkSurfaceRgb.b)) root.style.setProperty(
root.style.setProperty('--color-dark-850', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.5)) '--color-dark-800',
rgbToString(darkSurfaceRgb.r, darkSurfaceRgb.g, darkSurfaceRgb.b),
);
root.style.setProperty('--color-dark-850', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.5));
// Background colors (900-950): background color // Background colors (900-950): background color
root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7)) root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7));
root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b)) root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b));
// === LIGHT THEME PALETTE === // === LIGHT THEME PALETTE ===
const lightBgRgb = hexToRgb(colors.lightBackground) const lightBgRgb = hexToRgb(colors.lightBackground);
const lightSurfaceRgb = hexToRgb(colors.lightSurface) const lightSurfaceRgb = hexToRgb(colors.lightSurface);
const lightTextRgb = hexToRgb(colors.lightText) const lightTextRgb = hexToRgb(colors.lightText);
const lightTextSecRgb = hexToRgb(colors.lightTextSecondary) const lightTextSecRgb = hexToRgb(colors.lightTextSecondary);
// Apply champagne palette with actual user colors: // Apply champagne palette with actual user colors:
// Background colors (light shades): 50-100 = surface, 200-400 = background tones // Background colors (light shades): 50-100 = surface, 200-400 = background tones
root.style.setProperty('--color-champagne-50', rgbToString(lightSurfaceRgb.r, lightSurfaceRgb.g, lightSurfaceRgb.b)) root.style.setProperty(
root.style.setProperty('--color-champagne-100', interpolateRgb(lightSurfaceRgb, lightBgRgb, 0.3)) '--color-champagne-50',
root.style.setProperty('--color-champagne-200', rgbToString(lightBgRgb.r, lightBgRgb.g, lightBgRgb.b)) rgbToString(lightSurfaceRgb.r, lightSurfaceRgb.g, lightSurfaceRgb.b),
root.style.setProperty('--color-champagne-300', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.2)) );
root.style.setProperty('--color-champagne-400', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.4)) root.style.setProperty('--color-champagne-100', interpolateRgb(lightSurfaceRgb, lightBgRgb, 0.3));
root.style.setProperty(
'--color-champagne-200',
rgbToString(lightBgRgb.r, lightBgRgb.g, lightBgRgb.b),
);
root.style.setProperty('--color-champagne-300', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.2));
root.style.setProperty('--color-champagne-400', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.4));
// Transition colors (500-600): between bg and text // Transition colors (500-600): between bg and text
root.style.setProperty('--color-champagne-500', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.6)) root.style.setProperty('--color-champagne-500', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.6));
root.style.setProperty('--color-champagne-600', rgbToString(lightTextSecRgb.r, lightTextSecRgb.g, lightTextSecRgb.b)) root.style.setProperty(
'--color-champagne-600',
rgbToString(lightTextSecRgb.r, lightTextSecRgb.g, lightTextSecRgb.b),
);
// Text colors (700-950): secondary to primary text // Text colors (700-950): secondary to primary text
root.style.setProperty('--color-champagne-700', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.33)) root.style.setProperty(
root.style.setProperty('--color-champagne-800', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.66)) '--color-champagne-700',
root.style.setProperty('--color-champagne-900', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b)) interpolateRgb(lightTextSecRgb, lightTextRgb, 0.33),
root.style.setProperty('--color-champagne-950', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b)) );
root.style.setProperty(
'--color-champagne-800',
interpolateRgb(lightTextSecRgb, lightTextRgb, 0.66),
);
root.style.setProperty(
'--color-champagne-900',
rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b),
);
root.style.setProperty(
'--color-champagne-950',
rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b),
);
// === STATUS COLOR PALETTES === // === STATUS COLOR PALETTES ===
for (const shade of SHADE_LEVELS) { for (const shade of SHADE_LEVELS) {
root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade]) root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade]);
root.style.setProperty(`--color-success-${shade}`, successPalette[shade]) root.style.setProperty(`--color-success-${shade}`, successPalette[shade]);
root.style.setProperty(`--color-warning-${shade}`, warningPalette[shade]) root.style.setProperty(`--color-warning-${shade}`, warningPalette[shade]);
root.style.setProperty(`--color-error-${shade}`, errorPalette[shade]) root.style.setProperty(`--color-error-${shade}`, errorPalette[shade]);
} }
// Apply semantic colors (hex for direct use) // Apply semantic colors (hex for direct use)
root.style.setProperty('--color-dark-bg', colors.darkBackground) root.style.setProperty('--color-dark-bg', colors.darkBackground);
root.style.setProperty('--color-dark-surface', colors.darkSurface) root.style.setProperty('--color-dark-surface', colors.darkSurface);
root.style.setProperty('--color-dark-text', colors.darkText) root.style.setProperty('--color-dark-text', colors.darkText);
root.style.setProperty('--color-dark-text-secondary', colors.darkTextSecondary) root.style.setProperty('--color-dark-text-secondary', colors.darkTextSecondary);
root.style.setProperty('--color-light-bg', colors.lightBackground) root.style.setProperty('--color-light-bg', colors.lightBackground);
root.style.setProperty('--color-light-surface', colors.lightSurface) root.style.setProperty('--color-light-surface', colors.lightSurface);
root.style.setProperty('--color-light-text', colors.lightText) root.style.setProperty('--color-light-text', colors.lightText);
root.style.setProperty('--color-light-text-secondary', colors.lightTextSecondary) root.style.setProperty('--color-light-text-secondary', colors.lightTextSecondary);
} }
export function useThemeColors() { export function useThemeColors() {
const queryClient = useQueryClient() const queryClient = useQueryClient();
const { const {
data: colors, data: colors,
@@ -208,22 +241,22 @@ export function useThemeColors() {
staleTime: 5 * 60 * 1000, // 5 minutes staleTime: 5 * 60 * 1000, // 5 minutes
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
retry: 1, retry: 1,
}) });
// Apply colors when loaded or changed // Apply colors when loaded or changed
useEffect(() => { useEffect(() => {
const colorsToApply = colors || DEFAULT_THEME_COLORS const colorsToApply = colors || DEFAULT_THEME_COLORS;
applyThemeColors(colorsToApply) applyThemeColors(colorsToApply);
}, [colors]) }, [colors]);
const invalidateColors = () => { const invalidateColors = () => {
queryClient.invalidateQueries({ queryKey: ['theme-colors'] }) queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
} };
return { return {
colors: colors || DEFAULT_THEME_COLORS, colors: colors || DEFAULT_THEME_COLORS,
isLoading, isLoading,
error, error,
invalidateColors, invalidateColors,
} };
} }

View File

@@ -1,156 +1,159 @@
import { useEffect, useRef, useCallback, useState } from 'react' import { useEffect, useRef, useCallback, useState } from 'react';
import { useAuthStore } from '../store/auth' import { useAuthStore } from '../store/auth';
const isDev = import.meta.env.DEV const isDev = import.meta.env.DEV;
export interface WSMessage { export interface WSMessage {
type: string type: string;
ticket_id?: number ticket_id?: number;
message?: string message?: string;
title?: string title?: string;
user_id?: number user_id?: number;
is_admin?: boolean is_admin?: boolean;
} }
interface UseWebSocketOptions { interface UseWebSocketOptions {
onMessage?: (message: WSMessage) => void onMessage?: (message: WSMessage) => void;
onConnect?: () => void onConnect?: () => void;
onDisconnect?: () => void onDisconnect?: () => void;
} }
export function useWebSocket(options: UseWebSocketOptions = {}) { export function useWebSocket(options: UseWebSocketOptions = {}) {
const { accessToken, isAuthenticated } = useAuthStore() const { accessToken, isAuthenticated } = useAuthStore();
const wsRef = useRef<WebSocket | null>(null) const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null) const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null) const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [isConnected, setIsConnected] = useState(false) const [isConnected, setIsConnected] = useState(false);
const reconnectAttemptsRef = useRef(0) const reconnectAttemptsRef = useRef(0);
const maxReconnectAttempts = 5 const maxReconnectAttempts = 5;
const optionsRef = useRef(options) const optionsRef = useRef(options);
// Update options ref when they change // Update options ref when they change
useEffect(() => { useEffect(() => {
optionsRef.current = options optionsRef.current = options;
}, [options]) }, [options]);
const cleanup = useCallback(() => { const cleanup = useCallback(() => {
if (reconnectTimeoutRef.current) { if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null reconnectTimeoutRef.current = null;
} }
if (pingIntervalRef.current) { if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current) clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null pingIntervalRef.current = null;
} }
if (wsRef.current) { if (wsRef.current) {
wsRef.current.close() wsRef.current.close();
wsRef.current = null wsRef.current = null;
} }
}, []) }, []);
const connect = useCallback(() => { const connect = useCallback(() => {
if (!accessToken || !isAuthenticated) { if (!accessToken || !isAuthenticated) {
return return;
} }
// Don't reconnect if already connected // Don't reconnect if already connected
if (wsRef.current?.readyState === WebSocket.OPEN) { if (wsRef.current?.readyState === WebSocket.OPEN) {
return return;
} }
cleanup() cleanup();
// Build WebSocket URL // Build WebSocket URL
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
let host = window.location.host let host = window.location.host;
// Handle VITE_API_URL - can be absolute URL or relative path // Handle VITE_API_URL - can be absolute URL or relative path
const apiUrl = import.meta.env.VITE_API_URL const apiUrl = import.meta.env.VITE_API_URL;
if (apiUrl && (apiUrl.startsWith('http://') || apiUrl.startsWith('https://'))) { if (apiUrl && (apiUrl.startsWith('http://') || apiUrl.startsWith('https://'))) {
try { try {
host = new URL(apiUrl).host host = new URL(apiUrl).host;
} catch { } catch {
// If URL parsing fails, use window.location.host // If URL parsing fails, use window.location.host
} }
} }
// If apiUrl is relative (like /api), use window.location.host // If apiUrl is relative (like /api), use window.location.host
const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}` const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}`;
try { try {
const ws = new WebSocket(wsUrl) const ws = new WebSocket(wsUrl);
wsRef.current = ws wsRef.current = ws;
ws.onopen = () => { ws.onopen = () => {
if (isDev) console.log('[WS] Connected') if (isDev) console.log('[WS] Connected');
setIsConnected(true) setIsConnected(true);
reconnectAttemptsRef.current = 0 reconnectAttemptsRef.current = 0;
optionsRef.current.onConnect?.() optionsRef.current.onConnect?.();
// Setup ping interval (every 25 seconds) // Setup ping interval (every 25 seconds)
pingIntervalRef.current = setInterval(() => { pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' })) ws.send(JSON.stringify({ type: 'ping' }));
}
}, 25000)
} }
}, 25000);
};
ws.onmessage = (event) => { ws.onmessage = (event) => {
try { try {
const message = JSON.parse(event.data) as WSMessage const message = JSON.parse(event.data) as WSMessage;
// Ignore pong messages // Ignore pong messages
if (message.type === 'pong' || message.type === 'connected') { if (message.type === 'pong' || message.type === 'connected') {
return return;
} }
optionsRef.current.onMessage?.(message) optionsRef.current.onMessage?.(message);
} catch (e) { } catch (e) {
if (isDev) console.error('[WS] Failed to parse message:', e) if (isDev) console.error('[WS] Failed to parse message:', e);
}
} }
};
ws.onclose = (event) => { ws.onclose = (event) => {
if (isDev) console.log('[WS] Disconnected:', event.code, event.reason) if (isDev) console.log('[WS] Disconnected:', event.code, event.reason);
setIsConnected(false) setIsConnected(false);
optionsRef.current.onDisconnect?.() optionsRef.current.onDisconnect?.();
if (pingIntervalRef.current) { if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current) clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null pingIntervalRef.current = null;
} }
// Attempt to reconnect if not closed intentionally // Attempt to reconnect if not closed intentionally
if (event.code !== 1000 && reconnectAttemptsRef.current < maxReconnectAttempts) { if (event.code !== 1000 && reconnectAttemptsRef.current < maxReconnectAttempts) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000) const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
if (isDev) console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`) if (isDev)
console.log(
`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`,
);
reconnectTimeoutRef.current = setTimeout(() => { reconnectTimeoutRef.current = setTimeout(() => {
reconnectAttemptsRef.current++ reconnectAttemptsRef.current++;
connect() connect();
}, delay) }, delay);
}
} }
};
ws.onerror = (error) => { ws.onerror = (error) => {
if (isDev) console.error('[WS] Error:', error) if (isDev) console.error('[WS] Error:', error);
} };
} catch (e) { } catch (e) {
if (isDev) console.error('[WS] Failed to connect:', e) if (isDev) console.error('[WS] Failed to connect:', e);
} }
}, [accessToken, isAuthenticated, cleanup]) }, [accessToken, isAuthenticated, cleanup]);
// Connect when authenticated // Connect when authenticated
useEffect(() => { useEffect(() => {
if (isAuthenticated && accessToken) { if (isAuthenticated && accessToken) {
connect() connect();
} else { } else {
cleanup() cleanup();
setIsConnected(false) setIsConnected(false);
} }
return cleanup return cleanup;
}, [isAuthenticated, accessToken, connect, cleanup]) }, [isAuthenticated, accessToken, connect, cleanup]);
return { isConnected } return { isConnected };
} }

View File

@@ -1,18 +1,18 @@
import i18n from 'i18next' import i18n from 'i18next';
import { initReactI18next } from 'react-i18next' import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector' import LanguageDetector from 'i18next-browser-languagedetector';
import ru from './locales/ru.json' import ru from './locales/ru.json';
import en from './locales/en.json' import en from './locales/en.json';
import zh from './locales/zh.json' import zh from './locales/zh.json';
import fa from './locales/fa.json' import fa from './locales/fa.json';
const resources = { const resources = {
ru: { translation: ru }, ru: { translation: ru },
en: { translation: en }, en: { translation: en },
zh: { translation: zh }, zh: { translation: zh },
fa: { translation: fa }, fa: { translation: fa },
} };
i18n i18n
.use(LanguageDetector) .use(LanguageDetector)
@@ -35,6 +35,6 @@ i18n
react: { react: {
useSuspense: false, useSuspense: false,
}, },
}) });
export default i18n export default i18n;

View File

@@ -1,20 +1,20 @@
import React from 'react' import React from 'react';
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App' import App from './App';
import { ThemeColorsProvider } from './providers/ThemeColorsProvider' import { ThemeColorsProvider } from './providers/ThemeColorsProvider';
import { ToastProvider } from './components/Toast' import { ToastProvider } from './components/Toast';
import { initLogoPreload } from './api/branding' import { initLogoPreload } from './api/branding';
import { initTelegramWebApp } from './hooks/useTelegramWebApp' import { initTelegramWebApp } from './hooks/useTelegramWebApp';
import './i18n' import './i18n';
import './styles/globals.css' import './styles/globals.css';
// Initialize Telegram WebApp (expand, disable swipes) // Initialize Telegram WebApp (expand, disable swipes)
initTelegramWebApp() initTelegramWebApp();
// Preload logo from cache immediately on page load // Preload logo from cache immediately on page load
initLogoPreload() initLogoPreload();
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
@@ -23,7 +23,7 @@ const queryClient = new QueryClient({
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
}, },
}, },
}) });
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
@@ -37,4 +37,4 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>
</React.StrictMode>, </React.StrictMode>,
) );

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,81 +1,115 @@
import { useState, useRef, useMemo } from 'react' import { useState, useRef, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom';
import { import {
adminBroadcastsApi, adminBroadcastsApi,
Broadcast, Broadcast,
BroadcastFilter, BroadcastFilter,
TariffFilter, TariffFilter,
BroadcastCreateRequest, BroadcastCreateRequest,
} from '../api/adminBroadcasts' } from '../api/adminBroadcasts';
// Icons // Icons
const BackIcon = () => ( const BackIcon = () => (
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg> </svg>
) );
const BroadcastIcon = () => ( const BroadcastIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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" /> <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> </svg>
) );
const PlusIcon = () => ( const PlusIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg> </svg>
) );
const XIcon = () => ( const XIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
) );
const RefreshIcon = () => ( const RefreshIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <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> </svg>
) );
const StopIcon = () => ( const StopIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" 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" /> <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> </svg>
) );
const PhotoIcon = () => ( const PhotoIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <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> </svg>
) );
const VideoIcon = () => ( const VideoIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <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> </svg>
) );
const DocumentIcon = () => ( const DocumentIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <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> </svg>
) );
const UsersIcon = () => ( const UsersIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <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> </svg>
) );
const ChevronDownIcon = () => ( const ChevronDownIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg> </svg>
) );
// Status badge component // Status badge component
function StatusBadge({ status }: { status: string }) { function StatusBadge({ status }: { status: string }) {
@@ -87,13 +121,13 @@ function StatusBadge({ status }: { status: string }) {
failed: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Ошибка' }, failed: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Ошибка' },
cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-400', label: 'Отменено' }, cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-400', label: 'Отменено' },
cancelling: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'Отменяется' }, cancelling: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'Отменяется' },
} };
const config = statusConfig[status] || statusConfig.queued const config = statusConfig[status] || statusConfig.queued;
return ( return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${config.bg} ${config.text}`}> <span className={`rounded-full px-2 py-1 text-xs font-medium ${config.bg} ${config.text}`}>
{config.label} {config.label}
</span> </span>
) );
} }
// Filter labels // Filter labels
@@ -105,201 +139,203 @@ const FILTER_GROUP_LABELS: Record<string, string> = {
activity: 'По активности', activity: 'По активности',
source: 'По источнику', source: 'По источнику',
tariff: 'По тарифу', tariff: 'По тарифу',
} };
// Create broadcast modal // Create broadcast modal
interface CreateModalProps { interface CreateModalProps {
onClose: () => void onClose: () => void;
onSuccess: () => void onSuccess: () => void;
} }
function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) { function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
const { t } = useTranslation() const { t } = useTranslation();
const queryClient = useQueryClient() const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null);
const [target, setTarget] = useState('') const [target, setTarget] = useState('');
const [messageText, setMessageText] = useState('') const [messageText, setMessageText] = useState('');
const [selectedButtons, setSelectedButtons] = useState<string[]>(['home']) const [selectedButtons, setSelectedButtons] = useState<string[]>(['home']);
const [mediaFile, setMediaFile] = useState<File | null>(null) const [mediaFile, setMediaFile] = useState<File | null>(null);
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo') const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo');
const [mediaPreview, setMediaPreview] = useState<string | null>(null) const [mediaPreview, setMediaPreview] = useState<string | null>(null);
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null) const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false);
const [showFilters, setShowFilters] = useState(false) const [showFilters, setShowFilters] = useState(false);
// Fetch filters // Fetch filters
const { data: filtersData, isLoading: filtersLoading } = useQuery({ const { data: filtersData, isLoading: filtersLoading } = useQuery({
queryKey: ['admin', 'broadcasts', 'filters'], queryKey: ['admin', 'broadcasts', 'filters'],
queryFn: adminBroadcastsApi.getFilters, queryFn: adminBroadcastsApi.getFilters,
}) });
// Fetch buttons // Fetch buttons
const { data: buttonsData } = useQuery({ const { data: buttonsData } = useQuery({
queryKey: ['admin', 'broadcasts', 'buttons'], queryKey: ['admin', 'broadcasts', 'buttons'],
queryFn: adminBroadcastsApi.getButtons, queryFn: adminBroadcastsApi.getButtons,
}) });
// Preview mutation // Preview mutation
const previewMutation = useMutation({ const previewMutation = useMutation({
mutationFn: adminBroadcastsApi.preview, mutationFn: adminBroadcastsApi.preview,
}) });
// Create mutation // Create mutation
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: adminBroadcastsApi.create, mutationFn: adminBroadcastsApi.create,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] }) queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
onSuccess() onSuccess();
onClose() onClose();
}, },
}) });
// Group filters // Group filters
const groupedFilters = useMemo(() => { const groupedFilters = useMemo(() => {
if (!filtersData) return {} if (!filtersData) return {};
const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {} const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {};
// Basic filters // Basic filters
filtersData.filters.forEach(f => { filtersData.filters.forEach((f) => {
const group = f.group || 'basic' const group = f.group || 'basic';
if (!groups[group]) groups[group] = [] if (!groups[group]) groups[group] = [];
groups[group].push(f) groups[group].push(f);
}) });
// Tariff filters // Tariff filters
if (filtersData.tariff_filters.length > 0) { if (filtersData.tariff_filters.length > 0) {
groups['tariff'] = filtersData.tariff_filters groups['tariff'] = filtersData.tariff_filters;
} }
// Custom filters // Custom filters
filtersData.custom_filters.forEach(f => { filtersData.custom_filters.forEach((f) => {
const group = f.group || 'custom' const group = f.group || 'custom';
if (!groups[group]) groups[group] = [] if (!groups[group]) groups[group] = [];
groups[group].push(f) groups[group].push(f);
}) });
return groups return groups;
}, [filtersData]) }, [filtersData]);
// Selected filter info // Selected filter info
const selectedFilter = useMemo(() => { const selectedFilter = useMemo(() => {
if (!target || !filtersData) return null if (!target || !filtersData) return null;
const all = [ const all = [
...filtersData.filters, ...filtersData.filters,
...filtersData.tariff_filters, ...filtersData.tariff_filters,
...filtersData.custom_filters, ...filtersData.custom_filters,
] ];
return all.find(f => f.key === target) return all.find((f) => f.key === target);
}, [target, filtersData]) }, [target, filtersData]);
// Handle file selection // Handle file selection
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0];
if (!file) return if (!file) return;
setMediaFile(file) setMediaFile(file);
// Determine media type // Determine media type
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
setMediaType('photo') setMediaType('photo');
setMediaPreview(URL.createObjectURL(file)) setMediaPreview(URL.createObjectURL(file));
} else if (file.type.startsWith('video/')) { } else if (file.type.startsWith('video/')) {
setMediaType('video') setMediaType('video');
setMediaPreview(null) setMediaPreview(null);
} else { } else {
setMediaType('document') setMediaType('document');
setMediaPreview(null) setMediaPreview(null);
} }
// Upload file // Upload file
setIsUploading(true) setIsUploading(true);
try { try {
const result = await adminBroadcastsApi.uploadMedia(file, mediaType) const result = await adminBroadcastsApi.uploadMedia(file, mediaType);
setUploadedFileId(result.file_id) setUploadedFileId(result.file_id);
} catch (err) { } catch (err) {
console.error('Upload failed:', err) console.error('Upload failed:', err);
setMediaFile(null) setMediaFile(null);
setMediaPreview(null) setMediaPreview(null);
} finally { } finally {
setIsUploading(false) setIsUploading(false);
}
} }
};
// Remove media // Remove media
const handleRemoveMedia = () => { const handleRemoveMedia = () => {
setMediaFile(null) setMediaFile(null);
setMediaPreview(null) setMediaPreview(null);
setUploadedFileId(null) setUploadedFileId(null);
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = '' fileInputRef.current.value = '';
}
} }
};
// Toggle button // Toggle button
const toggleButton = (key: string) => { const toggleButton = (key: string) => {
setSelectedButtons(prev => setSelectedButtons((prev) =>
prev.includes(key) ? prev.filter(b => b !== key) : [...prev, key] prev.includes(key) ? prev.filter((b) => b !== key) : [...prev, key],
) );
} };
// Submit // Submit
const handleSubmit = () => { const handleSubmit = () => {
if (!target || !messageText.trim()) return if (!target || !messageText.trim()) return;
const data: BroadcastCreateRequest = { const data: BroadcastCreateRequest = {
target, target,
message_text: messageText, message_text: messageText,
selected_buttons: selectedButtons, selected_buttons: selectedButtons,
} };
if (uploadedFileId) { if (uploadedFileId) {
data.media = { data.media = {
type: mediaType, type: mediaType,
file_id: uploadedFileId, file_id: uploadedFileId,
} };
} }
createMutation.mutate(data) createMutation.mutate(data);
} };
const recipientsCount = previewMutation.data?.count ?? selectedFilter?.count ?? null const recipientsCount = previewMutation.data?.count ?? selectedFilter?.count ?? null;
return ( return (
<div className="fixed inset-0 flex items-center justify-center z-50 p-4 overflow-y-auto"> <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-2xl my-4"> <div className="my-4 w-full max-w-2xl rounded-xl bg-dark-800">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700"> <div className="flex items-center justify-between border-b border-dark-700 p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-accent-500/20 rounded-lg text-accent-400"> <div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
<BroadcastIcon /> <BroadcastIcon />
</div> </div>
<h2 className="text-lg font-semibold text-dark-100">{t('admin.broadcasts.create')}</h2> <h2 className="text-lg font-semibold text-dark-100">{t('admin.broadcasts.create')}</h2>
</div> </div>
<button onClick={onClose} className="p-2 hover:bg-dark-700 rounded-lg transition-colors"> <button onClick={onClose} className="rounded-lg p-2 transition-colors hover:bg-dark-700">
<XIcon /> <XIcon />
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="p-4 space-y-4 max-h-[70vh] overflow-y-auto"> <div className="max-h-[70vh] space-y-4 overflow-y-auto p-4">
{/* Filter selection */} {/* Filter selection */}
<div> <div>
<label className="block text-sm font-medium text-dark-300 mb-2"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.selectFilter')} {t('admin.broadcasts.selectFilter')}
</label> </label>
<div className="relative"> <div className="relative">
<button <button
onClick={() => setShowFilters(!showFilters)} onClick={() => setShowFilters(!showFilters)}
className="w-full p-3 bg-dark-700 rounded-lg text-left flex items-center justify-between hover:bg-dark-600 transition-colors" className="flex w-full items-center justify-between rounded-lg bg-dark-700 p-3 text-left transition-colors hover:bg-dark-600"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<UsersIcon /> <UsersIcon />
<span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}> <span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}>
{selectedFilter ? selectedFilter.label : t('admin.broadcasts.selectFilterPlaceholder')} {selectedFilter
? selectedFilter.label
: t('admin.broadcasts.selectFilterPlaceholder')}
</span> </span>
{recipientsCount !== null && ( {recipientsCount !== null && (
<span className="px-2 py-0.5 bg-accent-500/20 text-accent-400 rounded-full text-xs"> <span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
{recipientsCount} {t('admin.broadcasts.recipients')} {recipientsCount} {t('admin.broadcasts.recipients')}
</span> </span>
)} )}
@@ -308,24 +344,24 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
</button> </button>
{showFilters && ( {showFilters && (
<div className="absolute top-full left-0 right-0 mt-1 bg-dark-700 rounded-lg shadow-xl z-10 max-h-64 overflow-y-auto"> <div className="absolute left-0 right-0 top-full z-10 mt-1 max-h-64 overflow-y-auto rounded-lg bg-dark-700 shadow-xl">
{filtersLoading ? ( {filtersLoading ? (
<div className="p-4 text-center text-dark-400">Loading...</div> <div className="p-4 text-center text-dark-400">Loading...</div>
) : ( ) : (
Object.entries(groupedFilters).map(([group, filters]) => ( Object.entries(groupedFilters).map(([group, filters]) => (
<div key={group}> <div key={group}>
<div className="px-3 py-2 text-xs font-medium text-dark-400 bg-dark-800 sticky top-0"> <div className="sticky top-0 bg-dark-800 px-3 py-2 text-xs font-medium text-dark-400">
{FILTER_GROUP_LABELS[group] || group} {FILTER_GROUP_LABELS[group] || group}
</div> </div>
{filters.map(filter => ( {filters.map((filter) => (
<button <button
key={filter.key} key={filter.key}
onClick={() => { onClick={() => {
setTarget(filter.key) setTarget(filter.key);
setShowFilters(false) setShowFilters(false);
previewMutation.mutate(filter.key) previewMutation.mutate(filter.key);
}} }}
className={`w-full px-3 py-2 text-left hover:bg-dark-600 transition-colors flex items-center justify-between ${ className={`flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-dark-600 ${
target === filter.key ? 'bg-accent-500/20' : '' target === filter.key ? 'bg-accent-500/20' : ''
}`} }`}
> >
@@ -345,29 +381,27 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
{/* Message text */} {/* Message text */}
<div> <div>
<label className="block text-sm font-medium text-dark-300 mb-2"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.messageText')} {t('admin.broadcasts.messageText')}
</label> </label>
<textarea <textarea
value={messageText} value={messageText}
onChange={e => setMessageText(e.target.value)} onChange={(e) => setMessageText(e.target.value)}
placeholder={t('admin.broadcasts.messageTextPlaceholder')} placeholder={t('admin.broadcasts.messageTextPlaceholder')}
rows={5} rows={5}
maxLength={4000} maxLength={4000}
className="w-full p-3 bg-dark-700 rounded-lg text-dark-100 placeholder-dark-400 resize-none focus:outline-none focus:ring-2 focus:ring-accent-500" className="w-full resize-none rounded-lg bg-dark-700 p-3 text-dark-100 placeholder-dark-400 focus:outline-none focus:ring-2 focus:ring-accent-500"
/> />
<div className="text-xs text-dark-400 mt-1 text-right"> <div className="mt-1 text-right text-xs text-dark-400">{messageText.length}/4000</div>
{messageText.length}/4000
</div>
</div> </div>
{/* Media upload */} {/* Media upload */}
<div> <div>
<label className="block text-sm font-medium text-dark-300 mb-2"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.media')} {t('admin.broadcasts.media')}
</label> </label>
{mediaFile ? ( {mediaFile ? (
<div className="p-3 bg-dark-700 rounded-lg"> <div className="rounded-lg bg-dark-700 p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{mediaType === 'photo' && <PhotoIcon />} {mediaType === 'photo' && <PhotoIcon />}
@@ -382,7 +416,7 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
</div> </div>
<button <button
onClick={handleRemoveMedia} onClick={handleRemoveMedia}
className="p-2 hover:bg-dark-600 rounded-lg text-dark-400 hover:text-red-400" className="rounded-lg p-2 text-dark-400 hover:bg-dark-600 hover:text-red-400"
disabled={isUploading} disabled={isUploading}
> >
<XIcon /> <XIcon />
@@ -396,7 +430,9 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
/> />
)} )}
{isUploading && ( {isUploading && (
<div className="mt-2 text-sm text-accent-400">{t('admin.broadcasts.uploading')}</div> <div className="mt-2 text-sm text-accent-400">
{t('admin.broadcasts.uploading')}
</div>
)} )}
</div> </div>
) : ( ) : (
@@ -410,7 +446,7 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
/> />
<button <button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className="flex-1 p-3 bg-dark-700 rounded-lg text-dark-400 hover:bg-dark-600 hover:text-dark-100 transition-colors flex items-center justify-center gap-2" className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-dark-700 p-3 text-dark-400 transition-colors hover:bg-dark-600 hover:text-dark-100"
> >
<PhotoIcon /> <PhotoIcon />
<span>{t('admin.broadcasts.addMedia')}</span> <span>{t('admin.broadcasts.addMedia')}</span>
@@ -421,15 +457,15 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
{/* Buttons selection */} {/* Buttons selection */}
<div> <div>
<label className="block text-sm font-medium text-dark-300 mb-2"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.buttons')} {t('admin.broadcasts.buttons')}
</label> </label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{buttonsData?.buttons.map(button => ( {buttonsData?.buttons.map((button) => (
<button <button
key={button.key} key={button.key}
onClick={() => toggleButton(button.key)} onClick={() => toggleButton(button.key)}
className={`px-3 py-2 rounded-lg text-sm transition-colors ${ className={`rounded-lg px-3 py-2 text-sm transition-colors ${
selectedButtons.includes(button.key) selectedButtons.includes(button.key)
? 'bg-accent-500 text-white' ? 'bg-accent-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600' : 'bg-dark-700 text-dark-300 hover:bg-dark-600'
@@ -443,76 +479,74 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-dark-700"> <div className="flex items-center justify-between border-t border-dark-700 p-4">
<div className="text-sm text-dark-400"> <div className="text-sm text-dark-400">
{recipientsCount !== null && ( {recipientsCount !== null && (
<span> <span>
{t('admin.broadcasts.willBeSent')}: <strong className="text-accent-400">{recipientsCount}</strong> {t('admin.broadcasts.users')} {t('admin.broadcasts.willBeSent')}:{' '}
<strong className="text-accent-400">{recipientsCount}</strong>{' '}
{t('admin.broadcasts.users')}
</span> </span>
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={onClose} onClick={onClose}
className="px-4 py-2 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 transition-colors" className="rounded-lg bg-dark-700 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-600"
> >
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!target || !messageText.trim() || createMutation.isPending || isUploading} disabled={!target || !messageText.trim() || createMutation.isPending || isUploading}
className="px-4 py-2 bg-accent-500 rounded-lg text-white hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
> >
{createMutation.isPending ? ( {createMutation.isPending ? <RefreshIcon /> : <BroadcastIcon />}
<RefreshIcon />
) : (
<BroadcastIcon />
)}
{t('admin.broadcasts.send')} {t('admin.broadcasts.send')}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
) );
} }
// Broadcast detail modal // Broadcast detail modal
interface DetailModalProps { interface DetailModalProps {
broadcast: Broadcast broadcast: Broadcast;
onClose: () => void onClose: () => void;
onStop: () => void onStop: () => void;
isStopping: boolean isStopping: boolean;
} }
function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: DetailModalProps) { function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: DetailModalProps) {
const { t } = useTranslation() const { t } = useTranslation();
const isRunning = ['queued', 'in_progress', 'cancelling'].includes(broadcast.status) const isRunning = ['queued', 'in_progress', 'cancelling'].includes(broadcast.status);
return ( return (
<div className="fixed inset-0 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-lg"> <div className="w-full max-w-lg rounded-xl bg-dark-800">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700"> <div className="flex items-center justify-between border-b border-dark-700 p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<StatusBadge status={broadcast.status} /> <StatusBadge status={broadcast.status} />
<span className="text-dark-400">#{broadcast.id}</span> <span className="text-dark-400">#{broadcast.id}</span>
</div> </div>
<button onClick={onClose} className="p-2 hover:bg-dark-700 rounded-lg transition-colors"> <button onClick={onClose} className="rounded-lg p-2 transition-colors hover:bg-dark-700">
<XIcon /> <XIcon />
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="p-4 space-y-4"> <div className="space-y-4 p-4">
{/* Progress */} {/* Progress */}
{isRunning && ( {isRunning && (
<div> <div>
<div className="flex justify-between text-sm mb-1"> <div className="mb-1 flex justify-between text-sm">
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span> <span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
<span className="text-dark-100">{broadcast.progress_percent.toFixed(1)}%</span> <span className="text-dark-100">{broadcast.progress_percent.toFixed(1)}%</span>
</div> </div>
<div className="h-2 bg-dark-700 rounded-full overflow-hidden"> <div className="h-2 overflow-hidden rounded-full bg-dark-700">
<div <div
className="h-full bg-accent-500 transition-all duration-300" className="h-full bg-accent-500 transition-all duration-300"
style={{ width: `${broadcast.progress_percent}%` }} style={{ width: `${broadcast.progress_percent}%` }}
@@ -523,15 +557,15 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="p-3 bg-dark-700 rounded-lg text-center"> <div className="rounded-lg bg-dark-700 p-3 text-center">
<p className="text-2xl font-bold text-dark-100">{broadcast.total_count}</p> <p className="text-2xl font-bold text-dark-100">{broadcast.total_count}</p>
<p className="text-xs text-dark-400">{t('admin.broadcasts.total')}</p> <p className="text-xs text-dark-400">{t('admin.broadcasts.total')}</p>
</div> </div>
<div className="p-3 bg-dark-700 rounded-lg text-center"> <div className="rounded-lg bg-dark-700 p-3 text-center">
<p className="text-2xl font-bold text-green-400">{broadcast.sent_count}</p> <p className="text-2xl font-bold text-green-400">{broadcast.sent_count}</p>
<p className="text-xs text-dark-400">{t('admin.broadcasts.sent')}</p> <p className="text-xs text-dark-400">{t('admin.broadcasts.sent')}</p>
</div> </div>
<div className="p-3 bg-dark-700 rounded-lg text-center"> <div className="rounded-lg bg-dark-700 p-3 text-center">
<p className="text-2xl font-bold text-red-400">{broadcast.failed_count}</p> <p className="text-2xl font-bold text-red-400">{broadcast.failed_count}</p>
<p className="text-xs text-dark-400">{t('admin.broadcasts.failed')}</p> <p className="text-xs text-dark-400">{t('admin.broadcasts.failed')}</p>
</div> </div>
@@ -539,14 +573,14 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
{/* Target */} {/* Target */}
<div> <div>
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.filter')}</p> <p className="mb-1 text-sm text-dark-400">{t('admin.broadcasts.filter')}</p>
<p className="text-dark-100">{broadcast.target_type}</p> <p className="text-dark-100">{broadcast.target_type}</p>
</div> </div>
{/* Message */} {/* Message */}
<div> <div>
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.message')}</p> <p className="mb-1 text-sm text-dark-400">{t('admin.broadcasts.message')}</p>
<div className="p-3 bg-dark-700 rounded-lg text-dark-100 whitespace-pre-wrap text-sm max-h-40 overflow-y-auto"> <div className="max-h-40 overflow-y-auto whitespace-pre-wrap rounded-lg bg-dark-700 p-3 text-sm text-dark-100">
{broadcast.message_text} {broadcast.message_text}
</div> </div>
</div> </div>
@@ -554,7 +588,7 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
{/* Media */} {/* Media */}
{broadcast.has_media && ( {broadcast.has_media && (
<div> <div>
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.media')}</p> <p className="mb-1 text-sm text-dark-400">{t('admin.broadcasts.media')}</p>
<div className="flex items-center gap-2 text-dark-100"> <div className="flex items-center gap-2 text-dark-100">
{broadcast.media_type === 'photo' && <PhotoIcon />} {broadcast.media_type === 'photo' && <PhotoIcon />}
{broadcast.media_type === 'video' && <VideoIcon />} {broadcast.media_type === 'video' && <VideoIcon />}
@@ -573,11 +607,11 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
{/* Footer */} {/* Footer */}
{isRunning && broadcast.status !== 'cancelling' && ( {isRunning && broadcast.status !== 'cancelling' && (
<div className="p-4 border-t border-dark-700"> <div className="border-t border-dark-700 p-4">
<button <button
onClick={onStop} onClick={onStop}
disabled={isStopping} disabled={isStopping}
className="w-full py-2 bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors flex items-center justify-center gap-2 disabled:opacity-50" className="flex w-full items-center justify-center gap-2 rounded-lg bg-red-500/20 py-2 text-red-400 transition-colors hover:bg-red-500/30 disabled:opacity-50"
> >
<StopIcon /> <StopIcon />
{t('admin.broadcasts.stop')} {t('admin.broadcasts.stop')}
@@ -586,48 +620,48 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
)} )}
</div> </div>
</div> </div>
) );
} }
// Main component // Main component
export default function AdminBroadcasts() { export default function AdminBroadcasts() {
const { t } = useTranslation() const { t } = useTranslation();
const queryClient = useQueryClient() const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false);
const [selectedBroadcast, setSelectedBroadcast] = useState<Broadcast | null>(null) const [selectedBroadcast, setSelectedBroadcast] = useState<Broadcast | null>(null);
const [page, setPage] = useState(0) const [page, setPage] = useState(0);
const limit = 20 const limit = 20;
// Fetch broadcasts // Fetch broadcasts
const { data, isLoading, refetch } = useQuery({ const { data, isLoading, refetch } = useQuery({
queryKey: ['admin', 'broadcasts', 'list', page], queryKey: ['admin', 'broadcasts', 'list', page],
queryFn: () => adminBroadcastsApi.list(limit, page * limit), queryFn: () => adminBroadcastsApi.list(limit, page * limit),
refetchInterval: 5000, // Auto refresh every 5s refetchInterval: 5000, // Auto refresh every 5s
}) });
// Stop mutation // Stop mutation
const stopMutation = useMutation({ const stopMutation = useMutation({
mutationFn: adminBroadcastsApi.stop, mutationFn: adminBroadcastsApi.stop,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] }) queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
setSelectedBroadcast(null) setSelectedBroadcast(null);
}, },
}) });
const broadcasts = data?.items || [] const broadcasts = data?.items || [];
const total = data?.total || 0 const total = data?.total || 0;
const totalPages = Math.ceil(total / limit) const totalPages = Math.ceil(total / limit);
return ( return (
<div className="min-h-screen bg-dark-900 p-4 pb-20 md:pb-4"> <div className="min-h-screen bg-dark-900 p-4 pb-20 md:pb-4">
<div className="max-w-4xl mx-auto"> <div className="mx-auto max-w-4xl">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link <Link
to="/admin" to="/admin"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors" 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 />
</Link> </Link>
@@ -639,13 +673,13 @@ export default function AdminBroadcasts() {
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => refetch()} onClick={() => refetch()}
className="p-2 bg-dark-800 rounded-lg text-dark-400 hover:text-dark-100 transition-colors" className="rounded-lg bg-dark-800 p-2 text-dark-400 transition-colors hover:text-dark-100"
> >
<RefreshIcon /> <RefreshIcon />
</button> </button>
<button <button
onClick={() => setShowCreateModal(true)} onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-accent-500 rounded-lg text-white hover:bg-accent-600 transition-colors flex items-center gap-2" className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
> >
<PlusIcon /> <PlusIcon />
<span className="hidden sm:inline">{t('admin.broadcasts.create')}</span> <span className="hidden sm:inline">{t('admin.broadcasts.create')}</span>
@@ -654,7 +688,7 @@ export default function AdminBroadcasts() {
</div> </div>
{/* Broadcasts list */} {/* Broadcasts list */}
<div className="bg-dark-800 rounded-xl overflow-hidden"> <div className="overflow-hidden rounded-xl bg-dark-800">
{isLoading ? ( {isLoading ? (
<div className="p-8 text-center text-dark-400"> <div className="p-8 text-center text-dark-400">
<RefreshIcon /> <RefreshIcon />
@@ -667,15 +701,15 @@ export default function AdminBroadcasts() {
</div> </div>
) : ( ) : (
<div className="divide-y divide-dark-700"> <div className="divide-y divide-dark-700">
{broadcasts.map(broadcast => ( {broadcasts.map((broadcast) => (
<button <button
key={broadcast.id} key={broadcast.id}
onClick={() => setSelectedBroadcast(broadcast)} onClick={() => setSelectedBroadcast(broadcast)}
className="w-full p-4 hover:bg-dark-700/50 transition-colors text-left" className="w-full p-4 text-left transition-colors hover:bg-dark-700/50"
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="mb-1 flex items-center gap-2">
<StatusBadge status={broadcast.status} /> <StatusBadge status={broadcast.status} />
<span className="text-xs text-dark-400">#{broadcast.id}</span> <span className="text-xs text-dark-400">#{broadcast.id}</span>
{broadcast.has_media && ( {broadcast.has_media && (
@@ -686,10 +720,8 @@ export default function AdminBroadcasts() {
</span> </span>
)} )}
</div> </div>
<p className="text-dark-100 text-sm truncate"> <p className="truncate text-sm text-dark-100">{broadcast.message_text}</p>
{broadcast.message_text} <div className="mt-2 flex items-center gap-4 text-xs text-dark-400">
</p>
<div className="flex items-center gap-4 mt-2 text-xs text-dark-400">
<span>{broadcast.target_type}</span> <span>{broadcast.target_type}</span>
<span> <span>
{broadcast.sent_count}/{broadcast.total_count} {broadcast.sent_count}/{broadcast.total_count}
@@ -699,13 +731,13 @@ export default function AdminBroadcasts() {
</div> </div>
{['queued', 'in_progress'].includes(broadcast.status) && ( {['queued', 'in_progress'].includes(broadcast.status) && (
<div className="w-16"> <div className="w-16">
<div className="h-1.5 bg-dark-600 rounded-full overflow-hidden"> <div className="h-1.5 overflow-hidden rounded-full bg-dark-600">
<div <div
className="h-full bg-accent-500" className="h-full bg-accent-500"
style={{ width: `${broadcast.progress_percent}%` }} style={{ width: `${broadcast.progress_percent}%` }}
/> />
</div> </div>
<p className="text-xs text-dark-400 text-center mt-1"> <p className="mt-1 text-center text-xs text-dark-400">
{broadcast.progress_percent.toFixed(0)}% {broadcast.progress_percent.toFixed(0)}%
</p> </p>
</div> </div>
@@ -718,11 +750,11 @@ export default function AdminBroadcasts() {
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-center gap-2 p-4 border-t border-dark-700"> <div className="flex items-center justify-center gap-2 border-t border-dark-700 p-4">
<button <button
onClick={() => setPage(p => Math.max(0, p - 1))} onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0} disabled={page === 0}
className="px-3 py-1 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed" className="rounded-lg bg-dark-700 px-3 py-1 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
> >
{t('common.prev')} {t('common.prev')}
</button> </button>
@@ -730,9 +762,9 @@ export default function AdminBroadcasts() {
{page + 1} / {totalPages} {page + 1} / {totalPages}
</span> </span>
<button <button
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1} disabled={page >= totalPages - 1}
className="px-3 py-1 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed" className="rounded-lg bg-dark-700 px-3 py-1 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
> >
{t('common.next')} {t('common.next')}
</button> </button>
@@ -746,7 +778,7 @@ export default function AdminBroadcasts() {
<CreateBroadcastModal <CreateBroadcastModal
onClose={() => setShowCreateModal(false)} onClose={() => setShowCreateModal(false)}
onSuccess={() => { onSuccess={() => {
refetch() refetch();
}} }}
/> />
)} )}
@@ -760,5 +792,5 @@ export default function AdminBroadcasts() {
/> />
)} )}
</div> </div>
) );
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,71 +1,93 @@
import { useState, useCallback, useRef, useEffect } from 'react' import { useState, useCallback, useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom';
import { import {
adminEmailTemplatesApi, adminEmailTemplatesApi,
EmailTemplateType, EmailTemplateType,
EmailTemplateDetail, EmailTemplateDetail,
EmailTemplateLanguageData, EmailTemplateLanguageData,
} from '../api/adminEmailTemplates' } from '../api/adminEmailTemplates';
// Icons // Icons
const BackIcon = () => ( const BackIcon = () => (
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg> </svg>
) );
const MailIcon = () => ( const MailIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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" /> <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> </svg>
) );
const SaveIcon = () => ( const SaveIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg> </svg>
) );
const EyeIcon = () => ( const EyeIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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="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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
) );
const SendIcon = () => ( const SendIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <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> </svg>
) );
const ResetIcon = () => ( const ResetIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <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> </svg>
) );
const XIcon = () => ( const XIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
) );
const LANG_LABELS: Record<string, string> = { const LANG_LABELS: Record<string, string> = {
ru: 'RU', ru: 'RU',
en: 'EN', en: 'EN',
zh: 'ZH', zh: 'ZH',
ua: 'UA', ua: 'UA',
} };
const LANG_FULL_LABELS: Record<string, string> = { const LANG_FULL_LABELS: Record<string, string> = {
ru: 'Русский', ru: 'Русский',
en: 'English', en: 'English',
zh: '中文', zh: '中文',
ua: 'Українська', ua: 'Українська',
} };
// ============ Template List View ============ // ============ Template List View ============
@@ -74,31 +96,31 @@ function TemplateCard({
currentLang, currentLang,
onClick, onClick,
}: { }: {
template: EmailTemplateType template: EmailTemplateType;
currentLang: string currentLang: string;
onClick: () => void onClick: () => void;
}) { }) {
const label = template.label[currentLang] || template.label['en'] || template.type const label = template.label[currentLang] || template.label['en'] || template.type;
const description = template.description[currentLang] || template.description['en'] || '' const description = template.description[currentLang] || template.description['en'] || '';
const customCount = Object.values(template.languages).filter(l => l.has_custom).length const customCount = Object.values(template.languages).filter((l) => l.has_custom).length;
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className="w-full text-left p-3 sm:p-4 bg-dark-800 rounded-xl border border-dark-700 hover:border-accent-500/50 transition-all duration-200 group" className="group w-full rounded-xl border border-dark-700 bg-dark-800 p-3 text-left transition-all duration-200 hover:border-accent-500/50 sm:p-4"
> >
<div className="flex items-start justify-between gap-2 sm:gap-3"> <div className="flex items-start justify-between gap-2 sm:gap-3">
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<h3 className="text-sm font-medium text-dark-100 group-hover:text-accent-400 transition-colors truncate"> <h3 className="truncate text-sm font-medium text-dark-100 transition-colors group-hover:text-accent-400">
{label} {label}
</h3> </h3>
<p className="text-xs text-dark-400 mt-1 line-clamp-2">{description}</p> <p className="mt-1 line-clamp-2 text-xs text-dark-400">{description}</p>
</div> </div>
<div className="flex items-center gap-1 sm:gap-1.5 flex-shrink-0 mt-0.5"> <div className="mt-0.5 flex flex-shrink-0 items-center gap-1 sm:gap-1.5">
{Object.entries(template.languages).map(([lang, status]) => ( {Object.entries(template.languages).map(([lang, status]) => (
<span <span
key={lang} key={lang}
className={`inline-flex items-center justify-center w-6 sm:w-7 h-5 rounded text-2xs font-medium ${ className={`inline-flex h-5 w-6 items-center justify-center rounded text-2xs font-medium sm:w-7 ${
status.has_custom status.has_custom
? 'bg-accent-500/20 text-accent-400 ring-1 ring-accent-500/30' ? 'bg-accent-500/20 text-accent-400 ring-1 ring-accent-500/30'
: 'bg-dark-700 text-dark-400' : 'bg-dark-700 text-dark-400'
@@ -112,14 +134,14 @@ function TemplateCard({
</div> </div>
{customCount > 0 && ( {customCount > 0 && (
<div className="mt-2"> <div className="mt-2">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-2xs bg-accent-500/10 text-accent-400"> <span className="inline-flex items-center gap-1 rounded-full bg-accent-500/10 px-2 py-0.5 text-2xs text-accent-400">
<span className="w-1.5 h-1.5 rounded-full bg-accent-400" /> <span className="h-1.5 w-1.5 rounded-full bg-accent-400" />
{customCount} custom {customCount} custom
</span> </span>
</div> </div>
)} )}
</button> </button>
) );
} }
// ============ Template Editor ============ // ============ Template Editor ============
@@ -129,225 +151,249 @@ function TemplateEditor({
onClose, onClose,
currentLang: interfaceLang, currentLang: interfaceLang,
}: { }: {
detail: EmailTemplateDetail detail: EmailTemplateDetail;
onClose: () => void onClose: () => void;
currentLang: string currentLang: string;
}) { }) {
const { t } = useTranslation() const { t } = useTranslation();
const queryClient = useQueryClient() const queryClient = useQueryClient();
const [activeLang, setActiveLang] = useState('ru') const [activeLang, setActiveLang] = useState('ru');
const [editSubject, setEditSubject] = useState('') const [editSubject, setEditSubject] = useState('');
const [editBody, setEditBody] = useState('') const [editBody, setEditBody] = useState('');
const [isDirty, setIsDirty] = useState(false) const [isDirty, setIsDirty] = useState(false);
const [showPreview, setShowPreview] = useState(false) const [showPreview, setShowPreview] = useState(false);
const [previewHtml, setPreviewHtml] = useState('') const [previewHtml, setPreviewHtml] = useState('');
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null) const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null);
const iframeRef = useRef<HTMLIFrameElement>(null) const iframeRef = useRef<HTMLIFrameElement>(null);
const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang] const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang];
// Load data for current language // Load data for current language
useEffect(() => { useEffect(() => {
if (langData) { if (langData) {
setEditSubject(langData.subject) setEditSubject(langData.subject);
setEditBody(langData.is_default ? langData.body_html : langData.body_html) setEditBody(langData.is_default ? langData.body_html : langData.body_html);
setIsDirty(false) setIsDirty(false);
} }
}, [activeLang, langData]) }, [activeLang, langData]);
const showToast = useCallback((type: 'success' | 'error', message: string) => { const showToast = useCallback((type: 'success' | 'error', message: string) => {
setToast({ type, message }) setToast({ type, message });
setTimeout(() => setToast(null), 3000) setTimeout(() => setToast(null), 3000);
}, []) }, []);
// Extract body content from full HTML (strip base template wrapper) // Extract body content from full HTML (strip base template wrapper)
const extractBodyContent = useCallback((html: string): string => { const extractBodyContent = useCallback((html: string): string => {
// If it's wrapped in the base template, extract just the content div // If it's wrapped in the base template, extract just the content div
const contentMatch = html.match(/<div class="content">\s*([\s\S]*?)\s*<\/div>\s*<div class="footer">/) const contentMatch = html.match(
/<div class="content">\s*([\s\S]*?)\s*<\/div>\s*<div class="footer">/,
);
if (contentMatch) { if (contentMatch) {
return contentMatch[1].trim() return contentMatch[1].trim();
} }
return html return html;
}, []) }, []);
// When langData changes (e.g., after refetch), update the body content // When langData changes (e.g., after refetch), update the body content
useEffect(() => { useEffect(() => {
if (langData) { if (langData) {
if (langData.is_default) { if (langData.is_default) {
// For default templates, extract just the content portion // For default templates, extract just the content portion
setEditBody(extractBodyContent(langData.body_html)) setEditBody(extractBodyContent(langData.body_html));
} else { } else {
setEditBody(langData.body_html) setEditBody(langData.body_html);
} }
setEditSubject(langData.subject) setEditSubject(langData.subject);
setIsDirty(false) setIsDirty(false);
} }
}, [activeLang, langData, extractBodyContent]) }, [activeLang, langData, extractBodyContent]);
// Save mutation // Save mutation
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: () => adminEmailTemplatesApi.updateTemplate(detail.notification_type, activeLang, { mutationFn: () =>
adminEmailTemplatesApi.updateTemplate(detail.notification_type, activeLang, {
subject: editSubject, subject: editSubject,
body_html: editBody, body_html: editBody,
}), }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] }) queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] });
queryClient.invalidateQueries({ queryKey: ['admin', 'email-template', detail.notification_type] }) queryClient.invalidateQueries({
setIsDirty(false) queryKey: ['admin', 'email-template', detail.notification_type],
showToast('success', t('admin.emailTemplates.saved', 'Template saved')) });
setIsDirty(false);
showToast('success', t('admin.emailTemplates.saved', 'Template saved'));
}, },
onError: () => { onError: () => {
showToast('error', t('common.error', 'Error')) showToast('error', t('common.error', 'Error'));
}, },
}) });
// Reset mutation // Reset mutation
const resetMutation = useMutation({ const resetMutation = useMutation({
mutationFn: () => adminEmailTemplatesApi.deleteTemplate(detail.notification_type, activeLang), mutationFn: () => adminEmailTemplatesApi.deleteTemplate(detail.notification_type, activeLang),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] }) queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] });
queryClient.invalidateQueries({ queryKey: ['admin', 'email-template', detail.notification_type] }) queryClient.invalidateQueries({
setIsDirty(false) queryKey: ['admin', 'email-template', detail.notification_type],
showToast('success', t('admin.emailTemplates.resetted', 'Template reset to default')) });
setIsDirty(false);
showToast('success', t('admin.emailTemplates.resetted', 'Template reset to default'));
}, },
onError: () => { onError: () => {
showToast('error', t('common.error', 'Error')) showToast('error', t('common.error', 'Error'));
}, },
}) });
// Preview mutation // Preview mutation
const previewMutation = useMutation({ const previewMutation = useMutation({
mutationFn: () => adminEmailTemplatesApi.previewTemplate(detail.notification_type, { mutationFn: () =>
adminEmailTemplatesApi.previewTemplate(detail.notification_type, {
language: activeLang, language: activeLang,
subject: editSubject, subject: editSubject,
body_html: editBody, body_html: editBody,
}), }),
onSuccess: (data) => { onSuccess: (data) => {
setPreviewHtml(data.body_html) setPreviewHtml(data.body_html);
setShowPreview(true) setShowPreview(true);
}, },
onError: () => { onError: () => {
showToast('error', t('common.error', 'Error')) showToast('error', t('common.error', 'Error'));
}, },
}) });
// Send test mutation // Send test mutation
const testMutation = useMutation({ const testMutation = useMutation({
mutationFn: () => adminEmailTemplatesApi.sendTestEmail(detail.notification_type, { mutationFn: () =>
adminEmailTemplatesApi.sendTestEmail(detail.notification_type, {
language: activeLang, language: activeLang,
}), }),
onSuccess: (data) => { onSuccess: (data) => {
showToast('success', `${t('admin.emailTemplates.testSent', 'Test email sent')}${data.sent_to}`) showToast(
'success',
`${t('admin.emailTemplates.testSent', 'Test email sent')}${data.sent_to}`,
);
}, },
onError: () => { onError: () => {
showToast('error', t('common.error', 'Error')) showToast('error', t('common.error', 'Error'));
}, },
}) });
// Write preview HTML into iframe // Write preview HTML into iframe
useEffect(() => { useEffect(() => {
if (showPreview && iframeRef.current && previewHtml) { if (showPreview && iframeRef.current && previewHtml) {
const doc = iframeRef.current.contentDocument const doc = iframeRef.current.contentDocument;
if (doc) { if (doc) {
doc.open() doc.open();
doc.write(previewHtml) doc.write(previewHtml);
doc.close() doc.close();
} }
} }
}, [showPreview, previewHtml]) }, [showPreview, previewHtml]);
const handleSubjectChange = (value: string) => { const handleSubjectChange = (value: string) => {
setEditSubject(value) setEditSubject(value);
setIsDirty(true) setIsDirty(true);
} };
const handleBodyChange = (value: string) => { const handleBodyChange = (value: string) => {
setEditBody(value) setEditBody(value);
setIsDirty(true) setIsDirty(true);
} };
const label = detail.label[interfaceLang] || detail.label['en'] || detail.notification_type const label = detail.label[interfaceLang] || detail.label['en'] || detail.notification_type;
return ( return (
<div className="space-y-3 sm:space-y-4"> <div className="space-y-3 sm:space-y-4">
{/* Header */} {/* Header */}
<div className="flex items-start sm:items-center justify-between gap-2"> <div className="flex items-start justify-between gap-2 sm:items-center">
<div className="flex items-start sm:items-center gap-2 sm:gap-3 min-w-0"> <div className="flex min-w-0 items-start gap-2 sm:items-center sm:gap-3">
<button onClick={onClose} className="p-1 rounded-lg hover:bg-dark-700 transition-colors flex-shrink-0 mt-0.5 sm:mt-0"> <button
onClick={onClose}
className="mt-0.5 flex-shrink-0 rounded-lg p-1 transition-colors hover:bg-dark-700 sm:mt-0"
>
<BackIcon /> <BackIcon />
</button> </button>
<div className="min-w-0"> <div className="min-w-0">
<h2 className="text-base sm:text-lg font-semibold text-dark-100 truncate">{label}</h2> <h2 className="truncate text-base font-semibold text-dark-100 sm:text-lg">{label}</h2>
<p className="text-xs text-dark-400 line-clamp-2"> <p className="line-clamp-2 text-xs text-dark-400">
{detail.description[interfaceLang] || detail.description['en'] || ''} {detail.description[interfaceLang] || detail.description['en'] || ''}
</p> </p>
</div> </div>
</div> </div>
{langData && !langData.is_default && ( {langData && !langData.is_default && (
<span className="px-2 sm:px-2.5 py-1 rounded-full text-2xs sm:text-xs font-medium bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/25 flex-shrink-0"> <span className="flex-shrink-0 rounded-full bg-accent-500/15 px-2 py-1 text-2xs font-medium text-accent-400 ring-1 ring-accent-500/25 sm:px-2.5 sm:text-xs">
Custom Custom
</span> </span>
)} )}
</div> </div>
{/* Language tabs */} {/* Language tabs */}
<div className="flex items-center gap-1 p-1 bg-dark-900 rounded-lg overflow-x-auto"> <div className="flex items-center gap-1 overflow-x-auto rounded-lg bg-dark-900 p-1">
{Object.keys(detail.languages).map(lang => { {Object.keys(detail.languages).map((lang) => {
const isActive = lang === activeLang const isActive = lang === activeLang;
const langInfo = detail.languages[lang] const langInfo = detail.languages[lang];
return ( return (
<button <button
key={lang} key={lang}
onClick={() => { onClick={() => {
if (isDirty && !window.confirm(t('admin.emailTemplates.unsavedWarning', 'Unsaved changes will be lost. Continue?'))) return if (
setActiveLang(lang) isDirty &&
!window.confirm(
t(
'admin.emailTemplates.unsavedWarning',
'Unsaved changes will be lost. Continue?',
),
)
)
return;
setActiveLang(lang);
}} }}
className={`flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-all duration-150 flex items-center justify-center gap-1 sm:gap-1.5 whitespace-nowrap ${ className={`flex flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md px-2 py-2 text-xs font-medium transition-all duration-150 sm:gap-1.5 sm:px-3 sm:text-sm ${
isActive isActive
? 'bg-dark-700 text-dark-100 shadow-sm' ? 'bg-dark-700 text-dark-100 shadow-sm'
: 'text-dark-400 hover:text-dark-200 hover:bg-dark-800' : 'text-dark-400 hover:bg-dark-800 hover:text-dark-200'
}`} }`}
> >
<span className="sm:hidden">{LANG_LABELS[lang] || lang}</span> <span className="sm:hidden">{LANG_LABELS[lang] || lang}</span>
<span className="hidden sm:inline">{LANG_FULL_LABELS[lang] || lang}</span> <span className="hidden sm:inline">{LANG_FULL_LABELS[lang] || lang}</span>
{!langInfo.is_default && ( {!langInfo.is_default && (
<span className="w-1.5 h-1.5 rounded-full bg-accent-400 flex-shrink-0" /> <span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent-400" />
)} )}
</button> </button>
) );
})} })}
</div> </div>
{/* Subject */} {/* Subject */}
<div> <div>
<label className="block text-xs font-medium text-dark-300 mb-1.5"> <label className="mb-1.5 block text-xs font-medium text-dark-300">
{t('admin.emailTemplates.subject', 'Subject')} {t('admin.emailTemplates.subject', 'Subject')}
</label> </label>
<input <input
type="text" type="text"
value={editSubject} value={editSubject}
onChange={e => handleSubjectChange(e.target.value)} onChange={(e) => handleSubjectChange(e.target.value)}
className="w-full px-3 py-2.5 bg-dark-900 border border-dark-600 rounded-lg text-sm text-dark-100 placeholder-dark-500 focus:outline-none focus:ring-1 focus:ring-accent-500 focus:border-accent-500 transition-colors" className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2.5 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500"
placeholder={t('admin.emailTemplates.subjectPlaceholder', 'Email subject line...')} placeholder={t('admin.emailTemplates.subjectPlaceholder', 'Email subject line...')}
/> />
</div> </div>
{/* Context variables hint */} {/* Context variables hint */}
{detail.context_vars.length > 0 && ( {detail.context_vars.length > 0 && (
<div className="p-2.5 sm:p-3 bg-dark-900/60 border border-dark-700 rounded-lg"> <div className="rounded-lg border border-dark-700 bg-dark-900/60 p-2.5 sm:p-3">
<p className="text-xs font-medium text-dark-300 mb-1.5"> <p className="mb-1.5 text-xs font-medium text-dark-300">
{t('admin.emailTemplates.variables', 'Available Variables')} {t('admin.emailTemplates.variables', 'Available Variables')}
</p> </p>
<div className="flex flex-wrap gap-1 sm:gap-1.5"> <div className="flex flex-wrap gap-1 sm:gap-1.5">
{detail.context_vars.map(v => ( {detail.context_vars.map((v) => (
<code <code
key={v} key={v}
className="px-2 py-0.5 rounded bg-dark-700 text-accent-400 text-xs font-mono cursor-pointer hover:bg-dark-600 transition-colors" className="cursor-pointer rounded bg-dark-700 px-2 py-0.5 font-mono text-xs text-accent-400 transition-colors hover:bg-dark-600"
title={t('admin.emailTemplates.clickToCopy', 'Click to copy')} title={t('admin.emailTemplates.clickToCopy', 'Click to copy')}
onClick={() => { onClick={() => {
navigator.clipboard.writeText(`{${v}}`) navigator.clipboard.writeText(`{${v}}`);
showToast('success', `Copied {${v}}`) showToast('success', `Copied {${v}}`);
}} }}
> >
{`{${v}}`} {`{${v}}`}
@@ -359,30 +405,33 @@ function TemplateEditor({
{/* Body HTML editor */} {/* Body HTML editor */}
<div> <div>
<label className="block text-xs font-medium text-dark-300 mb-1.5"> <label className="mb-1.5 block text-xs font-medium text-dark-300">
{t('admin.emailTemplates.body', 'Body (HTML)')} {t('admin.emailTemplates.body', 'Body (HTML)')}
</label> </label>
<textarea <textarea
ref={textareaRef} ref={textareaRef}
value={editBody} value={editBody}
onChange={e => handleBodyChange(e.target.value)} onChange={(e) => handleBodyChange(e.target.value)}
rows={12} rows={12}
className="w-full px-3 py-2.5 bg-dark-900 border border-dark-600 rounded-lg text-xs sm:text-sm text-dark-100 placeholder-dark-500 font-mono leading-relaxed focus:outline-none focus:ring-1 focus:ring-accent-500 focus:border-accent-500 transition-colors resize-y min-h-[200px] sm:min-h-[300px]" className="min-h-[200px] w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2.5 font-mono text-xs leading-relaxed text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 sm:min-h-[300px] sm:text-sm"
placeholder="<h2>Title</h2><p>Content...</p>" placeholder="<h2>Title</h2><p>Content...</p>"
spellCheck={false} spellCheck={false}
/> />
<p className="text-2xs text-dark-500 mt-1"> <p className="mt-1 text-2xs text-dark-500">
{t('admin.emailTemplates.bodyHint', 'HTML content that will be wrapped in the base email template with header and footer.')} {t(
'admin.emailTemplates.bodyHint',
'HTML content that will be wrapped in the base email template with header and footer.',
)}
</p> </p>
</div> </div>
{/* Actions */} {/* Actions */}
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2"> <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<div className="grid grid-cols-2 sm:flex gap-2"> <div className="grid grid-cols-2 gap-2 sm:flex">
<button <button
onClick={() => saveMutation.mutate()} onClick={() => saveMutation.mutate()}
disabled={!isDirty || saveMutation.isPending} disabled={!isDirty || saveMutation.isPending}
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors" 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 />
{saveMutation.isPending ? t('common.loading', 'Loading...') : t('common.save', 'Save')} {saveMutation.isPending ? t('common.loading', 'Loading...') : t('common.save', 'Save')}
@@ -391,35 +440,46 @@ function TemplateEditor({
<button <button
onClick={() => previewMutation.mutate()} onClick={() => previewMutation.mutate()}
disabled={previewMutation.isPending} disabled={previewMutation.isPending}
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-dark-700 text-dark-200 hover:bg-dark-600 disabled:opacity-40 transition-colors" 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"
> >
<EyeIcon /> <EyeIcon />
{t('admin.emailTemplates.preview', 'Preview')} {t('admin.emailTemplates.preview', 'Preview')}
</button> </button>
</div> </div>
<div className="grid grid-cols-2 sm:flex gap-2"> <div className="grid grid-cols-2 gap-2 sm:flex">
<button <button
onClick={() => testMutation.mutate()} onClick={() => testMutation.mutate()}
disabled={testMutation.isPending} disabled={testMutation.isPending}
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-dark-700 text-dark-200 hover:bg-dark-600 disabled:opacity-40 transition-colors" 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 />
{testMutation.isPending ? t('common.loading', 'Loading...') : t('admin.emailTemplates.sendTest', 'Send Test')} {testMutation.isPending
? t('common.loading', 'Loading...')
: t('admin.emailTemplates.sendTest', 'Send Test')}
</button> </button>
{langData && !langData.is_default && ( {langData && !langData.is_default && (
<button <button
onClick={() => { onClick={() => {
if (window.confirm(t('admin.emailTemplates.resetConfirm', 'Reset this template to the default version?'))) { if (
resetMutation.mutate() window.confirm(
t(
'admin.emailTemplates.resetConfirm',
'Reset this template to the default version?',
),
)
) {
resetMutation.mutate();
} }
}} }}
disabled={resetMutation.isPending} disabled={resetMutation.isPending}
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-dark-700 text-warning-400 hover:bg-dark-600 disabled:opacity-40 transition-colors sm:ml-auto" 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 />
<span className="truncate">{t('admin.emailTemplates.resetDefault', 'Reset to Default')}</span> <span className="truncate">
{t('admin.emailTemplates.resetDefault', 'Reset to Default')}
</span>
</button> </button>
)} )}
</div> </div>
@@ -427,26 +487,26 @@ function TemplateEditor({
{/* Toast */} {/* Toast */}
{toast && ( {toast && (
<div className={`fixed bottom-4 left-4 right-4 sm:left-auto sm:right-6 sm:bottom-6 z-50 px-4 py-3 rounded-xl text-sm font-medium shadow-lg animate-fade-in text-center sm:text-left ${ <div
toast.type === 'success' className={`fixed bottom-4 left-4 right-4 z-50 animate-fade-in rounded-xl px-4 py-3 text-center text-sm font-medium shadow-lg sm:bottom-6 sm:left-auto sm:right-6 sm:text-left ${
? 'bg-emerald-500/90 text-white' toast.type === 'success' ? 'bg-emerald-500/90 text-white' : 'bg-red-500/90 text-white'
: 'bg-red-500/90 text-white' }`}
}`}> >
{toast.message} {toast.message}
</div> </div>
)} )}
{/* Preview Modal */} {/* Preview Modal */}
{showPreview && ( {showPreview && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center sm:p-4 bg-black/60 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-sm sm:items-center sm:p-4">
<div className="bg-dark-800 rounded-t-2xl sm:rounded-2xl w-full sm:max-w-2xl max-h-[90vh] sm:max-h-[85vh] flex flex-col border border-dark-600 shadow-2xl"> <div className="flex max-h-[90vh] w-full flex-col rounded-t-2xl border border-dark-600 bg-dark-800 shadow-2xl sm:max-h-[85vh] sm:max-w-2xl sm:rounded-2xl">
<div className="flex items-center justify-between px-4 sm:px-5 py-3 sm:py-4 border-b border-dark-700"> <div className="flex items-center justify-between border-b border-dark-700 px-4 py-3 sm:px-5 sm:py-4">
<h3 className="text-base font-semibold text-dark-100"> <h3 className="text-base font-semibold text-dark-100">
{t('admin.emailTemplates.preview', 'Preview')} {t('admin.emailTemplates.preview', 'Preview')}
</h3> </h3>
<button <button
onClick={() => setShowPreview(false)} onClick={() => setShowPreview(false)}
className="p-1.5 rounded-lg hover:bg-dark-700 transition-colors" className="rounded-lg p-1.5 transition-colors hover:bg-dark-700"
> >
<XIcon /> <XIcon />
</button> </button>
@@ -454,7 +514,7 @@ function TemplateEditor({
<div className="flex-1 overflow-hidden p-1"> <div className="flex-1 overflow-hidden p-1">
<iframe <iframe
ref={iframeRef} ref={iframeRef}
className="w-full h-full min-h-[50vh] sm:min-h-[400px] rounded-lg bg-white" className="h-full min-h-[50vh] w-full rounded-lg bg-white sm:min-h-[400px]"
sandbox="allow-same-origin" sandbox="allow-same-origin"
title="Email Preview" title="Email Preview"
/> />
@@ -463,48 +523,48 @@ function TemplateEditor({
</div> </div>
)} )}
</div> </div>
) );
} }
// ============ Main Page ============ // ============ Main Page ============
export default function AdminEmailTemplates() { export default function AdminEmailTemplates() {
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation();
const currentLang = i18n.language || 'ru' const currentLang = i18n.language || 'ru';
const [selectedType, setSelectedType] = useState<string | null>(null) const [selectedType, setSelectedType] = useState<string | null>(null);
// Fetch template types list // Fetch template types list
const { data: typesData, isLoading: typesLoading } = useQuery({ const { data: typesData, isLoading: typesLoading } = useQuery({
queryKey: ['admin', 'email-templates'], queryKey: ['admin', 'email-templates'],
queryFn: adminEmailTemplatesApi.getTemplateTypes, queryFn: adminEmailTemplatesApi.getTemplateTypes,
}) });
// Fetch detail for selected type // Fetch detail for selected type
const { data: detailData, isLoading: detailLoading } = useQuery({ const { data: detailData, isLoading: detailLoading } = useQuery({
queryKey: ['admin', 'email-template', selectedType], queryKey: ['admin', 'email-template', selectedType],
queryFn: () => adminEmailTemplatesApi.getTemplate(selectedType!), queryFn: () => adminEmailTemplatesApi.getTemplate(selectedType!),
enabled: !!selectedType, enabled: !!selectedType,
}) });
return ( return (
<div className="max-w-4xl mx-auto px-3 sm:px-4 py-4 sm:py-6 space-y-4 sm:space-y-6"> <div className="mx-auto max-w-4xl space-y-4 px-3 py-4 sm:space-y-6 sm:px-4 sm:py-6">
{/* Page Header */} {/* Page Header */}
<div className="flex items-center gap-2 sm:gap-3"> <div className="flex items-center gap-2 sm:gap-3">
<Link <Link
to="/admin" to="/admin"
className="p-1.5 sm:p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors border border-dark-700 flex-shrink-0" 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"
> >
<BackIcon /> <BackIcon />
</Link> </Link>
<div className="flex items-center gap-2 sm:gap-2.5 min-w-0"> <div className="flex min-w-0 items-center gap-2 sm:gap-2.5">
<div className="p-1.5 sm:p-2 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 text-blue-400 flex-shrink-0"> <div className="flex-shrink-0 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 p-1.5 text-blue-400 sm:p-2">
<MailIcon /> <MailIcon />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h1 className="text-lg sm:text-xl font-bold text-dark-100 truncate"> <h1 className="truncate text-lg font-bold text-dark-100 sm:text-xl">
{t('admin.emailTemplates.title', 'Email Templates')} {t('admin.emailTemplates.title', 'Email Templates')}
</h1> </h1>
<p className="text-xs text-dark-400 truncate"> <p className="truncate text-xs text-dark-400">
{t('admin.emailTemplates.description', 'Manage email notification templates')} {t('admin.emailTemplates.description', 'Manage email notification templates')}
</p> </p>
</div> </div>
@@ -524,12 +584,12 @@ export default function AdminEmailTemplates() {
{typesLoading ? ( {typesLoading ? (
<div className="space-y-3"> <div className="space-y-3">
{[...Array(6)].map((_, i) => ( {[...Array(6)].map((_, i) => (
<div key={i} className="h-20 bg-dark-800 rounded-xl animate-pulse" /> <div key={i} className="h-20 animate-pulse rounded-xl bg-dark-800" />
))} ))}
</div> </div>
) : ( ) : (
<div className="grid gap-2 sm:gap-3 grid-cols-1 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3">
{typesData?.items.map(template => ( {typesData?.items.map((template) => (
<TemplateCard <TemplateCard
key={template.type} key={template.type}
template={template} template={template}
@@ -545,9 +605,9 @@ export default function AdminEmailTemplates() {
{/* Detail loading overlay */} {/* Detail loading overlay */}
{selectedType && detailLoading && ( {selectedType && detailLoading && (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" /> <div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div> </div>
)} )}
</div> </div>
) );
} }

View File

@@ -1,210 +1,298 @@
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
// Group header icons // Group header icons
const AnalyticsGroupIcon = () => ( const AnalyticsGroupIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605"
/>
</svg> </svg>
) );
const UsersGroupIcon = () => ( const UsersGroupIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-4 w-4" 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" /> <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> </svg>
) );
const TariffsGroupIcon = () => ( const TariffsGroupIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
) );
const MarketingGroupIcon = () => ( const MarketingGroupIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-4 w-4" 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" /> <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> </svg>
) );
const SystemGroupIcon = () => ( const SystemGroupIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-4 w-4" 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="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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
) );
// Modern icons with consistent styling // Modern icons with consistent styling
const ChartBarIcon = () => ( const ChartBarIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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" /> <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> </svg>
) );
const BanknotesIcon = () => ( const BanknotesIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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" /> <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> </svg>
) );
const UsersIcon = () => ( const UsersIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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" /> <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> </svg>
) );
const ChatBubbleIcon = () => ( const ChatBubbleIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" 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" /> <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> </svg>
) );
const NoSymbolIcon = () => ( const NoSymbolIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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" /> <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> </svg>
) );
const CreditCardIcon = () => ( const CreditCardIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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 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" /> <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> </svg>
) );
const TicketIcon = () => ( const TicketIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z"
/>
</svg> </svg>
) );
const GiftIcon = () => ( const GiftIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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 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" /> <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> </svg>
) );
const MegaphoneIcon = () => ( const MegaphoneIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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" /> <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> </svg>
) );
const PaperAirplaneIcon = () => ( const PaperAirplaneIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<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" /> <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> </svg>
) );
const SparklesIcon = () => ( const SparklesIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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" /> <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> </svg>
) );
const CogIcon = () => ( const CogIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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="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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
) );
const DevicePhoneMobileIcon = () => ( const DevicePhoneMobileIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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.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" /> <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> </svg>
) );
const ServerStackIcon = () => ( const ServerStackIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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" /> <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> </svg>
) );
const EnvelopeIcon = () => ( const EnvelopeIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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.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" /> <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> </svg>
) );
const CubeTransparentIcon = () => ( const CubeTransparentIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25"
/>
</svg> </svg>
) );
const ChevronRightIcon = () => ( const ChevronRightIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg> </svg>
) );
interface AdminItem { interface AdminItem {
to: string to: string;
icon: React.ReactNode icon: React.ReactNode;
title: string title: string;
description: string description: string;
} }
interface AdminGroup { interface AdminGroup {
id: string id: string;
title: string title: string;
icon: React.ReactNode icon: React.ReactNode;
gradient: string gradient: string;
borderColor: string borderColor: string;
iconBg: string iconBg: string;
iconColor: string iconColor: string;
items: AdminItem[] items: AdminItem[];
} }
function AdminCard({ to, icon, title, description, iconBg, iconColor }: AdminItem & { iconBg: string; iconColor: string }) { function AdminCard({
to,
icon,
title,
description,
iconBg,
iconColor,
}: AdminItem & { iconBg: string; iconColor: string }) {
return ( return (
<Link <Link
to={to} to={to}
className="group flex items-center gap-3 p-3 rounded-xl bg-dark-800/40 border border-dark-700/50 hover:bg-dark-800/80 hover:border-dark-600 transition-all duration-200" className="group flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/40 p-3 transition-all duration-200 hover:border-dark-600 hover:bg-dark-800/80"
>
<div
className={`h-10 w-10 rounded-lg ${iconBg} ${iconColor} flex shrink-0 items-center justify-center transition-transform group-hover:scale-105`}
> >
<div className={`w-10 h-10 rounded-lg ${iconBg} ${iconColor} flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform`}>
{icon} {icon}
</div> </div>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<h3 className="text-sm font-medium text-dark-100 group-hover:text-white transition-colors">{title}</h3> <h3 className="text-sm font-medium text-dark-100 transition-colors group-hover:text-white">
<p className="text-xs text-dark-500 truncate">{description}</p> {title}
</h3>
<p className="truncate text-xs text-dark-500">{description}</p>
</div> </div>
<div className="text-dark-600 group-hover:text-dark-400 transition-colors"> <div className="text-dark-600 transition-colors group-hover:text-dark-400">
<ChevronRightIcon /> <ChevronRightIcon />
</div> </div>
</Link> </Link>
) );
} }
function GroupSection({ group }: { group: AdminGroup }) { function GroupSection({ group }: { group: AdminGroup }) {
return ( return (
<div className="rounded-2xl border border-dark-700/50 overflow-hidden bg-dark-900/30 backdrop-blur"> <div className="overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900/30 backdrop-blur">
{/* Group Header */} {/* Group Header */}
<div className={`px-4 py-3 bg-gradient-to-r ${group.gradient} border-b ${group.borderColor}`}> <div className={`bg-gradient-to-r px-4 py-3 ${group.gradient} border-b ${group.borderColor}`}>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className={`p-1.5 rounded-lg ${group.iconBg} ${group.iconColor}`}> <div className={`rounded-lg p-1.5 ${group.iconBg} ${group.iconColor}`}>{group.icon}</div>
{group.icon}
</div>
<h2 className="text-sm font-semibold text-dark-100">{group.title}</h2> <h2 className="text-sm font-semibold text-dark-100">{group.title}</h2>
</div> </div>
</div> </div>
{/* Group Items */} {/* Group Items */}
<div className="p-2 space-y-1.5"> <div className="space-y-1.5 p-2">
{group.items.map((item) => ( {group.items.map((item) => (
<AdminCard <AdminCard key={item.to} {...item} iconBg={group.iconBg} iconColor={group.iconColor} />
key={item.to}
{...item}
iconBg={group.iconBg}
iconColor={group.iconColor}
/>
))} ))}
</div> </div>
</div> </div>
) );
} }
export default function AdminPanel() { export default function AdminPanel() {
const { t } = useTranslation() const { t } = useTranslation();
const groups: AdminGroup[] = [ const groups: AdminGroup[] = [
{ {
@@ -364,22 +452,22 @@ export default function AdminPanel() {
}, },
], ],
}, },
] ];
return ( return (
<div className="animate-fade-in space-y-4"> <div className="animate-fade-in space-y-4">
{/* Header */} {/* Header */}
<div className="mb-6"> <div className="mb-6">
<h1 className="text-2xl font-bold text-dark-100">{t('admin.panel.title')}</h1> <h1 className="text-2xl font-bold text-dark-100">{t('admin.panel.title')}</h1>
<p className="text-sm text-dark-400 mt-1">{t('admin.panel.subtitle')}</p> <p className="mt-1 text-sm text-dark-400">{t('admin.panel.subtitle')}</p>
</div> </div>
{/* Groups Grid */} {/* Groups Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{groups.map((group) => ( {groups.map((group) => (
<GroupSection key={group.id} group={group} /> <GroupSection key={group.id} group={group} />
))} ))}
</div> </div>
</div> </div>
) );
} }

View File

@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect } from 'react' import { useState, useCallback, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom';
import { import {
DndContext, DndContext,
closestCenter, closestCenter,
@@ -11,55 +11,69 @@ import {
useSensor, useSensor,
useSensors, useSensors,
type DragEndEvent, type DragEndEvent,
} from '@dnd-kit/core' } from '@dnd-kit/core';
import { import {
arrayMove, arrayMove,
SortableContext, SortableContext,
sortableKeyboardCoordinates, sortableKeyboardCoordinates,
useSortable, useSortable,
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable' } from '@dnd-kit/sortable';
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, PromoGroupSimple } from '../types' import type { PaymentMethodConfig, PromoGroupSimple } from '../types';
// ============ Icons ============ // ============ Icons ============
const BackIcon = () => ( const BackIcon = () => (
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg> </svg>
) );
const GripIcon = () => ( const GripIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg> </svg>
) );
const ChevronRightIcon = () => ( const ChevronRightIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg> </svg>
) );
const CloseIcon = () => ( const CloseIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
) );
const CheckIcon = () => ( const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg> </svg>
) );
const SaveIcon = () => ( const SaveIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <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> </svg>
) );
// ============ Method icon by type ============ // ============ Method icon by type ============
@@ -75,7 +89,7 @@ const METHOD_ICONS: Record<string, string> = {
wata: '\uD83D\uDCA7', wata: '\uD83D\uDCA7',
freekassa: '\uD83D\uDCB5', freekassa: '\uD83D\uDCB5',
cloudpayments: '\u2601\uFE0F', cloudpayments: '\u2601\uFE0F',
} };
const METHOD_LABELS: Record<string, string> = { const METHOD_LABELS: Record<string, string> = {
telegram_stars: 'Telegram Stars', telegram_stars: 'Telegram Stars',
@@ -89,53 +103,56 @@ const METHOD_LABELS: Record<string, string> = {
wata: 'WATA', wata: 'WATA',
freekassa: 'Freekassa', freekassa: 'Freekassa',
cloudpayments: 'CloudPayments', cloudpayments: 'CloudPayments',
} };
// ============ Sortable Card ============ // ============ Sortable Card ============
interface SortableCardProps { interface SortableCardProps {
config: PaymentMethodConfig config: PaymentMethodConfig;
onClick: () => void onClick: () => void;
} }
function SortablePaymentCard({ config, onClick }: SortableCardProps) { function SortablePaymentCard({ config, onClick }: SortableCardProps) {
const { t } = useTranslation() const { t } = useTranslation();
const { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
attributes, id: config.method_id,
listeners, });
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: config.method_id })
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
transition, transition,
zIndex: isDragging ? 50 : undefined, zIndex: isDragging ? 50 : undefined,
opacity: isDragging ? 0.85 : 1, opacity: isDragging ? 0.85 : 1,
} };
const displayName = config.display_name || config.default_display_name const displayName = config.display_name || config.default_display_name;
const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3' const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3';
// Build condition summary chips // Build condition summary chips
const chips: string[] = [] const chips: string[] = [];
if (config.user_type_filter === 'telegram') chips.push(t('admin.paymentMethods.userTypeTelegram', 'Telegram')) if (config.user_type_filter === 'telegram')
if (config.user_type_filter === 'email') chips.push(t('admin.paymentMethods.userTypeEmail', 'Email')) chips.push(t('admin.paymentMethods.userTypeTelegram', 'Telegram'));
if (config.first_topup_filter === 'yes') chips.push(t('admin.paymentMethods.firstTopupYes', 'C пополнением')) if (config.user_type_filter === 'email')
if (config.first_topup_filter === 'no') chips.push(t('admin.paymentMethods.firstTopupNo', 'Без пополнения')) chips.push(t('admin.paymentMethods.userTypeEmail', 'Email'));
if (config.first_topup_filter === 'yes')
chips.push(t('admin.paymentMethods.firstTopupYes', 'C пополнением'));
if (config.first_topup_filter === 'no')
chips.push(t('admin.paymentMethods.firstTopupNo', 'Без пополнения'));
if (config.promo_group_filter_mode === 'selected' && config.allowed_promo_group_ids.length > 0) { if (config.promo_group_filter_mode === 'selected' && config.allowed_promo_group_ids.length > 0) {
chips.push(`${config.allowed_promo_group_ids.length} ${t('admin.paymentMethods.promoGroupsShort', 'групп')}`) chips.push(
`${config.allowed_promo_group_ids.length} ${t('admin.paymentMethods.promoGroupsShort', 'групп')}`,
);
} }
// Count enabled sub-options // Count enabled sub-options
let subOptionsInfo = '' let subOptionsInfo = '';
if (config.available_sub_options && config.sub_options) { if (config.available_sub_options && config.sub_options) {
const enabledCount = config.available_sub_options.filter(o => config.sub_options?.[o.id] !== false).length const enabledCount = config.available_sub_options.filter(
const totalCount = config.available_sub_options.length (o) => config.sub_options?.[o.id] !== false,
).length;
const totalCount = config.available_sub_options.length;
if (enabledCount < totalCount) { if (enabledCount < totalCount) {
subOptionsInfo = `${enabledCount}/${totalCount}` subOptionsInfo = `${enabledCount}/${totalCount}`;
} }
} }
@@ -143,49 +160,49 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} style={style}
className={`group flex items-center gap-3 p-4 rounded-xl border transition-all ${ className={`group flex items-center gap-3 rounded-xl border p-4 transition-all ${
isDragging isDragging
? 'bg-dark-700/80 border-accent-500/50 shadow-xl shadow-accent-500/10' ? 'border-accent-500/50 bg-dark-700/80 shadow-xl shadow-accent-500/10'
: config.is_enabled : config.is_enabled
? 'bg-dark-800/50 border-dark-700/50 hover:border-dark-600' ? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
: 'bg-dark-900/30 border-dark-800/50 opacity-60' : 'border-dark-800/50 bg-dark-900/30 opacity-60'
}`} }`}
> >
{/* Drag handle */} {/* Drag handle */}
<button <button
{...attributes} {...attributes}
{...listeners} {...listeners}
className="flex-shrink-0 p-1.5 rounded-lg text-dark-500 hover:text-dark-300 hover:bg-dark-700/50 cursor-grab active:cursor-grabbing touch-manipulation" className="flex-shrink-0 cursor-grab touch-manipulation rounded-lg p-1.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing"
title={t('admin.paymentMethods.dragToReorder', 'Перетащите для изменения порядка')} title={t('admin.paymentMethods.dragToReorder', 'Перетащите для изменения порядка')}
> >
<GripIcon /> <GripIcon />
</button> </button>
{/* Method icon */} {/* Method icon */}
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-dark-700/50 flex items-center justify-center text-xl"> <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-dark-700/50 text-xl">
{icon} {icon}
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0 cursor-pointer" onClick={onClick}> <div className="min-w-0 flex-1 cursor-pointer" onClick={onClick}>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-dark-100 truncate">{displayName}</span> <span className="truncate font-semibold text-dark-100">{displayName}</span>
{config.is_enabled ? ( {config.is_enabled ? (
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-success-500/15 text-success-400 border border-success-500/20"> <span className="flex-shrink-0 rounded-full border border-success-500/20 bg-success-500/15 px-2 py-0.5 text-xs text-success-400">
{t('admin.paymentMethods.enabled', 'Вкл')} {t('admin.paymentMethods.enabled', 'Вкл')}
</span> </span>
) : ( ) : (
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-dark-700/50 text-dark-500 border border-dark-700/30"> <span className="flex-shrink-0 rounded-full border border-dark-700/30 bg-dark-700/50 px-2 py-0.5 text-xs text-dark-500">
{t('admin.paymentMethods.disabled', 'Выкл')} {t('admin.paymentMethods.disabled', 'Выкл')}
</span> </span>
)} )}
{!config.is_provider_configured && ( {!config.is_provider_configured && (
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-warning-500/15 text-warning-400 border border-warning-500/20"> <span className="flex-shrink-0 rounded-full border border-warning-500/20 bg-warning-500/15 px-2 py-0.5 text-xs text-warning-400">
{t('admin.paymentMethods.notConfigured', 'Не настроен')} {t('admin.paymentMethods.notConfigured', 'Не настроен')}
</span> </span>
)} )}
{subOptionsInfo && ( {subOptionsInfo && (
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-dark-700/50 text-dark-400"> <span className="flex-shrink-0 rounded-full bg-dark-700/50 px-2 py-0.5 text-xs text-dark-400">
{subOptionsInfo} {subOptionsInfo}
</span> </span>
)} )}
@@ -193,9 +210,12 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
{/* Condition chips */} {/* Condition chips */}
{chips.length > 0 && ( {chips.length > 0 && (
<div className="flex items-center gap-1.5 mt-1.5 flex-wrap"> <div className="mt-1.5 flex flex-wrap items-center gap-1.5">
{chips.map((chip, i) => ( {chips.map((chip, i) => (
<span key={i} className="text-xs px-2 py-0.5 rounded-md bg-accent-500/10 text-accent-400 border border-accent-500/15"> <span
key={i}
className="rounded-md border border-accent-500/15 bg-accent-500/10 px-2 py-0.5 text-xs text-accent-400"
>
{chip} {chip}
</span> </span>
))} ))}
@@ -206,54 +226,64 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
{/* Chevron */} {/* Chevron */}
<button <button
onClick={onClick} onClick={onClick}
className="flex-shrink-0 p-1 text-dark-500 hover:text-dark-300 transition-colors" className="flex-shrink-0 p-1 text-dark-500 transition-colors hover:text-dark-300"
> >
<ChevronRightIcon /> <ChevronRightIcon />
</button> </button>
</div> </div>
) );
} }
// ============ Detail Modal ============ // ============ Detail Modal ============
interface DetailModalProps { interface DetailModalProps {
config: PaymentMethodConfig config: PaymentMethodConfig;
promoGroups: PromoGroupSimple[] promoGroups: PromoGroupSimple[];
onClose: () => void onClose: () => void;
onSave: (methodId: string, data: Record<string, unknown>) => void onSave: (methodId: string, data: Record<string, unknown>) => void;
isSaving: boolean isSaving: boolean;
} }
function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSaving }: DetailModalProps) { function PaymentMethodDetailModal({
const { t } = useTranslation() config,
const displayName = config.display_name || config.default_display_name promoGroups,
const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3' onClose,
onSave,
isSaving,
}: DetailModalProps) {
const { t } = useTranslation();
const displayName = config.display_name || config.default_display_name;
const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3';
// Local state for editing // Local state for editing
const [isEnabled, setIsEnabled] = useState(config.is_enabled) const [isEnabled, setIsEnabled] = useState(config.is_enabled);
const [customName, setCustomName] = useState(config.display_name || '') const [customName, setCustomName] = useState(config.display_name || '');
const [subOptions, setSubOptions] = useState<Record<string, boolean>>(config.sub_options || {}) const [subOptions, setSubOptions] = useState<Record<string, boolean>>(config.sub_options || {});
const [minAmount, setMinAmount] = useState(config.min_amount_kopeks?.toString() || '') const [minAmount, setMinAmount] = useState(config.min_amount_kopeks?.toString() || '');
const [maxAmount, setMaxAmount] = useState(config.max_amount_kopeks?.toString() || '') const [maxAmount, setMaxAmount] = useState(config.max_amount_kopeks?.toString() || '');
const [userTypeFilter, setUserTypeFilter] = useState(config.user_type_filter) const [userTypeFilter, setUserTypeFilter] = useState(config.user_type_filter);
const [firstTopupFilter, setFirstTopupFilter] = useState(config.first_topup_filter) const [firstTopupFilter, setFirstTopupFilter] = useState(config.first_topup_filter);
const [promoGroupFilterMode, setPromoGroupFilterMode] = useState(config.promo_group_filter_mode) const [promoGroupFilterMode, setPromoGroupFilterMode] = useState(config.promo_group_filter_mode);
const [selectedPromoGroupIds, setSelectedPromoGroupIds] = useState<number[]>(config.allowed_promo_group_ids) const [selectedPromoGroupIds, setSelectedPromoGroupIds] = useState<number[]>(
config.allowed_promo_group_ids,
);
// Escape to close // Escape to close
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose() if (e.key === 'Escape') onClose();
} };
document.addEventListener('keydown', handleKeyDown) document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]) }, [onClose]);
// Scroll lock // Scroll lock
useEffect(() => { useEffect(() => {
document.body.style.overflow = 'hidden' document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = '' } return () => {
}, []) document.body.style.overflow = '';
};
}, []);
const handleSave = () => { const handleSave = () => {
const data: Record<string, unknown> = { const data: Record<string, unknown> = {
@@ -262,129 +292,148 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
first_topup_filter: firstTopupFilter, first_topup_filter: firstTopupFilter,
promo_group_filter_mode: promoGroupFilterMode, promo_group_filter_mode: promoGroupFilterMode,
allowed_promo_group_ids: promoGroupFilterMode === 'selected' ? selectedPromoGroupIds : [], allowed_promo_group_ids: promoGroupFilterMode === 'selected' ? selectedPromoGroupIds : [],
} };
// Display name // Display name
if (customName.trim()) { if (customName.trim()) {
data.display_name = customName.trim() data.display_name = customName.trim();
} else { } else {
data.reset_display_name = true data.reset_display_name = true;
} }
// Sub-options // Sub-options
if (config.available_sub_options) { if (config.available_sub_options) {
data.sub_options = subOptions data.sub_options = subOptions;
} }
// Amounts // Amounts
if (minAmount.trim()) { if (minAmount.trim()) {
data.min_amount_kopeks = parseInt(minAmount, 10) || null data.min_amount_kopeks = parseInt(minAmount, 10) || null;
} else { } else {
data.reset_min_amount = true data.reset_min_amount = true;
} }
if (maxAmount.trim()) { if (maxAmount.trim()) {
data.max_amount_kopeks = parseInt(maxAmount, 10) || null data.max_amount_kopeks = parseInt(maxAmount, 10) || null;
} else { } else {
data.reset_max_amount = true data.reset_max_amount = true;
} }
onSave(config.method_id, data) onSave(config.method_id, data);
} };
const togglePromoGroup = (id: number) => { const togglePromoGroup = (id: number) => {
setSelectedPromoGroupIds(prev => setSelectedPromoGroupIds((prev) =>
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id] prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
) );
} };
return ( return (
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center" onClick={onClose}> <div
className="fixed inset-0 z-50 flex items-start justify-center sm:items-center"
onClick={onClose}
>
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" /> <div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
<div <div
className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto m-4 rounded-2xl bg-dark-800 border border-dark-700 shadow-2xl" className="relative m-4 max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-dark-700 bg-dark-800 shadow-2xl"
onClick={e => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Header */} {/* Header */}
<div className="sticky top-0 z-10 flex items-center justify-between p-5 border-b border-dark-700 bg-dark-800 rounded-t-2xl"> <div className="sticky top-0 z-10 flex items-center justify-between rounded-t-2xl border-b border-dark-700 bg-dark-800 p-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-dark-700/50 flex items-center justify-center text-xl"> <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-dark-700/50 text-xl">
{icon} {icon}
</div> </div>
<div> <div>
<h2 className="text-lg font-bold text-dark-50">{displayName}</h2> <h2 className="text-lg font-bold text-dark-50">{displayName}</h2>
<p className="text-xs text-dark-500">{METHOD_LABELS[config.method_id] || config.method_id}</p> <p className="text-xs text-dark-500">
{METHOD_LABELS[config.method_id] || config.method_id}
</p>
</div> </div>
</div> </div>
<button onClick={onClose} className="p-2 rounded-lg hover:bg-dark-700 text-dark-400 hover:text-dark-200 transition-colors"> <button
onClick={onClose}
className="rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
>
<CloseIcon /> <CloseIcon />
</button> </button>
</div> </div>
<div className="p-5 space-y-6"> <div className="space-y-6 p-5">
{/* Enable toggle */} {/* Enable toggle */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<div className="text-sm font-medium text-dark-200">{t('admin.paymentMethods.methodEnabled', 'Метод включён')}</div> <div className="text-sm font-medium text-dark-200">
{t('admin.paymentMethods.methodEnabled', 'Метод включён')}
</div>
{!config.is_provider_configured && ( {!config.is_provider_configured && (
<div className="text-xs text-warning-400 mt-0.5">{t('admin.paymentMethods.providerNotConfigured', 'Провайдер не настроен в env')}</div> <div className="mt-0.5 text-xs text-warning-400">
{t('admin.paymentMethods.providerNotConfigured', 'Провайдер не настроен в env')}
</div>
)} )}
</div> </div>
<button <button
onClick={() => setIsEnabled(!isEnabled)} onClick={() => setIsEnabled(!isEnabled)}
className={`relative w-12 h-7 rounded-full transition-colors ${ className={`relative h-7 w-12 rounded-full transition-colors ${
isEnabled ? 'bg-accent-500' : 'bg-dark-600' isEnabled ? 'bg-accent-500' : 'bg-dark-600'
}`} }`}
> >
<span className={`absolute top-0.5 w-6 h-6 rounded-full bg-white shadow transition-transform ${ <span
className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition-transform ${
isEnabled ? 'left-[calc(100%-1.625rem)]' : 'left-0.5' isEnabled ? 'left-[calc(100%-1.625rem)]' : 'left-0.5'
}`} /> }`}
/>
</button> </button>
</div> </div>
{/* Display name */} {/* Display name */}
<div> <div>
<label className="block text-sm font-medium text-dark-200 mb-2"> <label className="mb-2 block text-sm font-medium text-dark-200">
{t('admin.paymentMethods.displayName', 'Отображаемое имя')} {t('admin.paymentMethods.displayName', 'Отображаемое имя')}
</label> </label>
<input <input
type="text" type="text"
value={customName} value={customName}
onChange={e => setCustomName(e.target.value)} onChange={(e) => setCustomName(e.target.value)}
placeholder={config.default_display_name} placeholder={config.default_display_name}
className="w-full px-4 py-2.5 rounded-xl bg-dark-900/50 border border-dark-700 text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500/50 transition-colors" className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none"
/> />
<p className="text-xs text-dark-500 mt-1"> <p className="mt-1 text-xs text-dark-500">
{t('admin.paymentMethods.displayNameHint', 'Оставьте пустым для имени по умолчанию')}: {config.default_display_name} {t('admin.paymentMethods.displayNameHint', 'Оставьте пустым для имени по умолчанию')}:{' '}
{config.default_display_name}
</p> </p>
</div> </div>
{/* Sub-options */} {/* Sub-options */}
{config.available_sub_options && config.available_sub_options.length > 0 && ( {config.available_sub_options && config.available_sub_options.length > 0 && (
<div> <div>
<label className="block text-sm font-medium text-dark-200 mb-2"> <label className="mb-2 block text-sm font-medium text-dark-200">
{t('admin.paymentMethods.subOptions', 'Варианты оплаты')} {t('admin.paymentMethods.subOptions', 'Варианты оплаты')}
</label> </label>
<div className="space-y-2"> <div className="space-y-2">
{config.available_sub_options.map(opt => { {config.available_sub_options.map((opt) => {
const enabled = subOptions[opt.id] !== false const enabled = subOptions[opt.id] !== false;
return ( return (
<button <button
key={opt.id} key={opt.id}
onClick={() => setSubOptions(prev => ({ ...prev, [opt.id]: !enabled }))} onClick={() => setSubOptions((prev) => ({ ...prev, [opt.id]: !enabled }))}
className={`w-full flex items-center justify-between p-3 rounded-xl border transition-all ${ className={`flex w-full items-center justify-between rounded-xl border p-3 transition-all ${
enabled enabled
? 'bg-dark-700/30 border-accent-500/30 text-dark-100' ? 'border-accent-500/30 bg-dark-700/30 text-dark-100'
: 'bg-dark-900/30 border-dark-800 text-dark-500' : 'border-dark-800 bg-dark-900/30 text-dark-500'
}`} }`}
> >
<span className="text-sm">{opt.name}</span> <span className="text-sm">{opt.name}</span>
<div className={`w-5 h-5 rounded flex items-center justify-center ${ <div
enabled ? 'bg-accent-500 text-white' : 'bg-dark-700 border border-dark-600' className={`flex h-5 w-5 items-center justify-center rounded ${
}`}> enabled
? 'bg-accent-500 text-white'
: 'border border-dark-600 bg-dark-700'
}`}
>
{enabled && <CheckIcon />} {enabled && <CheckIcon />}
</div> </div>
</button> </button>
) );
})} })}
</div> </div>
</div> </div>
@@ -393,55 +442,58 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
{/* Min/Max amounts */} {/* Min/Max amounts */}
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="block text-sm font-medium text-dark-200 mb-2"> <label className="mb-2 block text-sm font-medium text-dark-200">
{t('admin.paymentMethods.minAmount', 'Мин. сумма (коп.)')} {t('admin.paymentMethods.minAmount', 'Мин. сумма (коп.)')}
</label> </label>
<input <input
type="number" type="number"
value={minAmount} value={minAmount}
onChange={e => setMinAmount(e.target.value)} onChange={(e) => setMinAmount(e.target.value)}
placeholder={config.default_min_amount_kopeks.toString()} placeholder={config.default_min_amount_kopeks.toString()}
className="w-full px-4 py-2.5 rounded-xl bg-dark-900/50 border border-dark-700 text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500/50 transition-colors" className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-dark-200 mb-2"> <label className="mb-2 block text-sm font-medium text-dark-200">
{t('admin.paymentMethods.maxAmount', 'Макс. сумма (коп.)')} {t('admin.paymentMethods.maxAmount', 'Макс. сумма (коп.)')}
</label> </label>
<input <input
type="number" type="number"
value={maxAmount} value={maxAmount}
onChange={e => setMaxAmount(e.target.value)} onChange={(e) => setMaxAmount(e.target.value)}
placeholder={config.default_max_amount_kopeks.toString()} placeholder={config.default_max_amount_kopeks.toString()}
className="w-full px-4 py-2.5 rounded-xl bg-dark-900/50 border border-dark-700 text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500/50 transition-colors" className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none"
/> />
</div> </div>
</div> </div>
{/* Display conditions */} {/* Display conditions */}
<div className="pt-3 border-t border-dark-700"> <div className="border-t border-dark-700 pt-3">
<h3 className="text-sm font-semibold text-dark-200 mb-4"> <h3 className="mb-4 text-sm font-semibold text-dark-200">
{t('admin.paymentMethods.conditions', 'Условия отображения')} {t('admin.paymentMethods.conditions', 'Условия отображения')}
</h3> </h3>
{/* User type filter */} {/* User type filter */}
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm text-dark-300 mb-2"> <label className="mb-2 block text-sm text-dark-300">
{t('admin.paymentMethods.userTypeFilter', 'Тип пользователя')} {t('admin.paymentMethods.userTypeFilter', 'Тип пользователя')}
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
{(['all', 'telegram', 'email'] as const).map(val => ( {(['all', 'telegram', 'email'] as const).map((val) => (
<button <button
key={val} key={val}
onClick={() => setUserTypeFilter(val)} onClick={() => setUserTypeFilter(val)}
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-all ${ className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
userTypeFilter === val userTypeFilter === val
? 'bg-accent-500/20 border border-accent-500/40 text-accent-300' ? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
: 'bg-dark-900/50 border border-dark-700 text-dark-400 hover:border-dark-600' : 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
}`} }`}
> >
{val === 'all' ? t('admin.paymentMethods.userTypeAll', 'Все') : {val === 'all'
val === 'telegram' ? 'Telegram' : 'Email'} ? t('admin.paymentMethods.userTypeAll', 'Все')
: val === 'telegram'
? 'Telegram'
: 'Email'}
</button> </button>
))} ))}
</div> </div>
@@ -449,23 +501,25 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
{/* First topup filter */} {/* First topup filter */}
<div className="mb-4"> <div className="mb-4">
<label className="block text-sm text-dark-300 mb-2"> <label className="mb-2 block text-sm text-dark-300">
{t('admin.paymentMethods.firstTopupFilter', 'Первое пополнение')} {t('admin.paymentMethods.firstTopupFilter', 'Первое пополнение')}
</label> </label>
<div className="flex gap-2"> <div className="flex gap-2">
{(['any', 'yes', 'no'] as const).map(val => ( {(['any', 'yes', 'no'] as const).map((val) => (
<button <button
key={val} key={val}
onClick={() => setFirstTopupFilter(val)} onClick={() => setFirstTopupFilter(val)}
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-all ${ className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
firstTopupFilter === val firstTopupFilter === val
? 'bg-accent-500/20 border border-accent-500/40 text-accent-300' ? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
: 'bg-dark-900/50 border border-dark-700 text-dark-400 hover:border-dark-600' : 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
}`} }`}
> >
{val === 'any' ? t('admin.paymentMethods.firstTopupAny', 'Не важно') : {val === 'any'
val === 'yes' ? t('admin.paymentMethods.firstTopupYes', 'Было') : ? t('admin.paymentMethods.firstTopupAny', 'Не важно')
t('admin.paymentMethods.firstTopupNo', 'Не было')} : val === 'yes'
? t('admin.paymentMethods.firstTopupYes', 'Было')
: t('admin.paymentMethods.firstTopupNo', 'Не было')}
</button> </button>
))} ))}
</div> </div>
@@ -473,55 +527,56 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
{/* Promo groups filter */} {/* Promo groups filter */}
<div> <div>
<label className="block text-sm text-dark-300 mb-2"> <label className="mb-2 block text-sm text-dark-300">
{t('admin.paymentMethods.promoGroupFilter', 'Промо-группы')} {t('admin.paymentMethods.promoGroupFilter', 'Промо-группы')}
</label> </label>
<div className="flex gap-2 mb-3"> <div className="mb-3 flex gap-2">
{(['all', 'selected'] as const).map(val => ( {(['all', 'selected'] as const).map((val) => (
<button <button
key={val} key={val}
onClick={() => setPromoGroupFilterMode(val)} onClick={() => setPromoGroupFilterMode(val)}
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-all ${ className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
promoGroupFilterMode === val promoGroupFilterMode === val
? 'bg-accent-500/20 border border-accent-500/40 text-accent-300' ? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
: 'bg-dark-900/50 border border-dark-700 text-dark-400 hover:border-dark-600' : 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
}`} }`}
> >
{val === 'all' {val === 'all'
? t('admin.paymentMethods.promoGroupAll', 'Все группы') ? t('admin.paymentMethods.promoGroupAll', 'Все группы')
: t('admin.paymentMethods.promoGroupSelected', 'Выбранные') : t('admin.paymentMethods.promoGroupSelected', 'Выбранные')}
}
</button> </button>
))} ))}
</div> </div>
{promoGroupFilterMode === 'selected' && ( {promoGroupFilterMode === 'selected' && (
<div className="max-h-48 overflow-y-auto space-y-1.5 p-3 rounded-xl bg-dark-900/30 border border-dark-700/50"> <div className="max-h-48 space-y-1.5 overflow-y-auto rounded-xl border border-dark-700/50 bg-dark-900/30 p-3">
{promoGroups.length === 0 ? ( {promoGroups.length === 0 ? (
<p className="text-sm text-dark-500 text-center py-2"> <p className="py-2 text-center text-sm text-dark-500">
{t('admin.paymentMethods.noPromoGroups', 'Нет промо-групп')} {t('admin.paymentMethods.noPromoGroups', 'Нет промо-групп')}
</p> </p>
) : ( ) : (
promoGroups.map(group => { promoGroups.map((group) => {
const selected = selectedPromoGroupIds.includes(group.id) const selected = selectedPromoGroupIds.includes(group.id);
return ( return (
<button <button
key={group.id} key={group.id}
onClick={() => togglePromoGroup(group.id)} onClick={() => togglePromoGroup(group.id)}
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${ className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-all ${
selected selected
? 'bg-accent-500/15 text-accent-300' ? 'bg-accent-500/15 text-accent-300'
: 'text-dark-400 hover:bg-dark-800/50' : 'text-dark-400 hover:bg-dark-800/50'
}`} }`}
> >
<span>{group.name}</span> <span>{group.name}</span>
<div className={`w-4 h-4 rounded flex items-center justify-center ${ <div
className={`flex h-4 w-4 items-center justify-center rounded ${
selected ? 'bg-accent-500 text-white' : 'border border-dark-600' selected ? 'bg-accent-500 text-white' : 'border border-dark-600'
}`}> }`}
>
{selected && <CheckIcon />} {selected && <CheckIcon />}
</div> </div>
</button> </button>
) );
}) })
)} )}
</div> </div>
@@ -531,20 +586,20 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
</div> </div>
{/* Footer */} {/* Footer */}
<div className="sticky bottom-0 flex items-center gap-3 p-5 border-t border-dark-700 bg-dark-800 rounded-b-2xl"> <div className="sticky bottom-0 flex items-center gap-3 rounded-b-2xl border-t border-dark-700 bg-dark-800 p-5">
<button <button
onClick={onClose} onClick={onClose}
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors font-medium" className="flex-1 rounded-xl bg-dark-700 px-4 py-2.5 font-medium text-dark-300 transition-colors hover:bg-dark-600"
> >
{t('common.cancel', 'Отмена')} {t('common.cancel', 'Отмена')}
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={isSaving} disabled={isSaving}
className="flex-1 px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-400 disabled:opacity-50 transition-colors font-medium flex items-center justify-center gap-2" className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-accent-500 px-4 py-2.5 font-medium text-white transition-colors hover:bg-accent-400 disabled:opacity-50"
> >
{isSaving ? ( {isSaving ? (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : ( ) : (
<SaveIcon /> <SaveIcon />
)} )}
@@ -553,126 +608,130 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
</div> </div>
</div> </div>
</div> </div>
) );
} }
// ============ Toast ============ // ============ Toast ============
function Toast({ message, onClose }: { message: string; onClose: () => void }) { function Toast({ message, onClose }: { message: string; onClose: () => void }) {
useEffect(() => { useEffect(() => {
const timer = setTimeout(onClose, 3000) const timer = setTimeout(onClose, 3000);
return () => clearTimeout(timer) return () => clearTimeout(timer);
}, [onClose]) }, [onClose]);
return ( return (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-5 py-3 rounded-xl bg-success-500/90 text-white text-sm font-medium shadow-lg backdrop-blur-sm animate-fade-in flex items-center gap-2"> <div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 animate-fade-in items-center gap-2 rounded-xl bg-success-500/90 px-5 py-3 text-sm font-medium text-white shadow-lg backdrop-blur-sm">
<CheckIcon /> <CheckIcon />
{message} {message}
</div> </div>
) );
} }
// ============ Main Page ============ // ============ Main Page ============
export default function AdminPaymentMethods() { export default function AdminPaymentMethods() {
const { t } = useTranslation() const { t } = useTranslation();
const queryClient = useQueryClient() const queryClient = useQueryClient();
const [methods, setMethods] = useState<PaymentMethodConfig[]>([]) const [methods, setMethods] = useState<PaymentMethodConfig[]>([]);
const [selectedMethod, setSelectedMethod] = useState<PaymentMethodConfig | null>(null) const [selectedMethod, setSelectedMethod] = useState<PaymentMethodConfig | null>(null);
const [orderChanged, setOrderChanged] = useState(false) const [orderChanged, setOrderChanged] = useState(false);
const [toastMessage, setToastMessage] = useState<string | null>(null) const [toastMessage, setToastMessage] = useState<string | null>(null);
// Fetch payment methods // Fetch payment methods
const { data: fetchedMethods, isLoading } = useQuery({ const { data: fetchedMethods, isLoading } = useQuery({
queryKey: ['admin-payment-methods'], queryKey: ['admin-payment-methods'],
queryFn: adminPaymentMethodsApi.getAll, queryFn: adminPaymentMethodsApi.getAll,
}) });
// Fetch promo groups // Fetch promo groups
const { data: promoGroups = [] } = useQuery({ const { data: promoGroups = [] } = useQuery({
queryKey: ['admin-payment-methods-promo-groups'], queryKey: ['admin-payment-methods-promo-groups'],
queryFn: adminPaymentMethodsApi.getPromoGroups, queryFn: adminPaymentMethodsApi.getPromoGroups,
}) });
// Sync fetched data to local state // Sync fetched data to local state
useEffect(() => { useEffect(() => {
if (fetchedMethods && !orderChanged) { if (fetchedMethods && !orderChanged) {
setMethods(fetchedMethods) setMethods(fetchedMethods);
} }
}, [fetchedMethods, orderChanged]) }, [fetchedMethods, orderChanged]);
// Save order mutation // Save order mutation
const saveOrderMutation = useMutation({ const saveOrderMutation = useMutation({
mutationFn: (methodIds: string[]) => adminPaymentMethodsApi.updateOrder(methodIds), mutationFn: (methodIds: string[]) => adminPaymentMethodsApi.updateOrder(methodIds),
onSuccess: () => { onSuccess: () => {
setOrderChanged(false) setOrderChanged(false);
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] }) queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] });
setToastMessage(t('admin.paymentMethods.orderSaved', 'Порядок сохранён')) setToastMessage(t('admin.paymentMethods.orderSaved', 'Порядок сохранён'));
}, },
onError: () => { onError: () => {
setToastMessage(t('common.error', 'Ошибка')) setToastMessage(t('common.error', 'Ошибка'));
}, },
}) });
// Update method mutation // Update method mutation
const updateMethodMutation = useMutation({ const updateMethodMutation = useMutation({
mutationFn: ({ methodId, data }: { methodId: string; data: Record<string, unknown> }) => mutationFn: ({ methodId, data }: { methodId: string; data: Record<string, unknown> }) =>
adminPaymentMethodsApi.update(methodId, data), adminPaymentMethodsApi.update(methodId, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] }) queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] });
setSelectedMethod(null) setSelectedMethod(null);
setToastMessage(t('admin.paymentMethods.saved', 'Настройки сохранены')) setToastMessage(t('admin.paymentMethods.saved', 'Настройки сохранены'));
}, },
onError: () => { onError: () => {
setToastMessage(t('common.error', 'Ошибка')) setToastMessage(t('common.error', 'Ошибка'));
}, },
}) });
// DnD sensors // DnD sensors
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }), useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
) );
const handleDragEnd = useCallback((event: DragEndEvent) => { const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event const { active, over } = event;
if (over && active.id !== over.id) { if (over && active.id !== over.id) {
setMethods(prev => { setMethods((prev) => {
const oldIndex = prev.findIndex(m => m.method_id === active.id) const oldIndex = prev.findIndex((m) => m.method_id === active.id);
const newIndex = prev.findIndex(m => m.method_id === over.id) const newIndex = prev.findIndex((m) => m.method_id === over.id);
if (oldIndex === -1 || newIndex === -1) return prev if (oldIndex === -1 || newIndex === -1) return prev;
return arrayMove(prev, oldIndex, newIndex) return arrayMove(prev, oldIndex, newIndex);
}) });
setOrderChanged(true) setOrderChanged(true);
} }
}, []) }, []);
const handleSaveOrder = () => { const handleSaveOrder = () => {
saveOrderMutation.mutate(methods.map(m => m.method_id)) saveOrderMutation.mutate(methods.map((m) => m.method_id));
} };
const handleSaveMethod = (methodId: string, data: Record<string, unknown>) => { const handleSaveMethod = (methodId: string, data: Record<string, unknown>) => {
updateMethodMutation.mutate({ methodId, data }) updateMethodMutation.mutate({ methodId, data });
} };
const handleCloseToast = useCallback(() => setToastMessage(null), []) const handleCloseToast = useCallback(() => setToastMessage(null), []);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between flex-wrap gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link <Link
to="/admin" to="/admin"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors" 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 />
</Link> </Link>
<div> <div>
<h1 className="text-2xl font-bold text-dark-50">{t('admin.paymentMethods.title', 'Платёжные методы')}</h1> <h1 className="text-2xl font-bold text-dark-50">
<p className="text-sm text-dark-400">{t('admin.paymentMethods.description', 'Настройка порядка и условий отображения')}</p> {t('admin.paymentMethods.title', 'Платёжные методы')}
</h1>
<p className="text-sm text-dark-400">
{t('admin.paymentMethods.description', 'Настройка порядка и условий отображения')}
</p>
</div> </div>
</div> </div>
{orderChanged && ( {orderChanged && (
@@ -682,7 +741,7 @@ export default function AdminPaymentMethods() {
className="btn-primary flex items-center gap-2" className="btn-primary flex items-center gap-2"
> >
{saveOrderMutation.isPending ? ( {saveOrderMutation.isPending ? (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
) : ( ) : (
<SaveIcon /> <SaveIcon />
)} )}
@@ -692,22 +751,32 @@ export default function AdminPaymentMethods() {
</div> </div>
{/* Drag hint */} {/* Drag hint */}
<div className="text-sm text-dark-500 flex items-center gap-2"> <div className="flex items-center gap-2 text-sm text-dark-500">
<GripIcon /> <GripIcon />
{t('admin.paymentMethods.dragHint', 'Перетаскивайте карточки для изменения порядка. Нажмите для настройки.')} {t(
'admin.paymentMethods.dragHint',
'Перетаскивайте карточки для изменения порядка. Нажмите для настройки.',
)}
</div> </div>
{/* Methods list */} {/* Methods list */}
<div className="card"> <div className="card">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" /> <div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div> </div>
) : methods.length > 0 ? ( ) : methods.length > 0 ? (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}> <DndContext
<SortableContext items={methods.map(m => m.method_id)} strategy={verticalListSortingStrategy}> sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={methods.map((m) => m.method_id)}
strategy={verticalListSortingStrategy}
>
<div className="space-y-2"> <div className="space-y-2">
{methods.map(config => ( {methods.map((config) => (
<SortablePaymentCard <SortablePaymentCard
key={config.method_id} key={config.method_id}
config={config} config={config}
@@ -718,11 +787,13 @@ export default function AdminPaymentMethods() {
</SortableContext> </SortableContext>
</DndContext> </DndContext>
) : ( ) : (
<div className="text-center py-12"> <div className="py-12 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center"> <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
<span className="text-3xl">{'\uD83D\uDCB3'}</span> <span className="text-3xl">{'\uD83D\uDCB3'}</span>
</div> </div>
<div className="text-dark-400">{t('admin.paymentMethods.noMethods', 'Нет настроенных платёжных методов')}</div> <div className="text-dark-400">
{t('admin.paymentMethods.noMethods', 'Нет настроенных платёжных методов')}
</div>
</div> </div>
)} )}
</div> </div>
@@ -739,9 +810,7 @@ export default function AdminPaymentMethods() {
)} )}
{/* Toast */} {/* Toast */}
{toastMessage && ( {toastMessage && <Toast message={toastMessage} onClose={handleCloseToast} />}
<Toast message={toastMessage} onClose={handleCloseToast} />
)}
</div> </div>
) );
} }

View File

@@ -1,37 +1,42 @@
import { useState } from 'react' import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom';
import { adminPaymentsApi } from '../api/adminPayments' import { adminPaymentsApi } 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';
export default function AdminPayments() { export default function AdminPayments() {
const { t } = useTranslation() const { t } = useTranslation();
const queryClient = useQueryClient() const queryClient = useQueryClient();
const { formatAmount, currencySymbol } = useCurrency() const { formatAmount, currencySymbol } = useCurrency();
const [page, setPage] = useState(1) const [page, setPage] = useState(1);
const [methodFilter, setMethodFilter] = useState<string>('') const [methodFilter, setMethodFilter] = useState<string>('');
const [checkingPaymentId, setCheckingPaymentId] = useState<string | null>(null) const [checkingPaymentId, setCheckingPaymentId] = useState<string | null>(null);
// Fetch payments // Fetch payments
const { data: payments, isLoading, refetch } = useQuery<PaginatedResponse<PendingPayment>>({ const {
data: payments,
isLoading,
refetch,
} = useQuery<PaginatedResponse<PendingPayment>>({
queryKey: ['admin-payments', page, methodFilter], queryKey: ['admin-payments', page, methodFilter],
queryFn: () => adminPaymentsApi.getPendingPayments({ queryFn: () =>
adminPaymentsApi.getPendingPayments({
page, page,
per_page: 20, per_page: 20,
method_filter: methodFilter || undefined, method_filter: methodFilter || undefined,
}), }),
refetchInterval: 30000, // Auto-refresh every 30 seconds refetchInterval: 30000, // Auto-refresh every 30 seconds
}) });
// Fetch stats // Fetch stats
const { data: stats } = useQuery({ const { data: stats } = useQuery({
queryKey: ['admin-payments-stats'], queryKey: ['admin-payments-stats'],
queryFn: adminPaymentsApi.getStats, queryFn: adminPaymentsApi.getStats,
refetchInterval: 30000, refetchInterval: 30000,
}) });
// Check payment mutation // Check payment mutation
const checkPaymentMutation = useMutation({ const checkPaymentMutation = useMutation({
@@ -39,49 +44,66 @@ export default function AdminPayments() {
adminPaymentsApi.checkPaymentStatus(method, paymentId), adminPaymentsApi.checkPaymentStatus(method, paymentId),
onSuccess: async (result) => { onSuccess: async (result) => {
if (result.status_changed) { if (result.status_changed) {
await refetch() await refetch();
queryClient.invalidateQueries({ queryKey: ['admin-payments-stats'] }) queryClient.invalidateQueries({ queryKey: ['admin-payments-stats'] });
} else { } else {
await refetch() await refetch();
} }
}, },
onSettled: () => { onSettled: () => {
setCheckingPaymentId(null) setCheckingPaymentId(null);
}, },
}) });
const handleCheckPayment = (payment: PendingPayment) => { const handleCheckPayment = (payment: PendingPayment) => {
setCheckingPaymentId(`${payment.method}_${payment.id}`) setCheckingPaymentId(`${payment.method}_${payment.id}`);
checkPaymentMutation.mutate({ method: payment.method, paymentId: payment.id }) checkPaymentMutation.mutate({ method: payment.method, paymentId: payment.id });
} };
// Get unique methods from stats for filter // Get unique methods from stats for filter
const methodOptions = stats?.by_method ? Object.keys(stats.by_method) : [] const methodOptions = stats?.by_method ? Object.keys(stats.by_method) : [];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between flex-wrap gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link <Link
to="/admin" to="/admin"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors" 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
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
> >
<svg className="w-5 h-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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg> </svg>
</Link> </Link>
<div> <div>
<h1 className="text-2xl font-bold text-dark-50">{t('admin.payments.title', 'Проверка платежей')}</h1> <h1 className="text-2xl font-bold text-dark-50">
<p className="text-sm text-dark-400">{t('admin.payments.description', 'Ожидающие платежи за последние 24 часа')}</p> {t('admin.payments.title', 'Проверка платежей')}
</h1>
<p className="text-sm text-dark-400">
{t('admin.payments.description', 'Ожидающие платежи за последние 24 часа')}
</p>
</div> </div>
</div> </div>
<button <button onClick={() => refetch()} className="btn-secondary flex items-center gap-2">
onClick={() => refetch()} <svg
className="btn-secondary flex items-center gap-2" className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <path
<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" /> 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> </svg>
{t('common.refresh', 'Обновить')} {t('common.refresh', 'Обновить')}
</button> </button>
@@ -89,18 +111,20 @@ export default function AdminPayments() {
{/* Stats cards */} {/* Stats cards */}
{stats && ( {stats && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<div className="p-4 rounded-xl bg-dark-800/50 border border-dark-700/50"> <div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-4">
<div className="text-2xl font-bold text-dark-50">{stats.total_pending}</div> <div className="text-2xl font-bold text-dark-50">{stats.total_pending}</div>
<div className="text-sm text-dark-400">{t('admin.payments.totalPending', 'Всего ожидает')}</div> <div className="text-sm text-dark-400">
{t('admin.payments.totalPending', 'Всего ожидает')}
</div>
</div> </div>
{Object.entries(stats.by_method).map(([method, count]) => ( {Object.entries(stats.by_method).map(([method, count]) => (
<div <div
key={method} key={method}
className={`p-4 rounded-xl border cursor-pointer transition-all ${ className={`cursor-pointer rounded-xl border p-4 transition-all ${
methodFilter === method methodFilter === method
? 'bg-accent-500/20 border-accent-500/50' ? 'border-accent-500/50 bg-accent-500/20'
: 'bg-dark-800/50 border-dark-700/50 hover:border-dark-600' : 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
}`} }`}
onClick={() => setMethodFilter(methodFilter === method ? '' : method)} onClick={() => setMethodFilter(methodFilter === method ? '' : method)}
> >
@@ -113,11 +137,13 @@ export default function AdminPayments() {
{/* Filter */} {/* Filter */}
{methodOptions.length > 0 && ( {methodOptions.length > 0 && (
<div className="flex items-center gap-3 flex-wrap"> <div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-dark-400">{t('admin.payments.filterByMethod', 'Фильтр по методу')}:</span> <span className="text-sm text-dark-400">
{t('admin.payments.filterByMethod', 'Фильтр по методу')}:
</span>
<button <button
onClick={() => setMethodFilter('')} onClick={() => setMethodFilter('')}
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${ className={`rounded-lg px-3 py-1.5 text-sm transition-all ${
methodFilter === '' methodFilter === ''
? 'bg-accent-500 text-white' ? 'bg-accent-500 text-white'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700' : 'bg-dark-800 text-dark-300 hover:bg-dark-700'
@@ -129,7 +155,7 @@ export default function AdminPayments() {
<button <button
key={method} key={method}
onClick={() => setMethodFilter(method)} onClick={() => setMethodFilter(method)}
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${ className={`rounded-lg px-3 py-1.5 text-sm transition-all ${
methodFilter === method methodFilter === method
? 'bg-accent-500 text-white' ? 'bg-accent-500 text-white'
: 'bg-dark-800 text-dark-300 hover:bg-dark-700' : 'bg-dark-800 text-dark-300 hover:bg-dark-700'
@@ -145,28 +171,30 @@ export default function AdminPayments() {
<div className="card"> <div className="card">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" /> <div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div> </div>
) : payments?.items && payments.items.length > 0 ? ( ) : payments?.items && payments.items.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
{payments.items.map((payment) => { {payments.items.map((payment) => {
const paymentKey = `${payment.method}_${payment.id}` const paymentKey = `${payment.method}_${payment.id}`;
const isChecking = checkingPaymentId === paymentKey const isChecking = checkingPaymentId === paymentKey;
return ( return (
<div <div
key={paymentKey} key={paymentKey}
className="p-4 rounded-xl bg-dark-800/30 border border-dark-700/30" className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-4"
> >
<div className="flex items-start justify-between gap-4 flex-wrap"> <div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-2 flex-wrap"> <div className="mb-2 flex flex-wrap items-center gap-2">
<span className="font-semibold text-dark-100">{payment.method_display}</span> <span className="font-semibold text-dark-100">
<span className="text-sm px-2 py-0.5 rounded-full bg-dark-700/50 text-dark-300"> {payment.method_display}
</span>
<span className="rounded-full bg-dark-700/50 px-2 py-0.5 text-sm text-dark-300">
{payment.status_emoji} {payment.status_text} {payment.status_emoji} {payment.status_text}
</span> </span>
{payment.is_paid && ( {payment.is_paid && (
<span className="text-sm px-2 py-0.5 rounded-full bg-success-500/20 text-success-400"> <span className="rounded-full bg-success-500/20 px-2 py-0.5 text-sm text-success-400">
{t('admin.payments.paid', 'Оплачено')} {t('admin.payments.paid', 'Оплачено')}
</span> </span>
)} )}
@@ -174,16 +202,18 @@ export default function AdminPayments() {
<div className="text-lg font-semibold text-dark-50"> <div className="text-lg font-semibold text-dark-50">
{formatAmount(payment.amount_rubles)} {currencySymbol} {formatAmount(payment.amount_rubles)} {currencySymbol}
</div> </div>
<div className="text-sm text-dark-400 mt-1"> <div className="mt-1 text-sm text-dark-400">
ID: <code className="text-dark-300">{payment.identifier}</code> ID: <code className="text-dark-300">{payment.identifier}</code>
</div> </div>
<div className="text-xs text-dark-500 mt-1"> <div className="mt-1 text-xs text-dark-500">
{new Date(payment.created_at).toLocaleString()} {new Date(payment.created_at).toLocaleString()}
</div> </div>
{/* User info */} {/* User info */}
{(payment.user_username || payment.user_telegram_id) && ( {(payment.user_username || payment.user_telegram_id) && (
<div className="text-sm text-dark-400 mt-2"> <div className="mt-2 text-sm text-dark-400">
<span className="text-dark-500">{t('admin.payments.user', 'Пользователь')}:</span>{' '} <span className="text-dark-500">
{t('admin.payments.user', 'Пользователь')}:
</span>{' '}
{payment.user_username ? ( {payment.user_username ? (
<span className="text-dark-200">@{payment.user_username}</span> <span className="text-dark-200">@{payment.user_username}</span>
) : ( ) : (
@@ -198,7 +228,7 @@ export default function AdminPayments() {
href={payment.payment_url} href={payment.payment_url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="btn-secondary text-xs px-3 py-1.5" className="btn-secondary px-3 py-1.5 text-xs"
> >
{t('admin.payments.openLink', 'Открыть ссылку')} {t('admin.payments.openLink', 'Открыть ссылку')}
</a> </a>
@@ -207,13 +237,24 @@ export default function AdminPayments() {
<button <button
onClick={() => handleCheckPayment(payment)} onClick={() => handleCheckPayment(payment)}
disabled={isChecking} disabled={isChecking}
className="btn-primary text-xs px-3 py-1.5" className="btn-primary px-3 py-1.5 text-xs"
> >
{isChecking ? ( {isChecking ? (
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none"> <svg className="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> <circle
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" /> className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg> </svg>
{t('admin.payments.checking', 'Проверка...')} {t('admin.payments.checking', 'Проверка...')}
</span> </span>
@@ -225,52 +266,70 @@ export default function AdminPayments() {
</div> </div>
</div> </div>
{/* Show result after check */} {/* Show result after check */}
{checkPaymentMutation.isSuccess && checkPaymentMutation.variables?.paymentId === payment.id && ( {checkPaymentMutation.isSuccess &&
<div className={`mt-3 p-2 rounded-lg text-sm ${ checkPaymentMutation.variables?.paymentId === payment.id && (
<div
className={`mt-3 rounded-lg p-2 text-sm ${
checkPaymentMutation.data?.status_changed checkPaymentMutation.data?.status_changed
? 'bg-success-500/10 border border-success-500/30 text-success-400' ? 'border border-success-500/30 bg-success-500/10 text-success-400'
: 'bg-dark-700/30 text-dark-400' : 'bg-dark-700/30 text-dark-400'
}`}> }`}
>
{checkPaymentMutation.data?.message} {checkPaymentMutation.data?.message}
</div> </div>
)} )}
</div> </div>
) );
})} })}
</div> </div>
) : ( ) : (
<div className="text-center py-12"> <div className="py-12 text-center">
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center"> <div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg
<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" /> 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> </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>
)} )}
{/* Pagination */} {/* Pagination */}
{payments && payments.pages > 1 && ( {payments && payments.pages > 1 && (
<div className="mt-4 flex items-center gap-3 flex-wrap text-sm text-dark-500"> <div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-dark-500">
<button <button
type="button" type="button"
onClick={() => setPage((prev) => Math.max(1, prev - 1))} onClick={() => setPage((prev) => Math.max(1, prev - 1))}
disabled={payments.page <= 1} disabled={payments.page <= 1}
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[100px] ${ className={`btn-secondary min-w-[100px] flex-1 text-xs sm:flex-none sm:text-sm ${
payments.page <= 1 ? 'opacity-50 cursor-not-allowed' : '' payments.page <= 1 ? 'cursor-not-allowed opacity-50' : ''
}`} }`}
> >
{t('common.back', 'Назад')} {t('common.back', 'Назад')}
</button> </button>
<div className="flex-1 text-center"> <div className="flex-1 text-center">
{t('balance.page', '{current} / {total}', { current: payments.page, total: payments.pages })} {t('balance.page', '{current} / {total}', {
current: payments.page,
total: payments.pages,
})}
</div> </div>
<button <button
type="button" type="button"
onClick={() => setPage((prev) => Math.min(payments.pages, prev + 1))} onClick={() => setPage((prev) => Math.min(payments.pages, prev + 1))}
disabled={payments.page >= payments.pages} disabled={payments.page >= payments.pages}
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[100px] ${ className={`btn-secondary min-w-[100px] flex-1 text-xs sm:flex-none sm:text-sm ${
payments.page >= payments.pages ? 'opacity-50 cursor-not-allowed' : '' payments.page >= payments.pages ? 'cursor-not-allowed opacity-50' : ''
}`} }`}
> >
{t('common.next', 'Далее')} {t('common.next', 'Далее')}
@@ -279,5 +338,5 @@ export default function AdminPayments() {
)} )}
</div> </div>
</div> </div>
) );
} }

View File

@@ -1,77 +1,104 @@
import { useState } from 'react' import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom';
import { import {
promoOffersApi, promoOffersApi,
PromoOfferTemplate, PromoOfferTemplate,
PromoOfferTemplateUpdateRequest, PromoOfferTemplateUpdateRequest,
PromoOfferBroadcastRequest,
PromoOfferLog, PromoOfferLog,
TARGET_SEGMENTS, TARGET_SEGMENTS,
TargetSegment, TargetSegment,
OFFER_TYPE_CONFIG, OFFER_TYPE_CONFIG,
OfferType, OfferType,
} from '../api/promoOffers' } from '../api/promoOffers';
// Icons // Icons
const BackIcon = () => ( const BackIcon = () => (
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg> </svg>
) );
const EditIcon = () => ( const EditIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <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> </svg>
) );
const XIcon = () => ( const XIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
) );
const SendIcon = () => ( const SendIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg 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" /> <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> </svg>
) );
const ClockIcon = () => ( const ClockIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
) );
const UserIcon = () => ( const UserIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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 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" /> <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> </svg>
) );
const CheckIcon = () => ( const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg> </svg>
) );
const UsersIcon = () => ( const UsersIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <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" /> <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> </svg>
) );
// Helper functions // Helper functions
const formatDateTime = (date: string | null): string => { const formatDateTime = (date: string | null): string => {
if (!date) return '-' if (!date) return '-';
return new Date(date).toLocaleString('ru-RU', { return new Date(date).toLocaleString('ru-RU', {
day: '2-digit', day: '2-digit',
month: '2-digit', month: '2-digit',
year: 'numeric', year: 'numeric',
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}) });
} };
const getActionLabel = (action: string): string => { const getActionLabel = (action: string): string => {
const labels: Record<string, string> = { const labels: Record<string, string> = {
@@ -79,9 +106,9 @@ const getActionLabel = (action: string): string => {
claimed: 'Активировано', claimed: 'Активировано',
consumed: 'Использовано', consumed: 'Использовано',
disabled: 'Деактивировано', disabled: 'Деактивировано',
} };
return labels[action] || action return labels[action] || action;
} };
const getActionColor = (action: string): string => { const getActionColor = (action: string): string => {
const colors: Record<string, string> = { const colors: Record<string, string> = {
@@ -89,37 +116,39 @@ const getActionColor = (action: string): string => {
claimed: 'bg-emerald-500/20 text-emerald-400', claimed: 'bg-emerald-500/20 text-emerald-400',
consumed: 'bg-purple-500/20 text-purple-400', consumed: 'bg-purple-500/20 text-purple-400',
disabled: 'bg-dark-600 text-dark-400', disabled: 'bg-dark-600 text-dark-400',
} };
return colors[action] || 'bg-dark-600 text-dark-400' return colors[action] || 'bg-dark-600 text-dark-400';
} };
const getOfferTypeIcon = (offerType: string): string => { const getOfferTypeIcon = (offerType: string): string => {
return OFFER_TYPE_CONFIG[offerType as OfferType]?.icon || '🎁' return OFFER_TYPE_CONFIG[offerType as OfferType]?.icon || '🎁';
} };
const getOfferTypeLabel = (offerType: string): string => { const getOfferTypeLabel = (offerType: string): string => {
return OFFER_TYPE_CONFIG[offerType as OfferType]?.label || offerType return OFFER_TYPE_CONFIG[offerType as OfferType]?.label || offerType;
} };
// Template Edit Modal // Template Edit Modal
interface TemplateEditModalProps { interface TemplateEditModalProps {
template: PromoOfferTemplate template: PromoOfferTemplate;
onSave: (data: PromoOfferTemplateUpdateRequest) => void onSave: (data: PromoOfferTemplateUpdateRequest) => void;
onClose: () => void onClose: () => void;
isLoading?: boolean isLoading?: boolean;
} }
function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEditModalProps) { function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEditModalProps) {
const [name, setName] = useState(template.name) const [name, setName] = useState(template.name);
const [messageText, setMessageText] = useState(template.message_text) const [messageText, setMessageText] = useState(template.message_text);
const [buttonText, setButtonText] = useState(template.button_text) const [buttonText, setButtonText] = useState(template.button_text);
const [validHours, setValidHours] = useState(template.valid_hours) const [validHours, setValidHours] = useState(template.valid_hours);
const [discountPercent, setDiscountPercent] = useState(template.discount_percent) const [discountPercent, setDiscountPercent] = useState(template.discount_percent);
const [activeDiscountHours, setActiveDiscountHours] = useState(template.active_discount_hours || 0) const [activeDiscountHours, setActiveDiscountHours] = useState(
const [testDurationHours, setTestDurationHours] = useState(template.test_duration_hours || 0) template.active_discount_hours || 0,
const [isActive, setIsActive] = useState(template.is_active) );
const [testDurationHours, setTestDurationHours] = useState(template.test_duration_hours || 0);
const [isActive, setIsActive] = useState(template.is_active);
const isTestAccess = template.offer_type === 'test_access' const isTestAccess = template.offer_type === 'test_access';
const handleSubmit = () => { const handleSubmit = () => {
const data: PromoOfferTemplateUpdateRequest = { const data: PromoOfferTemplateUpdateRequest = {
@@ -129,84 +158,84 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
valid_hours: validHours, valid_hours: validHours,
discount_percent: discountPercent, discount_percent: discountPercent,
is_active: isActive, is_active: isActive,
} };
if (isTestAccess) { if (isTestAccess) {
data.test_duration_hours = testDurationHours > 0 ? testDurationHours : undefined data.test_duration_hours = testDurationHours > 0 ? testDurationHours : undefined;
} else { } else {
data.active_discount_hours = activeDiscountHours > 0 ? activeDiscountHours : undefined data.active_discount_hours = activeDiscountHours > 0 ? activeDiscountHours : undefined;
}
onSave(data)
} }
onSave(data);
};
return ( return (
<div className="fixed inset-0 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col"> <div className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-xl bg-dark-800">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700"> <div className="flex items-center justify-between border-b border-dark-700 p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span> <span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span>
<h2 className="text-lg font-semibold text-dark-100"> <h2 className="text-lg font-semibold text-dark-100">Редактирование шаблона</h2>
Редактирование шаблона
</h2>
</div> </div>
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors"> <button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
<XIcon /> <XIcon />
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4"> <div className="flex-1 space-y-4 overflow-y-auto p-4">
<div> <div>
<label className="block text-sm text-dark-300 mb-1">Название шаблона</label> <label className="mb-1 block text-sm text-dark-300">Название шаблона</label>
<input <input
type="text" type="text"
value={name} value={name}
onChange={e => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
/> />
</div> </div>
<div> <div>
<label className="block text-sm text-dark-300 mb-1">Текст сообщения</label> <label className="mb-1 block text-sm text-dark-300">Текст сообщения</label>
<textarea <textarea
value={messageText} value={messageText}
onChange={e => setMessageText(e.target.value)} onChange={(e) => setMessageText(e.target.value)}
rows={4} rows={4}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500 resize-none" className="w-full resize-none rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
/> />
</div> </div>
<div> <div>
<label className="block text-sm text-dark-300 mb-1">Текст кнопки</label> <label className="mb-1 block text-sm text-dark-300">Текст кнопки</label>
<input <input
type="text" type="text"
value={buttonText} value={buttonText}
onChange={e => setButtonText(e.target.value)} onChange={(e) => setButtonText(e.target.value)}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm text-dark-300 mb-1">Срок предложения (часы)</label> <label className="mb-1 block text-sm text-dark-300">Срок предложения (часы)</label>
<input <input
type="number" type="number"
value={validHours} value={validHours}
onChange={e => setValidHours(Math.max(1, parseInt(e.target.value) || 1))} onChange={(e) => setValidHours(Math.max(1, parseInt(e.target.value) || 1))}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
min={1} min={1}
/> />
<p className="text-xs text-dark-500 mt-1">Время на активацию</p> <p className="mt-1 text-xs text-dark-500">Время на активацию</p>
</div> </div>
{!isTestAccess && ( {!isTestAccess && (
<div> <div>
<label className="block text-sm text-dark-300 mb-1">Скидка (%)</label> <label className="mb-1 block text-sm text-dark-300">Скидка (%)</label>
<input <input
type="number" type="number"
value={discountPercent} value={discountPercent}
onChange={e => setDiscountPercent(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))} onChange={(e) =>
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" setDiscountPercent(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))
}
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
min={0} min={0}
max={100} max={100}
/> />
@@ -216,40 +245,42 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
{isTestAccess ? ( {isTestAccess ? (
<div> <div>
<label className="block text-sm text-dark-300 mb-1">Длительность доступа (часы)</label> <label className="mb-1 block text-sm text-dark-300">
Длительность доступа (часы)
</label>
<input <input
type="number" type="number"
value={testDurationHours} value={testDurationHours}
onChange={e => setTestDurationHours(Math.max(0, parseInt(e.target.value) || 0))} onChange={(e) => setTestDurationHours(Math.max(0, parseInt(e.target.value) || 0))}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
min={0} min={0}
/> />
<p className="text-xs text-dark-500 mt-1">0 = по умолчанию</p> <p className="mt-1 text-xs text-dark-500">0 = по умолчанию</p>
</div> </div>
) : ( ) : (
<div> <div>
<label className="block text-sm text-dark-300 mb-1">Действие скидки (часы)</label> <label className="mb-1 block text-sm text-dark-300">Действие скидки (часы)</label>
<input <input
type="number" type="number"
value={activeDiscountHours} value={activeDiscountHours}
onChange={e => setActiveDiscountHours(Math.max(0, parseInt(e.target.value) || 0))} onChange={(e) => setActiveDiscountHours(Math.max(0, parseInt(e.target.value) || 0))}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
min={0} min={0}
/> />
<p className="text-xs text-dark-500 mt-1">Сколько действует скидка после активации</p> <p className="mt-1 text-xs text-dark-500">Сколько действует скидка после активации</p>
</div> </div>
)} )}
<label className="flex items-center gap-3 cursor-pointer"> <label className="flex cursor-pointer items-center gap-3">
<button <button
type="button" type="button"
onClick={() => setIsActive(!isActive)} onClick={() => setIsActive(!isActive)}
className={`w-10 h-6 rounded-full transition-colors relative ${ className={`relative h-6 w-10 rounded-full transition-colors ${
isActive ? 'bg-accent-500' : 'bg-dark-600' isActive ? 'bg-accent-500' : 'bg-dark-600'
}`} }`}
> >
<span <span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${ className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
isActive ? 'left-5' : 'left-1' isActive ? 'left-5' : 'left-1'
}`} }`}
/> />
@@ -259,89 +290,89 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex justify-end gap-3 p-4 border-t border-dark-700"> <div className="flex justify-end gap-3 border-t border-dark-700 p-4">
<button <button
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors" className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
> >
Отмена Отмена
</button> </button>
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!name.trim() || isLoading} disabled={!name.trim() || isLoading}
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
> >
{isLoading ? 'Сохранение...' : 'Сохранить'} {isLoading ? 'Сохранение...' : 'Сохранить'}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
) );
} }
// Send Offer Modal // Send Offer Modal
interface SendOfferModalProps { interface SendOfferModalProps {
templates: PromoOfferTemplate[] templates: PromoOfferTemplate[];
onSend: (templateId: number, target: string | null, userId: number | null) => void onSend: (templateId: number, target: string | null, userId: number | null) => void;
onClose: () => void onClose: () => void;
isLoading?: boolean isLoading?: boolean;
} }
function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModalProps) { function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModalProps) {
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(templates[0]?.id || null) const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(
const [sendMode, setSendMode] = useState<'segment' | 'user'>('segment') templates[0]?.id || null,
const [selectedTarget, setSelectedTarget] = useState<TargetSegment>('active') );
const [userId, setUserId] = useState('') const [sendMode, setSendMode] = useState<'segment' | 'user'>('segment');
const [selectedTarget, setSelectedTarget] = useState<TargetSegment>('active');
const [userId, setUserId] = useState('');
const selectedTemplate = templates.find(t => t.id === selectedTemplateId) const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
const activeTemplates = templates.filter(t => t.is_active) const activeTemplates = templates.filter((t) => t.is_active);
const handleSubmit = () => { const handleSubmit = () => {
if (!selectedTemplateId) return if (!selectedTemplateId) return;
if (sendMode === 'user') { if (sendMode === 'user') {
const id = parseInt(userId) const id = parseInt(userId);
if (!id) return if (!id) return;
onSend(selectedTemplateId, null, id) onSend(selectedTemplateId, null, id);
} else { } else {
onSend(selectedTemplateId, selectedTarget, null) onSend(selectedTemplateId, selectedTarget, null);
}
} }
};
const isValid = () => { const isValid = () => {
if (!selectedTemplateId) return false if (!selectedTemplateId) return false;
if (sendMode === 'user' && !userId.trim()) return false if (sendMode === 'user' && !userId.trim()) return false;
return true return true;
} };
return ( return (
<div className="fixed inset-0 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col"> <div className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-xl bg-dark-800">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700"> <div className="flex items-center justify-between border-b border-dark-700 p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-accent-500/20 rounded-lg"> <div className="rounded-lg bg-accent-500/20 p-2">
<SendIcon /> <SendIcon />
</div> </div>
<h2 className="text-lg font-semibold text-dark-100"> <h2 className="text-lg font-semibold text-dark-100">Отправка промопредложения</h2>
Отправка промопредложения
</h2>
</div> </div>
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors"> <button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
<XIcon /> <XIcon />
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4"> <div className="flex-1 space-y-4 overflow-y-auto p-4">
{/* Template Selection */} {/* Template Selection */}
<div> <div>
<label className="block text-sm text-dark-300 mb-2">Шаблон предложения</label> <label className="mb-2 block text-sm text-dark-300">Шаблон предложения</label>
<div className="space-y-2"> <div className="space-y-2">
{activeTemplates.map(template => ( {activeTemplates.map((template) => (
<button <button
key={template.id} key={template.id}
onClick={() => setSelectedTemplateId(template.id)} onClick={() => setSelectedTemplateId(template.id)}
className={`w-full p-3 rounded-lg border text-left transition-colors ${ className={`w-full rounded-lg border p-3 text-left transition-colors ${
selectedTemplateId === template.id selectedTemplateId === template.id
? 'border-accent-500 bg-accent-500/10' ? 'border-accent-500 bg-accent-500/10'
: 'border-dark-600 bg-dark-700 hover:border-dark-500' : 'border-dark-600 bg-dark-700 hover:border-dark-500'
@@ -358,9 +389,7 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
{template.valid_hours}ч на активацию {template.valid_hours}ч на активацию
</div> </div>
</div> </div>
{selectedTemplateId === template.id && ( {selectedTemplateId === template.id && <CheckIcon />}
<CheckIcon />
)}
</div> </div>
</button> </button>
))} ))}
@@ -369,11 +398,11 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
{/* Send Mode */} {/* Send Mode */}
<div> <div>
<label className="block text-sm text-dark-300 mb-2">Кому отправить</label> <label className="mb-2 block text-sm text-dark-300">Кому отправить</label>
<div className="flex gap-2 mb-3"> <div className="mb-3 flex gap-2">
<button <button
onClick={() => setSendMode('segment')} onClick={() => setSendMode('segment')}
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${ className={`flex-1 rounded-lg border py-2 text-sm font-medium transition-colors ${
sendMode === 'segment' sendMode === 'segment'
? 'border-accent-500 bg-accent-500/10 text-accent-400' ? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-600 text-dark-400 hover:text-dark-200' : 'border-dark-600 text-dark-400 hover:text-dark-200'
@@ -384,7 +413,7 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
</button> </button>
<button <button
onClick={() => setSendMode('user')} onClick={() => setSendMode('user')}
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${ className={`flex-1 rounded-lg border py-2 text-sm font-medium transition-colors ${
sendMode === 'user' sendMode === 'user'
? 'border-accent-500 bg-accent-500/10 text-accent-400' ? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-600 text-dark-400 hover:text-dark-200' : 'border-dark-600 text-dark-400 hover:text-dark-200'
@@ -398,33 +427,35 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
{sendMode === 'segment' ? ( {sendMode === 'segment' ? (
<select <select
value={selectedTarget} value={selectedTarget}
onChange={e => setSelectedTarget(e.target.value as TargetSegment)} onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
> >
{Object.entries(TARGET_SEGMENTS).map(([key, label]) => ( {Object.entries(TARGET_SEGMENTS).map(([key, label]) => (
<option key={key} value={key}>{label}</option> <option key={key} value={key}>
{label}
</option>
))} ))}
</select> </select>
) : ( ) : (
<input <input
type="text" type="text"
value={userId} value={userId}
onChange={e => setUserId(e.target.value)} onChange={(e) => setUserId(e.target.value)}
placeholder="Telegram ID или User ID" placeholder="Telegram ID или User ID"
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
/> />
)} )}
</div> </div>
{/* Preview */} {/* Preview */}
{selectedTemplate && ( {selectedTemplate && (
<div className="p-4 bg-dark-700/50 rounded-lg"> <div className="rounded-lg bg-dark-700/50 p-4">
<h4 className="text-sm font-medium text-dark-300 mb-2">Предпросмотр</h4> <h4 className="mb-2 text-sm font-medium text-dark-300">Предпросмотр</h4>
<div className="text-sm text-dark-200 whitespace-pre-wrap"> <div className="whitespace-pre-wrap text-sm text-dark-200">
{selectedTemplate.message_text} {selectedTemplate.message_text}
</div> </div>
<div className="mt-3"> <div className="mt-3">
<span className="inline-block px-3 py-1.5 bg-accent-500 text-white text-sm rounded-lg"> <span className="inline-block rounded-lg bg-accent-500 px-3 py-1.5 text-sm text-white">
{selectedTemplate.button_text} {selectedTemplate.button_text}
</span> </span>
</div> </div>
@@ -433,17 +464,17 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex justify-end gap-3 p-4 border-t border-dark-700"> <div className="flex justify-end gap-3 border-t border-dark-700 p-4">
<button <button
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors" className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
> >
Отмена Отмена
</button> </button>
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!isValid() || isLoading} disabled={!isValid() || isLoading}
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
> >
<SendIcon /> <SendIcon />
{isLoading ? 'Отправка...' : 'Отправить'} {isLoading ? 'Отправка...' : 'Отправить'}
@@ -451,88 +482,106 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
</div> </div>
</div> </div>
</div> </div>
) );
} }
// Result Modal // Result Modal
interface ResultModalProps { interface ResultModalProps {
title: string title: string;
message: string message: string;
isSuccess: boolean isSuccess: boolean;
onClose: () => void onClose: () => void;
} }
function ResultModal({ title, message, isSuccess, onClose }: ResultModalProps) { function ResultModal({ title, message, isSuccess, onClose }: ResultModalProps) {
return ( return (
<div className="fixed inset-0 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="bg-dark-800 rounded-xl p-6 max-w-sm w-full text-center"> <div className="w-full max-w-sm rounded-xl bg-dark-800 p-6 text-center">
<div className={`w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center ${ <div
className={`mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full ${
isSuccess ? 'bg-emerald-500/20' : 'bg-error-500/20' isSuccess ? 'bg-emerald-500/20' : 'bg-error-500/20'
}`}> }`}
>
{isSuccess ? ( {isSuccess ? (
<svg className="w-8 h-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
className="h-8 w-8 text-emerald-400"
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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg> </svg>
) : ( ) : (
<svg className="w-8 h-8 text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
className="h-8 w-8 text-error-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
)} )}
</div> </div>
<h3 className="text-lg font-semibold text-dark-100 mb-2">{title}</h3> <h3 className="mb-2 text-lg font-semibold text-dark-100">{title}</h3>
<p className="text-dark-400 mb-6">{message}</p> <p className="mb-6 text-dark-400">{message}</p>
<button <button
onClick={onClose} onClick={onClose}
className="px-6 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors" className="rounded-lg bg-accent-500 px-6 py-2 text-white transition-colors hover:bg-accent-600"
> >
Закрыть Закрыть
</button> </button>
</div> </div>
</div> </div>
) );
} }
export default function AdminPromoOffers() { export default function AdminPromoOffers() {
const queryClient = useQueryClient() const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<'templates' | 'send' | 'logs'>('templates') const [activeTab, setActiveTab] = useState<'templates' | 'send' | 'logs'>('templates');
const [editingTemplate, setEditingTemplate] = useState<PromoOfferTemplate | null>(null) const [editingTemplate, setEditingTemplate] = useState<PromoOfferTemplate | null>(null);
const [showSendModal, setShowSendModal] = useState(false) const [showSendModal, setShowSendModal] = useState(false);
const [resultModal, setResultModal] = useState<{ title: string; message: string; isSuccess: boolean } | null>(null) const [resultModal, setResultModal] = useState<{
title: string;
message: string;
isSuccess: boolean;
} | null>(null);
// Queries // Queries
const { data: templatesData, isLoading: templatesLoading } = useQuery({ const { data: templatesData, isLoading: templatesLoading } = useQuery({
queryKey: ['admin-promo-templates'], queryKey: ['admin-promo-templates'],
queryFn: promoOffersApi.getTemplates, queryFn: promoOffersApi.getTemplates,
}) });
const { data: logsData, isLoading: logsLoading } = useQuery({ const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['admin-promo-logs'], queryKey: ['admin-promo-logs'],
queryFn: () => promoOffersApi.getLogs({ limit: 100 }), queryFn: () => promoOffersApi.getLogs({ limit: 100 }),
enabled: activeTab === 'logs', enabled: activeTab === 'logs',
}) });
// Mutations // Mutations
const updateTemplateMutation = useMutation({ const updateTemplateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: PromoOfferTemplateUpdateRequest }) => mutationFn: ({ id, data }: { id: number; data: PromoOfferTemplateUpdateRequest }) =>
promoOffersApi.updateTemplate(id, data), promoOffersApi.updateTemplate(id, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-promo-templates'] }) queryClient.invalidateQueries({ queryKey: ['admin-promo-templates'] });
setEditingTemplate(null) setEditingTemplate(null);
}, },
}) });
const broadcastMutation = useMutation({ const broadcastMutation = useMutation({
mutationFn: promoOffersApi.broadcastOffer, mutationFn: promoOffersApi.broadcastOffer,
onSuccess: (result) => { onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] }) queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] });
setShowSendModal(false) setShowSendModal(false);
let message = `Создано предложений: ${result.created_offers}` let message = `Создано предложений: ${result.created_offers}`;
if (result.notifications_sent > 0 || result.notifications_failed > 0) { if (result.notifications_sent > 0 || result.notifications_failed > 0) {
message += `\nУведомлений отправлено: ${result.notifications_sent}` message += `\nУведомлений отправлено: ${result.notifications_sent}`;
if (result.notifications_failed > 0) { if (result.notifications_failed > 0) {
message += ` (не доставлено: ${result.notifications_failed})` message += ` (не доставлено: ${result.notifications_failed})`;
} }
} }
@@ -540,22 +589,23 @@ export default function AdminPromoOffers() {
title: 'Отправлено!', title: 'Отправлено!',
message, message,
isSuccess: true, isSuccess: true,
}) });
}, },
onError: (error: any) => { onError: (error: unknown) => {
const axiosErr = error as { response?: { data?: { detail?: string } } };
setResultModal({ setResultModal({
title: 'Ошибка', title: 'Ошибка',
message: error.response?.data?.detail || 'Не удалось отправить предложение', message: axiosErr.response?.data?.detail || 'Не удалось отправить предложение',
isSuccess: false, isSuccess: false,
}) });
}, },
}) });
const handleSendOffer = (templateId: number, target: string | null, userId: number | null) => { const handleSendOffer = (templateId: number, target: string | null, userId: number | null) => {
const template = templatesData?.items.find(t => t.id === templateId) const template = templatesData?.items.find((t) => t.id === templateId);
if (!template) return if (!template) return;
const data: any = { const data: PromoOfferBroadcastRequest = {
notification_type: template.offer_type, notification_type: template.offer_type,
valid_hours: template.valid_hours, valid_hours: template.valid_hours,
discount_percent: template.discount_percent, discount_percent: template.discount_percent,
@@ -570,29 +620,24 @@ export default function AdminPromoOffers() {
send_notification: true, send_notification: true,
message_text: template.message_text, message_text: template.message_text,
button_text: template.button_text, button_text: template.button_text,
} ...(target ? { target } : {}),
...(userId ? { telegram_id: userId } : {}),
};
if (target) { broadcastMutation.mutate(data);
data.target = target };
}
if (userId) {
data.telegram_id = userId
}
broadcastMutation.mutate(data) const templates = templatesData?.items || [];
} const logs = logsData?.items || [];
const templates = templatesData?.items || []
const logs = logsData?.items || []
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link <Link
to="/admin" to="/admin"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors" 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 />
</Link> </Link>
@@ -603,7 +648,7 @@ export default function AdminPromoOffers() {
</div> </div>
<button <button
onClick={() => setShowSendModal(true)} onClick={() => setShowSendModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors" className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
> >
<SendIcon /> <SendIcon />
Отправить Отправить
@@ -611,10 +656,10 @@ export default function AdminPromoOffers() {
</div> </div>
{/* Tabs */} {/* Tabs */}
<div className="flex gap-1 mb-6 p-1 bg-dark-800 rounded-lg w-fit"> <div className="mb-6 flex w-fit gap-1 rounded-lg bg-dark-800 p-1">
<button <button
onClick={() => setActiveTab('templates')} onClick={() => setActiveTab('templates')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${ className={`rounded-md px-4 py-2 text-sm font-medium transition-colors ${
activeTab === 'templates' activeTab === 'templates'
? 'bg-dark-700 text-dark-100' ? 'bg-dark-700 text-dark-100'
: 'text-dark-400 hover:text-dark-200' : 'text-dark-400 hover:text-dark-200'
@@ -624,10 +669,8 @@ export default function AdminPromoOffers() {
</button> </button>
<button <button
onClick={() => setActiveTab('logs')} onClick={() => setActiveTab('logs')}
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${ className={`rounded-md px-4 py-2 text-sm font-medium transition-colors ${
activeTab === 'logs' activeTab === 'logs' ? 'bg-dark-700 text-dark-100' : 'text-dark-400 hover:text-dark-200'
? 'bg-dark-700 text-dark-100'
: 'text-dark-400 hover:text-dark-200'
}`} }`}
> >
Логи Логи
@@ -639,10 +682,10 @@ export default function AdminPromoOffers() {
<> <>
{templatesLoading ? ( {templatesLoading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" /> <div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div> </div>
) : templates.length === 0 ? ( ) : templates.length === 0 ? (
<div className="text-center py-12"> <div className="py-12 text-center">
<p className="text-dark-400">Нет шаблонов</p> <p className="text-dark-400">Нет шаблонов</p>
</div> </div>
) : ( ) : (
@@ -650,21 +693,23 @@ export default function AdminPromoOffers() {
{templates.map((template) => ( {templates.map((template) => (
<div <div
key={template.id} key={template.id}
className={`p-4 bg-dark-800 rounded-xl border transition-colors ${ className={`rounded-xl border bg-dark-800 p-4 transition-colors ${
template.is_active ? 'border-dark-700' : 'border-dark-700/50 opacity-60' template.is_active ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
}`} }`}
> >
<div className="flex items-start justify-between mb-3"> <div className="mb-3 flex items-start justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span> <span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span>
<div> <div>
<h3 className="font-medium text-dark-100">{template.name}</h3> <h3 className="font-medium text-dark-100">{template.name}</h3>
<span className="text-xs text-dark-500">{getOfferTypeLabel(template.offer_type)}</span> <span className="text-xs text-dark-500">
{getOfferTypeLabel(template.offer_type)}
</span>
</div> </div>
</div> </div>
<button <button
onClick={() => setEditingTemplate(template)} onClick={() => setEditingTemplate(template)}
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors" className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
> >
<EditIcon /> <EditIcon />
</button> </button>
@@ -674,7 +719,9 @@ export default function AdminPromoOffers() {
{template.discount_percent > 0 && ( {template.discount_percent > 0 && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-dark-400">Скидка:</span> <span className="text-dark-400">Скидка:</span>
<span className="text-accent-400 font-medium">{template.discount_percent}%</span> <span className="font-medium text-accent-400">
{template.discount_percent}%
</span>
</div> </div>
)} )}
<div className="flex justify-between"> <div className="flex justify-between">
@@ -695,14 +742,14 @@ export default function AdminPromoOffers() {
)} )}
</div> </div>
<div className="mt-3 pt-3 border-t border-dark-700"> <div className="mt-3 border-t border-dark-700 pt-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{template.is_active ? ( {template.is_active ? (
<span className="px-2 py-0.5 text-xs bg-emerald-500/20 text-emerald-400 rounded"> <span className="rounded bg-emerald-500/20 px-2 py-0.5 text-xs text-emerald-400">
Активен Активен
</span> </span>
) : ( ) : (
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded"> <span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
Неактивен Неактивен
</span> </span>
)} )}
@@ -720,37 +767,34 @@ export default function AdminPromoOffers() {
<> <>
{logsLoading ? ( {logsLoading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" /> <div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div> </div>
) : logs.length === 0 ? ( ) : logs.length === 0 ? (
<div className="text-center py-12"> <div className="py-12 text-center">
<p className="text-dark-400">Нет записей</p> <p className="text-dark-400">Нет записей</p>
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{logs.map((log: PromoOfferLog) => ( {logs.map((log: PromoOfferLog) => (
<div <div key={log.id} className="rounded-xl border border-dark-700 bg-dark-800 p-4">
key={log.id}
className="p-4 bg-dark-800 rounded-xl border border-dark-700"
>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 bg-dark-700 rounded-full flex items-center justify-center"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-dark-700">
<UserIcon /> <UserIcon />
</div> </div>
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="mb-1 flex items-center gap-2">
<span className="font-medium text-dark-100"> <span className="font-medium text-dark-100">
{log.user?.full_name || log.user?.username || `User #${log.user_id}`} {log.user?.full_name || log.user?.username || `User #${log.user_id}`}
</span> </span>
<span className={`px-2 py-0.5 text-xs rounded ${getActionColor(log.action)}`}> <span
className={`rounded px-2 py-0.5 text-xs ${getActionColor(log.action)}`}
>
{getActionLabel(log.action)} {getActionLabel(log.action)}
</span> </span>
</div> </div>
<div className="text-sm text-dark-400"> <div className="text-sm text-dark-400">
{log.source && ( {log.source && <span>{getOfferTypeLabel(log.source)}</span>}
<span>{getOfferTypeLabel(log.source)}</span>
)}
{log.percent && log.percent > 0 && ( {log.percent && log.percent > 0 && (
<span className="ml-2 text-accent-400">{log.percent}%</span> <span className="ml-2 text-accent-400">{log.percent}%</span>
)} )}
@@ -799,5 +843,5 @@ export default function AdminPromoOffers() {
/> />
)} )}
</div> </div>
) );
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,80 +1,113 @@
import { useState } from 'react' import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom';
import { import { serversApi, ServerListItem, ServerDetail, ServerUpdateRequest } from '../api/servers';
serversApi,
ServerListItem,
ServerDetail,
ServerUpdateRequest
} from '../api/servers'
// Icons // Icons
const BackIcon = () => ( const BackIcon = () => (
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg> </svg>
) );
const SyncIcon = () => ( const SyncIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <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> </svg>
) );
const EditIcon = () => ( const EditIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <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> </svg>
) );
const CheckIcon = () => ( const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg> </svg>
) );
const XIcon = () => ( const XIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <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" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
) );
const UsersIcon = () => ( const UsersIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" 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" /> <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> </svg>
) );
// Country flags (simple emoji mapping) // Country flags (simple emoji mapping)
const getCountryFlag = (code: string | null): string => { const getCountryFlag = (code: string | null): string => {
if (!code) return '' if (!code) return '';
const codeMap: Record<string, string> = { const codeMap: Record<string, string> = {
'RU': '🇷🇺', 'US': '🇺🇸', 'DE': '🇩🇪', 'NL': '🇳🇱', 'GB': '🇬🇧', RU: '🇷🇺',
'FR': '🇫🇷', 'FI': '🇫🇮', 'SE': '🇸🇪', 'PL': '🇵🇱', 'CZ': '🇨🇿', US: '🇺🇸',
'AT': '🇦🇹', 'CH': '🇨🇭', 'UA': '🇺🇦', 'KZ': '🇰🇿', 'JP': '🇯🇵', DE: '🇩🇪',
'KR': '🇰🇷', 'SG': '🇸🇬', 'HK': '🇭🇰', 'CA': '🇨🇦', 'AU': '🇦🇺', NL: '🇳🇱',
'BR': '🇧🇷', 'IN': '🇮🇳', 'TR': '🇹🇷', 'IL': '🇮🇱', 'AE': '🇦🇪', GB: '🇬🇧',
} FR: '🇫🇷',
return codeMap[code.toUpperCase()] || code FI: '🇫🇮',
} SE: '🇸🇪',
PL: '🇵🇱',
CZ: '🇨🇿',
AT: '🇦🇹',
CH: '🇨🇭',
UA: '🇺🇦',
KZ: '🇰🇿',
JP: '🇯🇵',
KR: '🇰🇷',
SG: '🇸🇬',
HK: '🇭🇰',
CA: '🇨🇦',
AU: '🇦🇺',
BR: '🇧🇷',
IN: '🇮🇳',
TR: '🇹🇷',
IL: '🇮🇱',
AE: '🇦🇪',
};
return codeMap[code.toUpperCase()] || code;
};
interface ServerModalProps { interface ServerModalProps {
server: ServerDetail server: ServerDetail;
onSave: (data: ServerUpdateRequest) => void onSave: (data: ServerUpdateRequest) => void;
onClose: () => void onClose: () => void;
isLoading?: boolean isLoading?: boolean;
} }
function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) { function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
const { t } = useTranslation() const { t } = useTranslation();
const [displayName, setDisplayName] = useState(server.display_name) const [displayName, setDisplayName] = useState(server.display_name);
const [description, setDescription] = useState(server.description || '') const [description, setDescription] = useState(server.description || '');
const [countryCode, setCountryCode] = useState(server.country_code || '') const [countryCode, setCountryCode] = useState(server.country_code || '');
const [priceKopeks, setPriceKopeks] = useState(server.price_kopeks) const [priceKopeks, setPriceKopeks] = useState(server.price_kopeks);
const [maxUsers, setMaxUsers] = useState<number | null>(server.max_users) const [maxUsers, setMaxUsers] = useState<number | null>(server.max_users);
const [sortOrder, setSortOrder] = useState(server.sort_order) const [sortOrder, setSortOrder] = useState(server.sort_order);
const handleSubmit = () => { const handleSubmit = () => {
const data: ServerUpdateRequest = { const data: ServerUpdateRequest = {
@@ -84,55 +117,59 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
price_kopeks: priceKopeks, price_kopeks: priceKopeks,
max_users: maxUsers || undefined, max_users: maxUsers || undefined,
sort_order: sortOrder, sort_order: sortOrder,
} };
onSave(data) onSave(data);
} };
return ( return (
<div className="fixed inset-0 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col"> <div className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-xl bg-dark-800">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700"> <div className="flex items-center justify-between border-b border-dark-700 p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xl">{getCountryFlag(server.country_code)}</span> <span className="text-xl">{getCountryFlag(server.country_code)}</span>
<h2 className="text-lg font-semibold text-dark-100"> <h2 className="text-lg font-semibold text-dark-100">{t('admin.servers.edit')}</h2>
{t('admin.servers.edit')}
</h2>
</div> </div>
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors"> <button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
<XIcon /> <XIcon />
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4"> <div className="flex-1 space-y-4 overflow-y-auto p-4">
{/* Original Name (readonly) */} {/* Original Name (readonly) */}
<div> <div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.originalName')}</label> <label className="mb-1 block text-sm text-dark-300">
<div className="px-3 py-2 bg-dark-700/50 border border-dark-600 rounded-lg text-dark-400"> {t('admin.servers.originalName')}
</label>
<div className="rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-2 text-dark-400">
{server.original_name || server.squad_uuid} {server.original_name || server.squad_uuid}
</div> </div>
</div> </div>
{/* Display Name */} {/* Display Name */}
<div> <div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.displayName')}</label> <label className="mb-1 block text-sm text-dark-300">
{t('admin.servers.displayName')}
</label>
<input <input
type="text" type="text"
value={displayName} value={displayName}
onChange={e => setDisplayName(e.target.value)} onChange={(e) => setDisplayName(e.target.value)}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
placeholder={t('admin.servers.displayNamePlaceholder')} placeholder={t('admin.servers.displayNamePlaceholder')}
/> />
</div> </div>
{/* Description */} {/* Description */}
<div> <div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.description')}</label> <label className="mb-1 block text-sm text-dark-300">
{t('admin.servers.description')}
</label>
<textarea <textarea
value={description} value={description}
onChange={e => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500 resize-none" className="w-full resize-none rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
rows={2} rows={2}
placeholder={t('admin.servers.descriptionPlaceholder')} placeholder={t('admin.servers.descriptionPlaceholder')}
/> />
@@ -140,46 +177,50 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
{/* Country Code */} {/* Country Code */}
<div> <div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.countryCode')}</label> <label className="mb-1 block text-sm text-dark-300">
{t('admin.servers.countryCode')}
</label>
<input <input
type="text" type="text"
value={countryCode} value={countryCode}
onChange={e => setCountryCode(e.target.value.toUpperCase().slice(0, 2))} onChange={(e) => setCountryCode(e.target.value.toUpperCase().slice(0, 2))}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
placeholder="RU" placeholder="RU"
maxLength={2} maxLength={2}
/> />
{countryCode && ( {countryCode && <span className="ml-2 text-xl">{getCountryFlag(countryCode)}</span>}
<span className="ml-2 text-xl">{getCountryFlag(countryCode)}</span>
)}
</div> </div>
{/* Price */} {/* Price */}
<div> <div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.price')}</label> <label className="mb-1 block text-sm text-dark-300">{t('admin.servers.price')}</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="number" type="number"
value={priceKopeks / 100} value={priceKopeks / 100}
onChange={e => setPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)} onChange={(e) => setPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
min={0} min={0}
step={1} step={1}
/> />
<span className="text-dark-400"></span> <span className="text-dark-400"></span>
</div> </div>
<p className="text-xs text-dark-500 mt-1">{t('admin.servers.priceHint')}</p> <p className="mt-1 text-xs text-dark-500">{t('admin.servers.priceHint')}</p>
</div> </div>
{/* Max Users */} {/* Max Users */}
<div> <div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.maxUsers')}</label> <label className="mb-1 block text-sm text-dark-300">
{t('admin.servers.maxUsers')}
</label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="number" type="number"
value={maxUsers || ''} value={maxUsers || ''}
onChange={e => setMaxUsers(e.target.value ? Math.max(0, parseInt(e.target.value)) : null)} onChange={(e) =>
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" setMaxUsers(e.target.value ? Math.max(0, parseInt(e.target.value)) : null)
}
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
min={0} min={0}
placeholder={t('admin.servers.unlimited')} placeholder={t('admin.servers.unlimited')}
/> />
@@ -191,34 +232,39 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
{/* Sort Order */} {/* Sort Order */}
<div> <div>
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.sortOrder')}</label> <label className="mb-1 block text-sm text-dark-300">
{t('admin.servers.sortOrder')}
</label>
<input <input
type="number" type="number"
value={sortOrder} value={sortOrder}
onChange={e => setSortOrder(parseInt(e.target.value) || 0)} onChange={(e) => setSortOrder(parseInt(e.target.value) || 0)}
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500" className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
/> />
</div> </div>
{/* Stats */} {/* Stats */}
<div className="pt-4 border-t border-dark-700"> <div className="border-t border-dark-700 pt-4">
<h4 className="text-sm font-medium text-dark-300 mb-2">{t('admin.servers.stats')}</h4> <h4 className="mb-2 text-sm font-medium text-dark-300">{t('admin.servers.stats')}</h4>
<div className="grid grid-cols-2 gap-2 text-sm"> <div className="grid grid-cols-2 gap-2 text-sm">
<div className="p-2 bg-dark-700/50 rounded-lg"> <div className="rounded-lg bg-dark-700/50 p-2">
<span className="text-dark-400">{t('admin.servers.currentUsers')}:</span> <span className="text-dark-400">{t('admin.servers.currentUsers')}:</span>
<span className="ml-2 text-dark-200">{server.current_users}</span> <span className="ml-2 text-dark-200">{server.current_users}</span>
</div> </div>
<div className="p-2 bg-dark-700/50 rounded-lg"> <div className="rounded-lg bg-dark-700/50 p-2">
<span className="text-dark-400">{t('admin.servers.activeSubscriptions')}:</span> <span className="text-dark-400">{t('admin.servers.activeSubscriptions')}:</span>
<span className="ml-2 text-dark-200">{server.active_subscriptions}</span> <span className="ml-2 text-dark-200">{server.active_subscriptions}</span>
</div> </div>
</div> </div>
{server.tariffs_using.length > 0 && ( {server.tariffs_using.length > 0 && (
<div className="mt-2 p-2 bg-dark-700/50 rounded-lg"> <div className="mt-2 rounded-lg bg-dark-700/50 p-2">
<span className="text-dark-400 text-sm">{t('admin.servers.usedByTariffs')}:</span> <span className="text-sm text-dark-400">{t('admin.servers.usedByTariffs')}:</span>
<div className="flex flex-wrap gap-1 mt-1"> <div className="mt-1 flex flex-wrap gap-1">
{server.tariffs_using.map(tariff => ( {server.tariffs_using.map((tariff) => (
<span key={tariff} className="px-2 py-0.5 bg-dark-600 text-dark-300 text-xs rounded"> <span
key={tariff}
className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-300"
>
{tariff} {tariff}
</span> </span>
))} ))}
@@ -229,103 +275,103 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex justify-end gap-3 p-4 border-t border-dark-700"> <div className="flex justify-end gap-3 border-t border-dark-700 p-4">
<button <button
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors" className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
> >
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!displayName || isLoading} disabled={!displayName || isLoading}
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
> >
{isLoading ? t('common.loading') : t('common.save')} {isLoading ? t('common.loading') : t('common.save')}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
) );
} }
export default function AdminServers() { export default function AdminServers() {
const { t } = useTranslation() const { t } = useTranslation();
const queryClient = useQueryClient() const queryClient = useQueryClient();
const [editingServer, setEditingServer] = useState<ServerDetail | null>(null) const [editingServer, setEditingServer] = useState<ServerDetail | null>(null);
const [loadingServerId, setLoadingServerId] = useState<number | null>(null) const [loadingServerId, setLoadingServerId] = useState<number | null>(null);
const [loadError, setLoadError] = useState<string | null>(null) const [loadError, setLoadError] = useState<string | null>(null);
// Queries // Queries
const { data: serversData, isLoading } = useQuery({ const { data: serversData, isLoading } = useQuery({
queryKey: ['admin-servers'], queryKey: ['admin-servers'],
queryFn: () => serversApi.getServers(true), queryFn: () => serversApi.getServers(true),
}) });
// Mutations // Mutations
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: ServerUpdateRequest }) => mutationFn: ({ id, data }: { id: number; data: ServerUpdateRequest }) =>
serversApi.updateServer(id, data), serversApi.updateServer(id, data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-servers'] }) queryClient.invalidateQueries({ queryKey: ['admin-servers'] });
setEditingServer(null) setEditingServer(null);
}, },
}) });
const toggleMutation = useMutation({ const toggleMutation = useMutation({
mutationFn: serversApi.toggleServer, mutationFn: serversApi.toggleServer,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-servers'] }) queryClient.invalidateQueries({ queryKey: ['admin-servers'] });
}, },
}) });
const toggleTrialMutation = useMutation({ const toggleTrialMutation = useMutation({
mutationFn: serversApi.toggleTrial, mutationFn: serversApi.toggleTrial,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-servers'] }) queryClient.invalidateQueries({ queryKey: ['admin-servers'] });
}, },
}) });
const syncMutation = useMutation({ const syncMutation = useMutation({
mutationFn: serversApi.syncServers, mutationFn: serversApi.syncServers,
onSuccess: (result) => { onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['admin-servers'] }) queryClient.invalidateQueries({ queryKey: ['admin-servers'] });
alert(result.message) alert(result.message);
}, },
}) });
const handleEdit = async (serverId: number) => { const handleEdit = async (serverId: number) => {
setLoadingServerId(serverId) setLoadingServerId(serverId);
setLoadError(null) setLoadError(null);
try { try {
const detail = await serversApi.getServer(serverId) const detail = await serversApi.getServer(serverId);
setEditingServer(detail) setEditingServer(detail);
} catch (error: unknown) { } catch (error: unknown) {
console.error('Failed to load server:', error) console.error('Failed to load server:', error);
const errorMessage = error instanceof Error ? error.message : t('admin.servers.loadError') const errorMessage = error instanceof Error ? error.message : t('admin.servers.loadError');
setLoadError(errorMessage) setLoadError(errorMessage);
} finally { } finally {
setLoadingServerId(null) setLoadingServerId(null);
}
} }
};
const handleSave = (data: ServerUpdateRequest) => { const handleSave = (data: ServerUpdateRequest) => {
if (editingServer) { if (editingServer) {
updateMutation.mutate({ id: editingServer.id, data }) updateMutation.mutate({ id: editingServer.id, data });
}
} }
};
const servers = serversData?.servers || [] const servers = serversData?.servers || [];
return ( return (
<div className="animate-fade-in"> <div className="animate-fade-in">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link <Link
to="/admin" to="/admin"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors" 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 />
</Link> </Link>
@@ -337,7 +383,7 @@ export default function AdminServers() {
<button <button
onClick={() => syncMutation.mutate()} onClick={() => syncMutation.mutate()}
disabled={syncMutation.isPending} disabled={syncMutation.isPending}
className="flex items-center gap-2 px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50" className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
> >
<SyncIcon /> <SyncIcon />
{syncMutation.isPending ? t('admin.servers.syncing') : t('admin.servers.sync')} {syncMutation.isPending ? t('admin.servers.syncing') : t('admin.servers.sync')}
@@ -347,10 +393,10 @@ export default function AdminServers() {
{/* Servers List */} {/* Servers List */}
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" /> <div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div> </div>
) : servers.length === 0 ? ( ) : servers.length === 0 ? (
<div className="text-center py-12"> <div className="py-12 text-center">
<p className="text-dark-400">{t('admin.servers.noServers')}</p> <p className="text-dark-400">{t('admin.servers.noServers')}</p>
<button <button
onClick={() => syncMutation.mutate()} onClick={() => syncMutation.mutate()}
@@ -364,27 +410,27 @@ export default function AdminServers() {
{servers.map((server: ServerListItem) => ( {servers.map((server: ServerListItem) => (
<div <div
key={server.id} key={server.id}
className={`p-4 bg-dark-800 rounded-xl border transition-colors ${ className={`rounded-xl border bg-dark-800 p-4 transition-colors ${
server.is_available ? 'border-dark-700' : 'border-dark-700/50 opacity-60' server.is_available ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
}`} }`}
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="mb-1 flex items-center gap-2">
<span className="text-lg">{getCountryFlag(server.country_code)}</span> <span className="text-lg">{getCountryFlag(server.country_code)}</span>
<h3 className="font-medium text-dark-100 truncate">{server.display_name}</h3> <h3 className="truncate font-medium text-dark-100">{server.display_name}</h3>
{server.is_trial_eligible && ( {server.is_trial_eligible && (
<span className="px-2 py-0.5 text-xs bg-success-500/20 text-success-400 rounded"> <span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
{t('admin.servers.trial')} {t('admin.servers.trial')}
</span> </span>
)} )}
{!server.is_available && ( {!server.is_available && (
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded"> <span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
{t('admin.servers.unavailable')} {t('admin.servers.unavailable')}
</span> </span>
)} )}
{server.is_full && ( {server.is_full && (
<span className="px-2 py-0.5 text-xs bg-warning-500/20 text-warning-400 rounded"> <span className="rounded bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
{t('admin.servers.full')} {t('admin.servers.full')}
</span> </span>
)} )}
@@ -396,7 +442,9 @@ export default function AdminServers() {
{server.max_users ? ` / ${server.max_users}` : ''} {server.max_users ? ` / ${server.max_users}` : ''}
</span> </span>
<span>{server.price_rubles} </span> <span>{server.price_rubles} </span>
<span className="text-dark-500 text-xs font-mono truncate max-w-[200px]">{server.squad_uuid}</span> <span className="max-w-[200px] truncate font-mono text-xs text-dark-500">
{server.squad_uuid}
</span>
</div> </div>
</div> </div>
@@ -404,12 +452,14 @@ export default function AdminServers() {
{/* Toggle Available */} {/* Toggle Available */}
<button <button
onClick={() => toggleMutation.mutate(server.id)} onClick={() => toggleMutation.mutate(server.id)}
className={`p-2 rounded-lg transition-colors ${ className={`rounded-lg p-2 transition-colors ${
server.is_available server.is_available
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30' ? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600' : 'bg-dark-700 text-dark-400 hover:bg-dark-600'
}`} }`}
title={server.is_available ? t('admin.servers.disable') : t('admin.servers.enable')} title={
server.is_available ? t('admin.servers.disable') : t('admin.servers.enable')
}
> >
{server.is_available ? <CheckIcon /> : <XIcon />} {server.is_available ? <CheckIcon /> : <XIcon />}
</button> </button>
@@ -417,7 +467,7 @@ export default function AdminServers() {
{/* Toggle Trial */} {/* Toggle Trial */}
<button <button
onClick={() => toggleTrialMutation.mutate(server.id)} onClick={() => toggleTrialMutation.mutate(server.id)}
className={`p-2 rounded-lg transition-colors ${ className={`rounded-lg p-2 transition-colors ${
server.is_trial_eligible server.is_trial_eligible
? 'bg-accent-500/20 text-accent-400 hover:bg-accent-500/30' ? 'bg-accent-500/20 text-accent-400 hover:bg-accent-500/30'
: 'bg-dark-700 text-dark-400 hover:bg-dark-600' : 'bg-dark-700 text-dark-400 hover:bg-dark-600'
@@ -431,11 +481,11 @@ export default function AdminServers() {
<button <button
onClick={() => handleEdit(server.id)} onClick={() => handleEdit(server.id)}
disabled={loadingServerId === server.id} disabled={loadingServerId === server.id}
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors disabled:opacity-50" className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:opacity-50"
title={t('admin.servers.edit')} title={t('admin.servers.edit')}
> >
{loadingServerId === server.id ? ( {loadingServerId === server.id ? (
<div className="w-4 h-4 border-2 border-dark-300 border-t-transparent rounded-full animate-spin" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-dark-300 border-t-transparent" />
) : ( ) : (
<EditIcon /> <EditIcon />
)} )}
@@ -449,12 +499,9 @@ export default function AdminServers() {
{/* Error Toast */} {/* Error Toast */}
{loadError && ( {loadError && (
<div className="fixed bottom-4 right-4 bg-error-500/90 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 z-50"> <div className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg bg-error-500/90 px-4 py-3 text-white shadow-lg">
<span>{loadError}</span> <span>{loadError}</span>
<button <button onClick={() => setLoadError(null)} className="rounded p-1 hover:bg-white/20">
onClick={() => setLoadError(null)}
className="p-1 hover:bg-white/20 rounded"
>
<XIcon /> <XIcon />
</button> </button>
</div> </div>
@@ -470,5 +517,5 @@ export default function AdminServers() {
/> />
)} )}
</div> </div>
) );
} }

View File

@@ -1,121 +1,120 @@
import { useState, useMemo, useEffect } from 'react' import { useState, useMemo, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings' import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
import { themeColorsApi } from '../api/themeColors' import { themeColorsApi } from '../api/themeColors';
import { useFavoriteSettings } from '../hooks/useFavoriteSettings' import { useFavoriteSettings } from '../hooks/useFavoriteSettings';
import { MenuIcon, MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin';
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
import { BrandingTab } from '../components/admin/BrandingTab';
import { ThemeTab } from '../components/admin/ThemeTab';
import { FavoritesTab } from '../components/admin/FavoritesTab';
import { SettingsTab } from '../components/admin/SettingsTab';
import { SettingsSidebar } from '../components/admin/SettingsSidebar';
import { import {
MenuIcon, SettingsSearch,
MENU_SECTIONS, SettingsSearchMobile,
MenuItem, SettingsSearchResults,
formatSettingKey } from '../components/admin/SettingsSearch';
} from '../components/admin'
import { AnalyticsTab } from '../components/admin/AnalyticsTab'
import { BrandingTab } from '../components/admin/BrandingTab'
import { ThemeTab } from '../components/admin/ThemeTab'
import { FavoritesTab } from '../components/admin/FavoritesTab'
import { SettingsTab } from '../components/admin/SettingsTab'
import { SettingsSidebar } from '../components/admin/SettingsSidebar'
import { SettingsSearch, SettingsSearchMobile, SettingsSearchResults } from '../components/admin/SettingsSearch'
export default function AdminSettings() { export default function AdminSettings() {
const { t } = useTranslation() const { t } = useTranslation();
// State // State
const [activeSection, setActiveSection] = useState('branding') const [activeSection, setActiveSection] = useState('branding');
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('');
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
// Favorites hook // Favorites hook
const { favorites, toggleFavorite, isFavorite } = useFavoriteSettings() const { favorites, toggleFavorite, isFavorite } = useFavoriteSettings();
// Scroll to top on mount and section change (fix for mobile webviews) // Scroll to top on mount and section change (fix for mobile webviews)
useEffect(() => { useEffect(() => {
// Use requestAnimationFrame for smoother scroll on mobile // Use requestAnimationFrame for smoother scroll on mobile
requestAnimationFrame(() => { requestAnimationFrame(() => {
window.scrollTo({ top: 0, behavior: 'instant' }) window.scrollTo({ top: 0, behavior: 'instant' });
document.documentElement.scrollTop = 0 document.documentElement.scrollTop = 0;
document.body.scrollTop = 0 document.body.scrollTop = 0;
}) });
}, [activeSection]) }, [activeSection]);
// Queries // Queries
const { data: themeColors } = useQuery({ const { data: themeColors } = useQuery({
queryKey: ['theme-colors'], queryKey: ['theme-colors'],
queryFn: themeColorsApi.getColors, queryFn: themeColorsApi.getColors,
}) });
const { data: allSettings } = useQuery({ const { data: allSettings } = useQuery({
queryKey: ['admin-settings'], queryKey: ['admin-settings'],
queryFn: () => adminSettingsApi.getSettings(), queryFn: () => adminSettingsApi.getSettings(),
}) });
// Get current menu item configuration // Get current menu item configuration
const currentMenuItem = useMemo(() => { const currentMenuItem = useMemo(() => {
for (const section of MENU_SECTIONS) { for (const section of MENU_SECTIONS) {
const item = section.items.find((i: MenuItem) => i.id === activeSection) const item = section.items.find((i: MenuItem) => i.id === activeSection);
if (item) return item if (item) return item;
} }
return null return null;
}, [activeSection]) }, [activeSection]);
// Get categories for current section // Get categories for current section
const currentCategories = useMemo(() => { const currentCategories = useMemo(() => {
if (!currentMenuItem?.categories || !allSettings || !Array.isArray(allSettings)) return [] if (!currentMenuItem?.categories || !allSettings || !Array.isArray(allSettings)) return [];
const categoryMap = new Map<string, SettingDefinition[]>() const categoryMap = new Map<string, SettingDefinition[]>();
for (const setting of allSettings) { for (const setting of allSettings) {
if (currentMenuItem.categories.includes(setting.category.key)) { if (currentMenuItem.categories.includes(setting.category.key)) {
if (!categoryMap.has(setting.category.key)) { if (!categoryMap.has(setting.category.key)) {
categoryMap.set(setting.category.key, []) categoryMap.set(setting.category.key, []);
} }
categoryMap.get(setting.category.key)!.push(setting) categoryMap.get(setting.category.key)!.push(setting);
} }
} }
return Array.from(categoryMap.entries()).map(([key, settings]) => ({ return Array.from(categoryMap.entries()).map(([key, settings]) => ({
key, key,
label: t(`admin.settings.categories.${key}`, key), label: t(`admin.settings.categories.${key}`, key),
settings settings,
})) }));
}, [currentMenuItem, allSettings, t]) }, [currentMenuItem, allSettings, t]);
// Filter settings for search - GLOBAL search across all settings // Filter settings for search - GLOBAL search across all settings
const filteredSettings = useMemo(() => { const filteredSettings = useMemo(() => {
if (!allSettings || !Array.isArray(allSettings) || !searchQuery) return [] if (!allSettings || !Array.isArray(allSettings) || !searchQuery) return [];
const q = searchQuery.toLowerCase().trim() const q = searchQuery.toLowerCase().trim();
if (!q) return [] if (!q) return [];
return allSettings.filter((s: SettingDefinition) => { return allSettings.filter((s: SettingDefinition) => {
// Search by key // Search by key
if (s.key.toLowerCase().includes(q)) return true if (s.key.toLowerCase().includes(q)) return true;
// Search by original name // Search by original name
if (s.name?.toLowerCase().includes(q)) return true if (s.name?.toLowerCase().includes(q)) return true;
// Search by translated name // Search by translated name
const formattedKey = formatSettingKey(s.name || s.key) const formattedKey = formatSettingKey(s.name || s.key);
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey) const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
if (translatedName.toLowerCase().includes(q)) return true if (translatedName.toLowerCase().includes(q)) return true;
// Search by description // Search by description
if (s.hint?.description?.toLowerCase().includes(q)) return true if (s.hint?.description?.toLowerCase().includes(q)) return true;
// Search by category // Search by category
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key) const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key);
if (categoryLabel.toLowerCase().includes(q)) return true if (categoryLabel.toLowerCase().includes(q)) return true;
return false return false;
}) });
}, [allSettings, searchQuery, t]) }, [allSettings, searchQuery, t]);
// Favorite settings // Favorite settings
const favoriteSettings = useMemo(() => { const favoriteSettings = useMemo(() => {
if (!allSettings || !Array.isArray(allSettings)) return [] if (!allSettings || !Array.isArray(allSettings)) return [];
return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key)) return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key));
}, [allSettings, favorites]) }, [allSettings, favorites]);
// Render content based on active section // Render content based on active section
const renderContent = () => { const renderContent = () => {
@@ -129,16 +128,16 @@ export default function AdminSettings() {
isFavorite={isFavorite} isFavorite={isFavorite}
toggleFavorite={toggleFavorite} toggleFavorite={toggleFavorite}
/> />
) );
} }
switch (activeSection) { switch (activeSection) {
case 'analytics': case 'analytics':
return <AnalyticsTab /> return <AnalyticsTab />;
case 'branding': case 'branding':
return <BrandingTab accentColor={themeColors?.accent} /> return <BrandingTab accentColor={themeColors?.accent} />;
case 'theme': case 'theme':
return <ThemeTab /> return <ThemeTab />;
case 'favorites': case 'favorites':
return ( return (
<FavoritesTab <FavoritesTab
@@ -146,9 +145,19 @@ export default function AdminSettings() {
isFavorite={isFavorite} isFavorite={isFavorite}
toggleFavorite={toggleFavorite} toggleFavorite={toggleFavorite}
/> />
) );
default: default:
if (['payments', 'subscriptions', 'interface', 'notifications', 'database', 'system', 'users'].includes(activeSection)) { if (
[
'payments',
'subscriptions',
'interface',
'notifications',
'database',
'system',
'users',
].includes(activeSection)
) {
return ( return (
<SettingsTab <SettingsTab
categories={currentCategories} categories={currentCategories}
@@ -157,18 +166,18 @@ export default function AdminSettings() {
isFavorite={isFavorite} isFavorite={isFavorite}
toggleFavorite={toggleFavorite} toggleFavorite={toggleFavorite}
/> />
) );
}
return null
} }
return null;
} }
};
return ( return (
<div className="flex min-h-screen pt-safe"> <div className="pt-safe flex min-h-screen">
{/* Mobile overlay */} {/* Mobile overlay */}
{mobileMenuOpen && ( {mobileMenuOpen && (
<div <div
className="fixed inset-0 bg-black/50 z-40 lg:hidden" className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
/> />
)} )}
@@ -183,18 +192,18 @@ export default function AdminSettings() {
/> />
{/* Main content */} {/* Main content */}
<main className="flex-1 min-w-0"> <main className="min-w-0 flex-1">
{/* Header */} {/* Header */}
<div className="sticky top-0 z-30 bg-dark-900/95 backdrop-blur-xl border-b border-dark-700/50 p-3 sm:p-4"> <div className="sticky top-0 z-30 border-b border-dark-700/50 bg-dark-900/95 p-3 backdrop-blur-xl sm:p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button <button
onClick={() => setMobileMenuOpen(true)} onClick={() => setMobileMenuOpen(true)}
className="p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors lg:hidden" className="rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700 lg:hidden"
> >
<MenuIcon /> <MenuIcon />
</button> </button>
<h2 className="text-lg sm:text-xl font-semibold text-dark-100 truncate"> <h2 className="truncate text-lg font-semibold text-dark-100 sm:text-xl">
{t(`admin.settings.${activeSection}`)} {t(`admin.settings.${activeSection}`)}
</h2> </h2>
@@ -207,22 +216,14 @@ export default function AdminSettings() {
/> />
</div> </div>
<SettingsSearchMobile <SettingsSearchMobile searchQuery={searchQuery} setSearchQuery={setSearchQuery} />
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
<SettingsSearchResults <SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
searchQuery={searchQuery}
resultsCount={filteredSettings.length}
/>
</div> </div>
{/* Content */} {/* Content */}
<div className="p-3 sm:p-4 lg:p-6"> <div className="p-3 sm:p-4 lg:p-6">{renderContent()}</div>
{renderContent()}
</div>
</main> </main>
</div> </div>
) );
} }

File diff suppressed because it is too large Load Diff

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