Merge pull request #102 from BEDOLAGA-DEV/main

ц
This commit is contained in:
Egor
2026-01-27 17:50:44 +03:00
committed by GitHub
140 changed files with 21233 additions and 16111 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',
},
}

View File

@@ -1,3 +1,5 @@
name: CI
on:
push:
branches: [main, dev]
@@ -5,7 +7,11 @@ on:
branches: [main, dev]
jobs:
lint-and-build:
lint:
uses: ./.github/workflows/lint.yml
build:
needs: lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
@@ -20,12 +26,6 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- name: Run TypeScript check
run: npx tsc --noEmit
- name: Build
run: npm run build
env:

View File

@@ -13,7 +13,11 @@ env:
IMAGE_NAME: ${{ github.repository }}
jobs:
lint:
uses: ./.github/workflows/lint.yml
build-and-push:
needs: lint
runs-on: ubuntu-latest
permissions:
contents: read

29
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
name: Lint & Format
on:
workflow_call:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- name: Check formatting
run: npm run format:check
- name: Run TypeScript check
run: npx tsc --noEmit

View File

@@ -9,7 +9,11 @@ permissions:
contents: write
jobs:
lint:
uses: ./.github/workflows/lint.yml
create-release:
needs: lint
runs-on: ubuntu-latest
steps:

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
npx lint-staged

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,
)

1961
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,13 +2,19 @@
"name": "cabinet-frontend",
"private": true,
"version": "1.0.0",
"description": "Remnawave Bedolaga Web App",
"license": "AGPL-3.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"",
"format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"",
"type-check": "tsc --noEmit",
"preview": "vite preview"
"preview": "vite preview",
"prepare": "husky"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -23,23 +29,38 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^13.5.0",
"react-router-dom": "^6.20.0",
"react-router-dom": "^7.13.0",
"zustand": "^4.4.0"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/dompurify": "^3.0.5",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.10.0",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.2.0",
"@vitejs/plugin-react": "^5.1.2",
"autoprefixer": "^10.4.16",
"eslint": "^8.53.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^17.1.0",
"husky": "^9.1.7",
"lint-staged": "^16.2.7",
"postcss": "^8.4.31",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.2",
"vite": "^5.0.0"
"typescript-eslint": "^8.54.0",
"vite": "^7.3.1"
},
"lint-staged": {
"*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
],
"*.{css,json}": [
"prettier --write"
]
}
}

View File

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

View File

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

View File

@@ -1,220 +1,240 @@
import apiClient from './client'
import apiClient from './client';
export interface LocalizedText {
en: string
ru: string
zh?: string
fa?: string
fr?: string
en: string;
ru: string;
zh?: string;
fa?: string;
fr?: string;
}
export interface AppButton {
id?: string // Unique identifier for React key (client-side only)
buttonLink: string
buttonText: LocalizedText
id?: string; // Unique identifier for React key (client-side only)
buttonLink: string;
buttonText: LocalizedText;
}
export interface AppStep {
description: LocalizedText
buttons?: AppButton[]
title?: LocalizedText
description: LocalizedText;
buttons?: AppButton[];
title?: LocalizedText;
}
export interface AppDefinition {
id: string
name: string
isFeatured: boolean
urlScheme: string
isNeedBase64Encoding?: boolean
installationStep: AppStep
addSubscriptionStep: AppStep
connectAndUseStep: AppStep
additionalBeforeAddSubscriptionStep?: AppStep
additionalAfterAddSubscriptionStep?: AppStep
id: string;
name: string;
isFeatured: boolean;
urlScheme: string;
isNeedBase64Encoding?: boolean;
installationStep: AppStep;
addSubscriptionStep: AppStep;
connectAndUseStep: AppStep;
additionalBeforeAddSubscriptionStep?: AppStep;
additionalAfterAddSubscriptionStep?: AppStep;
}
export interface AppConfigBranding {
name: string
logoUrl: string
supportUrl: string
name: string;
logoUrl: string;
supportUrl: string;
}
export interface AppConfigConfig {
additionalLocales: string[]
branding: AppConfigBranding
additionalLocales: string[];
branding: AppConfigBranding;
}
export interface AppConfigResponse {
config: AppConfigConfig
platforms: Record<string, AppDefinition[]>
config: AppConfigConfig;
platforms: Record<string, AppDefinition[]>;
}
export const adminAppsApi = {
// Get full app config
getConfig: async (): Promise<AppConfigResponse> => {
const response = await apiClient.get<AppConfigResponse>('/cabinet/admin/apps')
return response.data
const response = await apiClient.get<AppConfigResponse>('/cabinet/admin/apps');
return response.data;
},
// Get available platforms
getPlatforms: async (): Promise<string[]> => {
const response = await apiClient.get<string[]>('/cabinet/admin/apps/platforms')
return response.data
const response = await apiClient.get<string[]>('/cabinet/admin/apps/platforms');
return response.data;
},
// Get apps for a platform
getPlatformApps: async (platform: string): Promise<AppDefinition[]> => {
const response = await apiClient.get<AppDefinition[]>(`/cabinet/admin/apps/platforms/${platform}`)
return response.data
const response = await apiClient.get<AppDefinition[]>(
`/cabinet/admin/apps/platforms/${platform}`,
);
return response.data;
},
// Create a new app
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,
app,
})
return response.data
},
);
return response.data;
},
// Update an app
updateApp: async (platform: string, appId: string, app: AppDefinition): Promise<AppDefinition> => {
const response = await apiClient.put<AppDefinition>(`/cabinet/admin/apps/platforms/${platform}/${appId}`, {
updateApp: async (
platform: string,
appId: string,
app: AppDefinition,
): Promise<AppDefinition> => {
const response = await apiClient.put<AppDefinition>(
`/cabinet/admin/apps/platforms/${platform}/${appId}`,
{
app,
})
return response.data
},
);
return response.data;
},
// Delete an app
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
reorderApps: async (platform: string, appIds: string[]): Promise<void> => {
await apiClient.post(`/cabinet/admin/apps/platforms/${platform}/reorder`, {
app_ids: appIds,
})
});
},
// 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 }>(
`/cabinet/admin/apps/platforms/${platform}/copy/${appId}?target_platform=${targetPlatform}`
)
return response.data
`/cabinet/admin/apps/platforms/${platform}/copy/${appId}?target_platform=${targetPlatform}`,
);
return response.data;
},
// Get branding
getBranding: async (): Promise<AppConfigBranding> => {
const response = await apiClient.get<AppConfigBranding>('/cabinet/admin/apps/branding')
return response.data
const response = await apiClient.get<AppConfigBranding>('/cabinet/admin/apps/branding');
return response.data;
},
// Update branding
updateBranding: async (branding: AppConfigBranding): Promise<AppConfigBranding> => {
const response = await apiClient.put<AppConfigBranding>('/cabinet/admin/apps/branding', {
branding,
})
return response.data
});
return response.data;
},
// Get RemnaWave config status
getRemnaWaveStatus: async (): Promise<{ enabled: boolean; config_uuid: string | null }> => {
const response = await apiClient.get<{ enabled: boolean; config_uuid: string | null }>(
'/cabinet/admin/apps/remnawave/status'
)
return response.data
'/cabinet/admin/apps/remnawave/status',
);
return response.data;
},
// 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 }>(
'/cabinet/admin/apps/remnawave/uuid',
{ uuid }
)
return response.data
{ uuid },
);
return response.data;
},
// 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 }[]>(
'/cabinet/admin/apps/remnawave/configs'
)
return response.data
'/cabinet/admin/apps/remnawave/configs',
);
return response.data;
},
// Get RemnaWave subscription config
getRemnaWaveConfig: async (): Promise<RemnawaveConfig> => {
const response = await apiClient.get<{
uuid: string
name: string
view_position: number
config: RemnawaveConfig
}>('/cabinet/admin/apps/remnawave/config')
return response.data.config
uuid: string;
name: string;
view_position: number;
config: RemnawaveConfig;
}>('/cabinet/admin/apps/remnawave/config');
return response.data.config;
},
}
};
// ============== RemnaWave Format Types ==============
export interface RemnawaveButton {
url: string
text: LocalizedText
url: string;
text: LocalizedText;
}
export interface RemnawaveBlock {
title: LocalizedText
description: LocalizedText
buttons?: RemnawaveButton[]
svgIconKey?: string
svgIconColor?: string
title: LocalizedText;
description: LocalizedText;
buttons?: RemnawaveButton[];
svgIconKey?: string;
svgIconColor?: string;
}
export interface RemnawaveApp {
name: string
featured?: boolean
urlScheme?: string
isNeedBase64Encoding?: boolean
blocks: RemnawaveBlock[]
name: string;
featured?: boolean;
urlScheme?: string;
isNeedBase64Encoding?: boolean;
blocks: RemnawaveBlock[];
}
export interface RemnawavePlatform {
apps: RemnawaveApp[]
apps: RemnawaveApp[];
}
export interface RemnawaveSvgItem {
svgString: string
tags?: string[]
svgString: string;
tags?: string[];
}
export interface RemnawaveBaseSettings {
isShowTutorialButton: boolean
tutorialUrl: string
isShowTutorialButton: boolean;
tutorialUrl: string;
}
export interface RemnawaveBaseTranslations {
installApp: LocalizedText
addSubscription: LocalizedText
connectAndUse: LocalizedText
copyLink: LocalizedText
openApp: LocalizedText
tutorial: LocalizedText
close: LocalizedText
installApp: LocalizedText;
addSubscription: LocalizedText;
connectAndUse: LocalizedText;
copyLink: LocalizedText;
openApp: LocalizedText;
tutorial: LocalizedText;
close: LocalizedText;
}
export interface RemnawaveBrandingSettings {
name: string
logoUrl: string
supportUrl: string
name: string;
logoUrl: string;
supportUrl: string;
}
export interface RemnawaveConfig {
platforms: Record<string, RemnawavePlatform>
svgLibrary?: Record<string, RemnawaveSvgItem>
baseSettings?: RemnawaveBaseSettings
baseTranslations?: RemnawaveBaseTranslations
brandingSettings?: RemnawaveBrandingSettings
platforms: Record<string, RemnawavePlatform>;
svgLibrary?: Record<string, RemnawaveSvgItem>;
baseSettings?: RemnawaveBaseSettings;
baseTranslations?: RemnawaveBaseTranslations;
brandingSettings?: RemnawaveBrandingSettings;
}
// ============== Converter Functions ==============
@@ -225,23 +245,29 @@ const emptyLocalizedText = (): LocalizedText => ({
zh: '',
fa: '',
fr: '',
})
});
// Convert Cabinet button to RemnaWave button
const cabinetButtonToRemnawave = (button: AppButton): RemnawaveButton => ({
url: button.buttonLink,
text: { ...emptyLocalizedText(), ...button.buttonText },
})
});
// Convert RemnaWave button to Cabinet button
const remnawaveButtonToCabinet = (button: RemnawaveButton): AppButton => ({
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
export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
const blocks: RemnawaveBlock[] = []
const blocks: RemnawaveBlock[] = [];
// Block 1: Installation
blocks.push({
@@ -250,17 +276,27 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
buttons: app.installationStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'download',
svgIconColor: '#3B82F6',
})
});
// 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({
title: app.additionalBeforeAddSubscriptionStep.title || { ...emptyLocalizedText(), en: 'Preparation', ru: 'Подготовка' },
description: { ...emptyLocalizedText(), ...app.additionalBeforeAddSubscriptionStep.description },
title: app.additionalBeforeAddSubscriptionStep.title || {
...emptyLocalizedText(),
en: 'Preparation',
ru: 'Подготовка',
},
description: {
...emptyLocalizedText(),
...app.additionalBeforeAddSubscriptionStep.description,
},
buttons: app.additionalBeforeAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'settings',
svgIconColor: '#8B5CF6',
})
});
}
// Block 3: Add subscription
@@ -270,17 +306,27 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
buttons: app.addSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'plus',
svgIconColor: '#10B981',
})
});
// 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({
title: app.additionalAfterAddSubscriptionStep.title || { ...emptyLocalizedText(), en: 'Configuration', ru: 'Настройка' },
description: { ...emptyLocalizedText(), ...app.additionalAfterAddSubscriptionStep.description },
title: app.additionalAfterAddSubscriptionStep.title || {
...emptyLocalizedText(),
en: 'Configuration',
ru: 'Настройка',
},
description: {
...emptyLocalizedText(),
...app.additionalAfterAddSubscriptionStep.description,
},
buttons: app.additionalAfterAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'settings',
svgIconColor: '#F59E0B',
})
});
}
// Block 5: Connect and use
@@ -290,7 +336,7 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
buttons: app.connectAndUseStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'check',
svgIconColor: '#22C55E',
})
});
return {
name: app.name,
@@ -298,78 +344,104 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
urlScheme: app.urlScheme,
isNeedBase64Encoding: app.isNeedBase64Encoding,
blocks,
}
}
};
};
// Convert RemnaWave app to Cabinet app format
export const remnawaveAppToCabinet = (app: RemnawaveApp, platform: string): AppDefinition => {
const blocks = app.blocks || []
const blocks = app.blocks || [];
// Default empty step
const emptyStep = (): AppStep => ({
description: emptyLocalizedText(),
buttons: [],
})
});
// Try to identify blocks by their titles or position
let installationBlock: RemnawaveBlock | undefined
let subscriptionBlock: RemnawaveBlock | undefined
let connectBlock: RemnawaveBlock | undefined
let beforeSubBlock: RemnawaveBlock | undefined
let afterSubBlock: RemnawaveBlock | undefined
let installationBlock: RemnawaveBlock | undefined;
let subscriptionBlock: RemnawaveBlock | undefined;
let connectBlock: RemnawaveBlock | undefined;
let beforeSubBlock: RemnawaveBlock | undefined;
let afterSubBlock: RemnawaveBlock | undefined;
// First pass: try to identify by title keywords
for (const block of blocks) {
const enTitle = (block.title?.en || '').toLowerCase()
const ruTitle = (block.title?.ru || '').toLowerCase()
const enTitle = (block.title?.en || '').toLowerCase();
const ruTitle = (block.title?.ru || '').toLowerCase();
if (enTitle.includes('install') || ruTitle.includes('установ') || ruTitle.includes('скачай')) {
if (!installationBlock) installationBlock = block
} else if (enTitle.includes('subscription') || enTitle.includes('add') || ruTitle.includes('подписк') || ruTitle.includes('добав')) {
if (!subscriptionBlock) subscriptionBlock = block
} else if (enTitle.includes('connect') || enTitle.includes('use') || ruTitle.includes('подключ') || ruTitle.includes('использ')) {
if (!connectBlock) connectBlock = block
if (!installationBlock) installationBlock = block;
} else if (
enTitle.includes('subscription') ||
enTitle.includes('add') ||
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
if (!installationBlock && blocks.length > 0) {
installationBlock = blocks[0]
installationBlock = blocks[0];
}
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) {
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
const additionalBlocks = blocks.filter(b =>
b !== installationBlock && b !== subscriptionBlock && b !== connectBlock
)
const additionalBlocks = blocks.filter(
(b) => b !== installationBlock && b !== subscriptionBlock && b !== connectBlock,
);
if (additionalBlocks.length >= 1) {
// Check if block appears before subscription
const subIndex = blocks.indexOf(subscriptionBlock!)
const firstAdditionalIndex = blocks.indexOf(additionalBlocks[0])
const subIndex = blocks.indexOf(subscriptionBlock!);
const firstAdditionalIndex = blocks.indexOf(additionalBlocks[0]);
if (firstAdditionalIndex < subIndex) {
beforeSubBlock = additionalBlocks[0]
beforeSubBlock = additionalBlocks[0];
if (additionalBlocks.length >= 2) {
afterSubBlock = additionalBlocks[1]
afterSubBlock = additionalBlocks[1];
}
} else {
afterSubBlock = additionalBlocks[0]
afterSubBlock = additionalBlocks[0];
}
}
// Convert block to cabinet step
const blockToStep = (block: RemnawaveBlock | undefined): AppStep => {
if (!block) return emptyStep()
if (!block) return emptyStep();
return {
description: { 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,
description: {
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),
}
}
};
};
return {
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),
additionalBeforeAddSubscriptionStep: beforeSubBlock ? blockToStep(beforeSubBlock) : undefined,
additionalAfterAddSubscriptionStep: afterSubBlock ? blockToStep(afterSubBlock) : undefined,
}
}
};
};
// Export all apps from cabinet to RemnaWave format
export const exportToRemnawaveFormat = (
platformApps: Record<string, AppDefinition[]>,
branding?: AppConfigBranding
branding?: AppConfigBranding,
): RemnawaveConfig => {
const platforms: Record<string, RemnawavePlatform> = {}
const platforms: Record<string, RemnawavePlatform> = {};
for (const [platform, apps] of Object.entries(platformApps)) {
platforms[platform] = {
apps: apps.map(cabinetAppToRemnawave),
}
};
}
return {
platforms,
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>' },
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>' },
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>',
},
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: {
isShowTutorialButton: false,
tutorialUrl: '',
},
baseTranslations: {
installApp: { en: 'Install App', ru: 'Установить приложение', zh: '安装应用', 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' },
installApp: {
en: 'Install App',
ru: 'Установить приложение',
zh: '安装应用',
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' },
close: { en: 'Close', ru: 'Закрыть', zh: '关闭', fa: 'بستن', fr: 'Fermer' },
},
brandingSettings: branding ? {
brandingSettings: branding
? {
name: branding.name,
logoUrl: branding.logoUrl,
supportUrl: branding.supportUrl,
} : undefined,
}
}
: undefined,
};
};
// Import apps from RemnaWave format to cabinet
export const importFromRemnawaveFormat = (
config: RemnawaveConfig
config: RemnawaveConfig,
): { 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 || {})) {
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,
logoUrl: config.brandingSettings.logoUrl,
supportUrl: config.brandingSettings.supportUrl,
} : undefined
return { platformApps, branding }
}
: undefined;
return { platformApps, branding };
};

View File

@@ -1,168 +1,187 @@
import apiClient from './client'
import apiClient from './client';
// Types
export interface BroadcastFilter {
key: string
label: string
count: number | null
group: string | null
key: string;
label: string;
count: number | null;
group: string | null;
}
export interface TariffFilter {
key: string
label: string
tariff_id: number
count: number
key: string;
label: string;
tariff_id: number;
count: number;
}
export interface BroadcastFiltersResponse {
filters: BroadcastFilter[]
tariff_filters: TariffFilter[]
custom_filters: BroadcastFilter[]
filters: BroadcastFilter[];
tariff_filters: TariffFilter[];
custom_filters: BroadcastFilter[];
}
export interface TariffForBroadcast {
id: number
name: string
filter_key: string
active_users_count: number
id: number;
name: string;
filter_key: string;
active_users_count: number;
}
export interface BroadcastTariffsResponse {
tariffs: TariffForBroadcast[]
tariffs: TariffForBroadcast[];
}
export interface BroadcastButton {
key: string
label: string
default: boolean
key: string;
label: string;
default: boolean;
}
export interface BroadcastButtonsResponse {
buttons: BroadcastButton[]
buttons: BroadcastButton[];
}
export interface BroadcastMedia {
type: 'photo' | 'video' | 'document'
file_id: string
caption?: string
type: 'photo' | 'video' | 'document';
file_id: string;
caption?: string;
}
export interface BroadcastCreateRequest {
target: string
message_text: string
selected_buttons: string[]
media?: BroadcastMedia
target: string;
message_text: string;
selected_buttons: string[];
media?: BroadcastMedia;
}
export interface Broadcast {
id: number
target_type: string
message_text: string
has_media: boolean
media_type: string | null
media_file_id: string | null
media_caption: string | null
total_count: number
sent_count: number
failed_count: number
status: 'queued' | 'in_progress' | 'completed' | 'partial' | 'failed' | 'cancelled' | 'cancelling'
admin_id: number | null
admin_name: string | null
created_at: string
completed_at: string | null
progress_percent: number
id: number;
target_type: string;
message_text: string;
has_media: boolean;
media_type: string | null;
media_file_id: string | null;
media_caption: string | null;
total_count: number;
sent_count: number;
failed_count: number;
status:
| 'queued'
| 'in_progress'
| 'completed'
| 'partial'
| 'failed'
| 'cancelled'
| 'cancelling';
admin_id: number | null;
admin_name: string | null;
created_at: string;
completed_at: string | null;
progress_percent: number;
}
export interface BroadcastListResponse {
items: Broadcast[]
total: number
limit: number
offset: number
items: Broadcast[];
total: number;
limit: number;
offset: number;
}
export interface BroadcastPreviewRequest {
target: string
target: string;
}
export interface BroadcastPreviewResponse {
target: string
count: number
target: string;
count: number;
}
export interface MediaUploadResponse {
media_type: string
file_id: string
file_unique_id: string | null
media_url: string
media_type: string;
file_id: string;
file_unique_id: string | null;
media_url: string;
}
export const adminBroadcastsApi = {
// Get all available filters with counts
getFilters: async (): Promise<BroadcastFiltersResponse> => {
const response = await apiClient.get<BroadcastFiltersResponse>('/cabinet/admin/broadcasts/filters')
return response.data
const response = await apiClient.get<BroadcastFiltersResponse>(
'/cabinet/admin/broadcasts/filters',
);
return response.data;
},
// Get tariffs for filtering
getTariffs: async (): Promise<BroadcastTariffsResponse> => {
const response = await apiClient.get<BroadcastTariffsResponse>('/cabinet/admin/broadcasts/tariffs')
return response.data
const response = await apiClient.get<BroadcastTariffsResponse>(
'/cabinet/admin/broadcasts/tariffs',
);
return response.data;
},
// Get available buttons
getButtons: async (): Promise<BroadcastButtonsResponse> => {
const response = await apiClient.get<BroadcastButtonsResponse>('/cabinet/admin/broadcasts/buttons')
return response.data
const response = await apiClient.get<BroadcastButtonsResponse>(
'/cabinet/admin/broadcasts/buttons',
);
return response.data;
},
// Preview broadcast (get recipients count)
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,
})
return response.data
},
);
return response.data;
},
// Create and start broadcast
create: async (data: BroadcastCreateRequest): Promise<Broadcast> => {
const response = await apiClient.post<Broadcast>('/cabinet/admin/broadcasts', data)
return response.data
const response = await apiClient.post<Broadcast>('/cabinet/admin/broadcasts', data);
return response.data;
},
// Get list of broadcasts
list: async (limit = 20, offset = 0): Promise<BroadcastListResponse> => {
const response = await apiClient.get<BroadcastListResponse>('/cabinet/admin/broadcasts', {
params: { limit, offset },
})
return response.data
});
return response.data;
},
// Get broadcast details
get: async (id: number): Promise<Broadcast> => {
const response = await apiClient.get<Broadcast>(`/cabinet/admin/broadcasts/${id}`)
return response.data
const response = await apiClient.get<Broadcast>(`/cabinet/admin/broadcasts/${id}`);
return response.data;
},
// Stop broadcast
stop: async (id: number): Promise<Broadcast> => {
const response = await apiClient.post<Broadcast>(`/cabinet/admin/broadcasts/${id}/stop`)
return response.data
const response = await apiClient.post<Broadcast>(`/cabinet/admin/broadcasts/${id}/stop`);
return response.data;
},
// Upload media (uses existing media endpoint)
uploadMedia: async (file: File, mediaType: 'photo' | 'video' | 'document'): Promise<MediaUploadResponse> => {
const formData = new FormData()
formData.append('file', file)
formData.append('media_type', mediaType)
uploadMedia: async (
file: File,
mediaType: 'photo' | 'video' | 'document',
): 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, {
headers: {
'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 {
has_custom: boolean
has_custom: boolean;
}
export interface EmailTemplateType {
type: string
label: Record<string, string>
description: Record<string, string>
context_vars: string[]
languages: Record<string, EmailTemplateLanguageStatus>
type: string;
label: Record<string, string>;
description: Record<string, string>;
context_vars: string[];
languages: Record<string, EmailTemplateLanguageStatus>;
}
export interface EmailTemplateListResponse {
items: EmailTemplateType[]
available_languages: string[]
items: EmailTemplateType[];
available_languages: string[];
}
export interface EmailTemplateLanguageData {
subject: string
body_html: string
is_default: boolean
default_subject: string
default_body_html: string
subject: string;
body_html: string;
is_default: boolean;
default_subject: string;
default_body_html: string;
}
export interface EmailTemplateDetail {
notification_type: string
label: Record<string, string>
description: Record<string, string>
context_vars: string[]
languages: Record<string, EmailTemplateLanguageData>
notification_type: string;
label: Record<string, string>;
description: Record<string, string>;
context_vars: string[];
languages: Record<string, EmailTemplateLanguageData>;
}
export interface EmailTemplateUpdateRequest {
subject: string
body_html: string
subject: string;
body_html: string;
}
export interface EmailTemplatePreviewRequest {
language: string
subject?: string
body_html?: string
language: string;
subject?: string;
body_html?: string;
}
export interface EmailTemplatePreviewResponse {
subject: string
body_html: string
subject: string;
body_html: string;
}
export interface EmailTemplateSendTestRequest {
language: string
email?: string
language: string;
email?: string;
}
export const adminEmailTemplatesApi = {
getTemplateTypes: async (): Promise<EmailTemplateListResponse> => {
const response = await apiClient.get<EmailTemplateListResponse>('/cabinet/admin/email-templates')
return response.data
const response = await apiClient.get<EmailTemplateListResponse>(
'/cabinet/admin/email-templates',
);
return response.data;
},
getTemplate: async (notificationType: string): Promise<EmailTemplateDetail> => {
const response = await apiClient.get<EmailTemplateDetail>(`/cabinet/admin/email-templates/${notificationType}`)
return response.data
const response = await apiClient.get<EmailTemplateDetail>(
`/cabinet/admin/email-templates/${notificationType}`,
);
return response.data;
},
updateTemplate: async (notificationType: string, language: string, data: EmailTemplateUpdateRequest): Promise<void> => {
await apiClient.put(`/cabinet/admin/email-templates/${notificationType}/${language}`, data)
updateTemplate: async (
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> => {
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> => {
const response = await apiClient.post<EmailTemplatePreviewResponse>(`/cabinet/admin/email-templates/${notificationType}/preview`, data)
return response.data
previewTemplate: async (
notificationType: string,
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 }> => {
const response = await apiClient.post<{ status: string; sent_to: string }>(`/cabinet/admin/email-templates/${notificationType}/test`, data)
return response.data
sendTestEmail: async (
notificationType: string,
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 type { PaymentMethodConfig, PromoGroupSimple } from '../types'
import apiClient from './client';
import type { PaymentMethodConfig, PromoGroupSimple } from '../types';
export const adminPaymentMethodsApi = {
getAll: async (): Promise<PaymentMethodConfig[]> => {
const response = await apiClient.get<PaymentMethodConfig[]>('/cabinet/admin/payment-methods')
return response.data
const response = await apiClient.get<PaymentMethodConfig[]>('/cabinet/admin/payment-methods');
return response.data;
},
getOne: async (methodId: string): Promise<PaymentMethodConfig> => {
const response = await apiClient.get<PaymentMethodConfig>(`/cabinet/admin/payment-methods/${methodId}`)
return response.data
const response = await apiClient.get<PaymentMethodConfig>(
`/cabinet/admin/payment-methods/${methodId}`,
);
return response.data;
},
update: async (methodId: string, data: Record<string, unknown>): Promise<PaymentMethodConfig> => {
const response = await apiClient.put<PaymentMethodConfig>(
`/cabinet/admin/payment-methods/${methodId}`,
data
)
return response.data
data,
);
return response.data;
},
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[]> => {
const response = await apiClient.get<PromoGroupSimple[]>('/cabinet/admin/payment-methods/promo-groups')
return response.data
const response = await apiClient.get<PromoGroupSimple[]>(
'/cabinet/admin/payment-methods/promo-groups',
);
return response.data;
},
}
};

View File

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

View File

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

View File

@@ -1,418 +1,478 @@
import apiClient from './client'
import apiClient from './client';
// ============ Types ============
export interface UserSubscriptionInfo {
id: number
status: string
is_trial: boolean
start_date: string | null
end_date: string | null
traffic_limit_gb: number
traffic_used_gb: number
device_limit: number
tariff_id: number | null
tariff_name: string | null
autopay_enabled: boolean
is_active: boolean
days_remaining: number
id: number;
status: string;
is_trial: boolean;
start_date: string | null;
end_date: string | null;
traffic_limit_gb: number;
traffic_used_gb: number;
device_limit: number;
tariff_id: number | null;
tariff_name: string | null;
autopay_enabled: boolean;
is_active: boolean;
days_remaining: number;
}
export interface UserPromoGroupInfo {
id: number
name: string
is_default: boolean
id: number;
name: string;
is_default: boolean;
}
export interface UserListItem {
id: number
telegram_id: number
username: string | null
first_name: string | null
last_name: string | null
full_name: string
status: string
balance_kopeks: number
balance_rubles: number
created_at: string
last_activity: string | null
has_subscription: boolean
subscription_status: string | null
subscription_is_trial: boolean
subscription_end_date: string | null
promo_group_id: number | null
promo_group_name: string | null
total_spent_kopeks: number
purchase_count: number
has_restrictions: boolean
restriction_topup: boolean
restriction_subscription: boolean
id: number;
telegram_id: number;
username: string | null;
first_name: string | null;
last_name: string | null;
full_name: string;
status: string;
balance_kopeks: number;
balance_rubles: number;
created_at: string;
last_activity: string | null;
has_subscription: boolean;
subscription_status: string | null;
subscription_is_trial: boolean;
subscription_end_date: string | null;
promo_group_id: number | null;
promo_group_name: string | null;
total_spent_kopeks: number;
purchase_count: number;
has_restrictions: boolean;
restriction_topup: boolean;
restriction_subscription: boolean;
}
export interface UsersListResponse {
users: UserListItem[]
total: number
offset: number
limit: number
users: UserListItem[];
total: number;
offset: number;
limit: number;
}
export interface UserTransactionItem {
id: number
type: string
amount_kopeks: number
amount_rubles: number
description: string | null
payment_method: string | null
is_completed: boolean
created_at: string
id: number;
type: string;
amount_kopeks: number;
amount_rubles: number;
description: string | null;
payment_method: string | null;
is_completed: boolean;
created_at: string;
}
export interface UserReferralInfo {
referral_code: string
referrals_count: number
total_earnings_kopeks: number
commission_percent: number | null
referred_by_id: number | null
referred_by_username: string | null
referral_code: string;
referrals_count: number;
total_earnings_kopeks: number;
commission_percent: number | null;
referred_by_id: number | null;
referred_by_username: string | null;
}
export interface UserDetailResponse {
id: number
telegram_id: number
username: string | null
first_name: string | null
last_name: string | null
full_name: string
status: string
language: string
balance_kopeks: number
balance_rubles: number
email: string | null
email_verified: boolean
created_at: string
updated_at: string | null
last_activity: string | null
cabinet_last_login: string | null
subscription: UserSubscriptionInfo | null
promo_group: UserPromoGroupInfo | null
referral: UserReferralInfo
total_spent_kopeks: number
purchase_count: number
used_promocodes: number
has_had_paid_subscription: boolean
lifetime_used_traffic_bytes: number
restriction_topup: boolean
restriction_subscription: boolean
restriction_reason: string | null
promo_offer_discount_percent: number
promo_offer_discount_source: string | null
promo_offer_discount_expires_at: string | null
recent_transactions: UserTransactionItem[]
remnawave_uuid: string | null
id: number;
telegram_id: number;
username: string | null;
first_name: string | null;
last_name: string | null;
full_name: string;
status: string;
language: string;
balance_kopeks: number;
balance_rubles: number;
email: string | null;
email_verified: boolean;
created_at: string;
updated_at: string | null;
last_activity: string | null;
cabinet_last_login: string | null;
subscription: UserSubscriptionInfo | null;
promo_group: UserPromoGroupInfo | null;
referral: UserReferralInfo;
total_spent_kopeks: number;
purchase_count: number;
used_promocodes: number;
has_had_paid_subscription: boolean;
lifetime_used_traffic_bytes: number;
restriction_topup: boolean;
restriction_subscription: boolean;
restriction_reason: string | null;
promo_offer_discount_percent: number;
promo_offer_discount_source: string | null;
promo_offer_discount_expires_at: string | null;
recent_transactions: UserTransactionItem[];
remnawave_uuid: string | null;
}
export interface UsersStatsResponse {
total_users: number
active_users: number
blocked_users: number
deleted_users: number
new_today: number
new_week: number
new_month: number
users_with_subscription: number
users_with_active_subscription: number
users_with_trial: number
users_with_expired_subscription: number
total_balance_kopeks: number
total_balance_rubles: number
avg_balance_kopeks: number
active_today: number
active_week: number
active_month: number
total_users: number;
active_users: number;
blocked_users: number;
deleted_users: number;
new_today: number;
new_week: number;
new_month: number;
users_with_subscription: number;
users_with_active_subscription: number;
users_with_trial: number;
users_with_expired_subscription: number;
total_balance_kopeks: number;
total_balance_rubles: number;
avg_balance_kopeks: number;
active_today: number;
active_week: number;
active_month: number;
}
// Available tariffs
export interface PeriodPriceInfo {
days: number
price_kopeks: number
price_rubles: number
days: number;
price_kopeks: number;
price_rubles: number;
}
export interface UserAvailableTariff {
id: number
name: string
description: string | null
is_active: boolean
is_trial_available: boolean
traffic_limit_gb: number
device_limit: number
tier_level: number
display_order: number
period_prices: PeriodPriceInfo[]
is_daily: boolean
daily_price_kopeks: number
custom_days_enabled: boolean
price_per_day_kopeks: number
min_days: number
max_days: number
is_available: boolean
requires_promo_group: boolean
id: number;
name: string;
description: string | null;
is_active: boolean;
is_trial_available: boolean;
traffic_limit_gb: number;
device_limit: number;
tier_level: number;
display_order: number;
period_prices: PeriodPriceInfo[];
is_daily: boolean;
daily_price_kopeks: number;
custom_days_enabled: boolean;
price_per_day_kopeks: number;
min_days: number;
max_days: number;
is_available: boolean;
requires_promo_group: boolean;
}
export interface UserAvailableTariffsResponse {
user_id: number
promo_group_id: number | null
promo_group_name: string | null
tariffs: UserAvailableTariff[]
total: number
current_tariff_id: number | null
current_tariff_name: string | null
user_id: number;
promo_group_id: number | null;
promo_group_name: string | null;
tariffs: UserAvailableTariff[];
total: number;
current_tariff_id: number | null;
current_tariff_name: string | null;
}
// Sync types
export interface PanelUserInfo {
uuid: string | null
short_uuid: string | null
username: string | null
status: string | null
expire_at: string | null
traffic_limit_gb: number
traffic_used_gb: number
device_limit: number
subscription_url: string | null
active_squads: string[]
uuid: string | null;
short_uuid: string | null;
username: string | null;
status: string | null;
expire_at: string | null;
traffic_limit_gb: number;
traffic_used_gb: number;
device_limit: number;
subscription_url: string | null;
active_squads: string[];
}
export interface SyncFromPanelResponse {
success: boolean
message: string
panel_user: PanelUserInfo | null
changes: Record<string, unknown>
errors: string[]
success: boolean;
message: string;
panel_user: PanelUserInfo | null;
changes: Record<string, unknown>;
errors: string[];
}
export interface SyncToPanelResponse {
success: boolean
message: string
action: string
panel_uuid: string | null
changes: Record<string, unknown>
errors: string[]
success: boolean;
message: string;
action: string;
panel_uuid: string | null;
changes: Record<string, unknown>;
errors: string[];
}
export interface PanelSyncStatusResponse {
user_id: number
telegram_id: number
remnawave_uuid: string | null
last_sync: string | null
bot_subscription_status: string | null
bot_subscription_end_date: string | null
bot_traffic_limit_gb: number
bot_traffic_used_gb: number
bot_device_limit: number
bot_squads: string[]
panel_found: boolean
panel_status: string | null
panel_expire_at: string | null
panel_traffic_limit_gb: number
panel_traffic_used_gb: number
panel_device_limit: number
panel_squads: string[]
has_differences: boolean
differences: string[]
user_id: number;
telegram_id: number;
remnawave_uuid: string | null;
last_sync: string | null;
bot_subscription_status: string | null;
bot_subscription_end_date: string | null;
bot_traffic_limit_gb: number;
bot_traffic_used_gb: number;
bot_device_limit: number;
bot_squads: string[];
panel_found: boolean;
panel_status: string | null;
panel_expire_at: string | null;
panel_traffic_limit_gb: number;
panel_traffic_used_gb: number;
panel_device_limit: number;
panel_squads: string[];
has_differences: boolean;
differences: string[];
}
// Update types
export interface UpdateBalanceRequest {
amount_kopeks: number
description?: string
create_transaction?: boolean
amount_kopeks: number;
description?: string;
create_transaction?: boolean;
}
export interface UpdateBalanceResponse {
success: boolean
old_balance_kopeks: number
new_balance_kopeks: number
message: string
success: boolean;
old_balance_kopeks: number;
new_balance_kopeks: number;
message: string;
}
export interface UpdateSubscriptionRequest {
action: 'extend' | 'set_end_date' | 'change_tariff' | 'set_traffic' | 'toggle_autopay' | 'cancel' | 'activate' | '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
action:
| 'extend'
| 'set_end_date'
| 'change_tariff'
| 'set_traffic'
| 'toggle_autopay'
| 'cancel'
| 'activate'
| '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 {
success: boolean
message: string
subscription: UserSubscriptionInfo | null
success: boolean;
message: string;
subscription: UserSubscriptionInfo | null;
}
export interface UpdateUserStatusResponse {
success: boolean
old_status: string
new_status: string
message: string
success: boolean;
old_status: string;
new_status: string;
message: string;
}
export interface UpdateRestrictionsRequest {
restriction_topup?: boolean
restriction_subscription?: boolean
restriction_reason?: string
restriction_topup?: boolean;
restriction_subscription?: boolean;
restriction_reason?: string;
}
export interface UpdateRestrictionsResponse {
success: boolean
restriction_topup: boolean
restriction_subscription: boolean
restriction_reason: string | null
message: string
success: boolean;
restriction_topup: boolean;
restriction_subscription: boolean;
restriction_reason: string | null;
message: string;
}
export interface SyncFromPanelRequest {
update_subscription?: boolean
update_traffic?: boolean
create_if_missing?: boolean
update_subscription?: boolean;
update_traffic?: boolean;
create_if_missing?: boolean;
}
export interface SyncToPanelRequest {
create_if_missing?: boolean
update_status?: boolean
update_traffic_limit?: boolean
update_expire_date?: boolean
update_squads?: boolean
create_if_missing?: boolean;
update_status?: boolean;
update_traffic_limit?: boolean;
update_expire_date?: boolean;
update_squads?: boolean;
}
// ============ API ============
export const adminUsersApi = {
// List users
getUsers: async (params: {
offset?: number
limit?: number
search?: string
status?: 'active' | 'blocked' | 'deleted'
sort_by?: 'created_at' | 'balance' | 'traffic' | 'last_activity' | 'total_spent' | 'purchase_count'
} = {}): Promise<UsersListResponse> => {
const response = await apiClient.get('/cabinet/admin/users', { params })
return response.data
getUsers: async (
params: {
offset?: number;
limit?: number;
search?: string;
status?: 'active' | 'blocked' | 'deleted';
sort_by?:
| 'created_at'
| '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
getStats: async (): Promise<UsersStatsResponse> => {
const response = await apiClient.get('/cabinet/admin/users/stats')
return response.data
const response = await apiClient.get('/cabinet/admin/users/stats');
return response.data;
},
// Get user detail
getUser: async (userId: number): Promise<UserDetailResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}`)
return response.data
const response = await apiClient.get(`/cabinet/admin/users/${userId}`);
return response.data;
},
// Get user by telegram ID
getUserByTelegram: async (telegramId: number): Promise<UserDetailResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/by-telegram/${telegramId}`)
return response.data
const response = await apiClient.get(`/cabinet/admin/users/by-telegram/${telegramId}`);
return response.data;
},
// 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`, {
params: { include_inactive: includeInactive }
})
return response.data
params: { include_inactive: includeInactive },
});
return response.data;
},
// Update balance
updateBalance: async (userId: number, data: UpdateBalanceRequest): Promise<UpdateBalanceResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/balance`, data)
return response.data
updateBalance: async (
userId: number,
data: UpdateBalanceRequest,
): Promise<UpdateBalanceResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/balance`, data);
return response.data;
},
// Update subscription
updateSubscription: async (userId: number, data: UpdateSubscriptionRequest): Promise<UpdateSubscriptionResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/subscription`, data)
return response.data
updateSubscription: async (
userId: number,
data: UpdateSubscriptionRequest,
): Promise<UpdateSubscriptionResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/subscription`, data);
return response.data;
},
// Update status
updateStatus: async (userId: number, status: 'active' | 'blocked' | 'deleted', reason?: string): Promise<UpdateUserStatusResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/status`, { status, reason })
return response.data
updateStatus: async (
userId: number,
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
blockUser: async (userId: number, reason?: string): Promise<UpdateUserStatusResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/block`, null, { params: { reason } })
return response.data
const response = await apiClient.post(`/cabinet/admin/users/${userId}/block`, null, {
params: { reason },
});
return response.data;
},
// Unblock user
unblockUser: async (userId: number): Promise<UpdateUserStatusResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/unblock`)
return response.data
const response = await apiClient.post(`/cabinet/admin/users/${userId}/unblock`);
return response.data;
},
// Update restrictions
updateRestrictions: async (userId: number, data: UpdateRestrictionsRequest): Promise<UpdateRestrictionsResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/restrictions`, data)
return response.data
updateRestrictions: async (
userId: number,
data: UpdateRestrictionsRequest,
): Promise<UpdateRestrictionsResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/restrictions`, data);
return response.data;
},
// Update promo group
updatePromoGroup: async (userId: number, 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
updatePromoGroup: async (
userId: number,
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
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}`, {
data: { soft_delete: softDelete, reason }
})
return response.data
data: { soft_delete: softDelete, reason },
});
return response.data;
},
// Get referrals
getReferrals: async (userId: number, offset = 0, limit = 50): Promise<UsersListResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, {
params: { offset, limit }
})
return response.data
params: { offset, limit },
});
return response.data;
},
// Get transactions
getTransactions: async (userId: number, params: {
offset?: number
limit?: number
transaction_type?: string
} = {}): Promise<{ transactions: UserTransactionItem[]; total: number; offset: number; limit: number }> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/transactions`, { params })
return response.data
getTransactions: async (
userId: number,
params: {
offset?: number;
limit?: number;
transaction_type?: string;
} = {},
): 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
getSyncStatus: async (userId: number): Promise<PanelSyncStatusResponse> => {
const response = await apiClient.get(`/cabinet/admin/users/${userId}/sync/status`)
return response.data
const response = await apiClient.get(`/cabinet/admin/users/${userId}/sync/status`);
return response.data;
},
// Sync from panel
syncFromPanel: async (userId: number, data: SyncFromPanelRequest = {}): Promise<SyncFromPanelResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/from-panel`, data)
return response.data
syncFromPanel: async (
userId: number,
data: SyncFromPanelRequest = {},
): Promise<SyncFromPanelResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/from-panel`, data);
return response.data;
},
// Sync to panel
syncToPanel: async (userId: number, data: SyncToPanelRequest = {}): Promise<SyncToPanelResponse> => {
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/to-panel`, data)
return response.data
syncToPanel: async (
userId: number,
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 type { AuthResponse, RegisterResponse, TokenResponse, User } from '../types'
import apiClient from './client';
import type { AuthResponse, RegisterResponse, TokenResponse, User } from '../types';
export const authApi = {
// Telegram WebApp authentication
loginTelegram: async (initData: string): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram', {
init_data: initData,
})
return response.data
});
return response.data;
},
// Telegram Login Widget authentication
loginTelegramWidget: async (data: {
id: number
first_name: string
last_name?: string
username?: string
photo_url?: string
auth_date: number
hash: string
id: number;
first_name: string;
last_name?: string;
username?: string;
photo_url?: string;
auth_date: number;
hash: string;
}): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', data)
return response.data
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', data);
return response.data;
},
// Email login
@@ -29,63 +29,69 @@ export const authApi = {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/login', {
email,
password,
})
return response.data
});
return response.data;
},
// 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', {
email,
password,
})
return response.data
});
return response.data;
},
// Register standalone email account (no Telegram required)
// Returns message - user must verify email before login
registerEmailStandalone: async (data: {
email: string
password: string
first_name?: string
language?: string
referral_code?: string
email: string;
password: string;
first_name?: string;
language?: string;
referral_code?: string;
}): Promise<RegisterResponse> => {
const response = await apiClient.post<RegisterResponse>('/cabinet/auth/email/register/standalone', data)
return response.data
const response = await apiClient.post<RegisterResponse>(
'/cabinet/auth/email/register/standalone',
data,
);
return response.data;
},
// Verify email and get auth tokens
verifyEmail: async (token: string): Promise<AuthResponse> => {
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/verify', { token })
return response.data
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/verify', { token });
return response.data;
},
// Resend verification email
resendVerification: async (): Promise<{ message: string }> => {
const response = await apiClient.post('/cabinet/auth/email/resend')
return response.data
const response = await apiClient.post('/cabinet/auth/email/resend');
return response.data;
},
// Refresh token
refreshToken: async (refreshToken: string): Promise<TokenResponse> => {
const response = await apiClient.post<TokenResponse>('/cabinet/auth/refresh', {
refresh_token: refreshToken,
})
return response.data
});
return response.data;
},
// Logout
logout: async (refreshToken: string): Promise<void> => {
await apiClient.post('/cabinet/auth/logout', {
refresh_token: refreshToken,
})
});
},
// Forgot password
forgotPassword: async (email: string): Promise<{ message: string }> => {
const response = await apiClient.post('/cabinet/auth/password/forgot', { email })
return response.data
const response = await apiClient.post('/cabinet/auth/password/forgot', { email });
return response.data;
},
// Reset password
@@ -93,13 +99,13 @@ export const authApi = {
const response = await apiClient.post('/cabinet/auth/password/reset', {
token,
password,
})
return response.data
});
return response.data;
},
// Get current user
getMe: async (): Promise<User> => {
const response = await apiClient.get<User>('/cabinet/auth/me')
return response.data
const response = await apiClient.get<User>('/cabinet/auth/me');
return response.data;
},
}
};

View File

@@ -1,100 +1,124 @@
import apiClient from './client'
import type { Balance, Transaction, PaymentMethod, PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types'
import apiClient from './client';
import type {
Balance,
Transaction,
PaymentMethod,
PaginatedResponse,
PendingPayment,
ManualCheckResponse,
} from '../types';
export const balanceApi = {
// Get current balance
getBalance: async (): Promise<Balance> => {
const response = await apiClient.get<Balance>('/cabinet/balance')
return response.data
const response = await apiClient.get<Balance>('/cabinet/balance');
return response.data;
},
// Get transaction history
getTransactions: async (params?: {
page?: number
per_page?: number
type?: string
page?: number;
per_page?: number;
type?: string;
}): Promise<PaginatedResponse<Transaction>> => {
const response = await apiClient.get<PaginatedResponse<Transaction>>('/cabinet/balance/transactions', {
const response = await apiClient.get<PaginatedResponse<Transaction>>(
'/cabinet/balance/transactions',
{
params,
})
return response.data
},
);
return response.data;
},
// Get available payment methods
getPaymentMethods: async (): Promise<PaymentMethod[]> => {
const response = await apiClient.get<PaymentMethod[]>('/cabinet/balance/payment-methods')
return response.data
const response = await apiClient.get<PaymentMethod[]>('/cabinet/balance/payment-methods');
return response.data;
},
// Create top-up payment
createTopUp: async (amountKopeks: number, paymentMethod: string, paymentOption?: string): Promise<{
payment_id: string
payment_url: string
amount_kopeks: number
amount_rubles: number
status: string
expires_at: string | null
createTopUp: async (
amountKopeks: number,
paymentMethod: string,
paymentOption?: string,
): Promise<{
payment_id: string;
payment_url: string;
amount_kopeks: number;
amount_rubles: number;
status: string;
expires_at: string | null;
}> => {
const payload: {
amount_kopeks: number
payment_method: string
payment_option?: string
amount_kopeks: number;
payment_method: string;
payment_option?: string;
} = {
amount_kopeks: amountKopeks,
payment_method: paymentMethod,
}
};
if (paymentOption) {
payload.payment_option = paymentOption
payload.payment_option = paymentOption;
}
const response = await apiClient.post('/cabinet/balance/topup', payload)
return response.data
const response = await apiClient.post('/cabinet/balance/topup', payload);
return response.data;
},
// Activate promo code
activatePromocode: async (code: string): Promise<{
success: boolean
message: string
balance_before: number
balance_after: number
bonus_description: string | null
activatePromocode: async (
code: string,
): Promise<{
success: boolean;
message: string;
balance_before: number;
balance_after: number;
bonus_description: string | null;
}> => {
const response = await apiClient.post('/cabinet/promocode/activate', { code })
return response.data
const response = await apiClient.post('/cabinet/promocode/activate', { code });
return response.data;
},
// Create Telegram Stars invoice for Mini App balance top-up
createStarsInvoice: async (amountKopeks: number): Promise<{
invoice_url: string
stars_amount?: number
amount_kopeks?: number
createStarsInvoice: async (
amountKopeks: number,
): Promise<{
invoice_url: string;
stars_amount?: number;
amount_kopeks?: number;
}> => {
const response = await apiClient.post('/cabinet/balance/stars-invoice', {
amount_kopeks: amountKopeks,
})
return response.data
});
return response.data;
},
// Get pending payments for manual verification
getPendingPayments: async (params?: {
page?: number
per_page?: number
page?: number;
per_page?: number;
}): 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,
})
return response.data
},
);
return response.data;
},
// Get specific pending payment details
getPendingPayment: async (method: string, paymentId: number): Promise<PendingPayment> => {
const response = await apiClient.get<PendingPayment>(`/cabinet/balance/pending-payments/${method}/${paymentId}`)
return response.data
const response = await apiClient.get<PendingPayment>(
`/cabinet/balance/pending-payments/${method}/${paymentId}`,
);
return response.data;
},
// Manually check payment status
checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => {
const response = await apiClient.post<ManualCheckResponse>(`/cabinet/balance/pending-payments/${method}/${paymentId}/check`)
return response.data
const response = await apiClient.post<ManualCheckResponse>(
`/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 ===
export interface BanSystemStatus {
enabled: boolean
configured: boolean
enabled: boolean;
configured: boolean;
}
export interface BanSystemStats {
total_users: number
active_users: number
users_over_limit: number
total_requests: number
total_punishments: number
active_punishments: number
nodes_online: number
nodes_total: number
agents_online: number
agents_total: number
panel_connected: boolean
uptime_seconds: number | null
total_users: number;
active_users: number;
users_over_limit: number;
total_requests: number;
total_punishments: number;
active_punishments: number;
nodes_online: number;
nodes_total: number;
agents_online: number;
agents_total: number;
panel_connected: boolean;
uptime_seconds: number | null;
}
export interface BanUserIPInfo {
ip: string
first_seen: string | null
last_seen: string | null
node: string | null
request_count: number
country_code: string | null
country_name: string | null
city: string | null
ip: string;
first_seen: string | null;
last_seen: string | null;
node: string | null;
request_count: number;
country_code: string | null;
country_name: string | null;
city: string | null;
}
export interface BanUserRequestLog {
timestamp: string
source_ip: string
destination: string | null
dest_port: number | null
protocol: string | null
action: string | null
node: string | null
timestamp: string;
source_ip: string;
destination: string | null;
dest_port: number | null;
protocol: string | null;
action: string | null;
node: string | null;
}
export interface BanUserListItem {
email: string
unique_ip_count: number
total_requests: number
limit: number | null
is_over_limit: boolean
blocked_count: number
last_seen: string | null
email: string;
unique_ip_count: number;
total_requests: number;
limit: number | null;
is_over_limit: boolean;
blocked_count: number;
last_seen: string | null;
}
export interface BanUsersListResponse {
users: BanUserListItem[]
total: number
offset: number
limit: number
users: BanUserListItem[];
total: number;
offset: number;
limit: number;
}
export interface BanUserDetailResponse {
email: string
unique_ip_count: number
total_requests: number
limit: number | null
is_over_limit: boolean
blocked_count: number
ips: BanUserIPInfo[]
recent_requests: BanUserRequestLog[]
network_type: string | null
email: string;
unique_ip_count: number;
total_requests: number;
limit: number | null;
is_over_limit: boolean;
blocked_count: number;
ips: BanUserIPInfo[];
recent_requests: BanUserRequestLog[];
network_type: string | null;
}
export interface BanPunishmentItem {
id: number | null
user_id: string
uuid: string | null
username: string
reason: string | null
punished_at: string
enable_at: string | null
ip_count: number
limit: number
enabled: boolean
enabled_at: string | null
node_name: string | null
id: number | null;
user_id: string;
uuid: string | null;
username: string;
reason: string | null;
punished_at: string;
enable_at: string | null;
ip_count: number;
limit: number;
enabled: boolean;
enabled_at: string | null;
node_name: string | null;
}
export interface BanPunishmentsListResponse {
punishments: BanPunishmentItem[]
total: number
punishments: BanPunishmentItem[];
total: number;
}
export interface BanHistoryResponse {
items: BanPunishmentItem[]
total: number
items: BanPunishmentItem[];
total: number;
}
export interface BanUserRequest {
username: string
minutes: number
reason?: string
username: string;
minutes: number;
reason?: string;
}
export interface UnbanResponse {
success: boolean
message: string
success: boolean;
message: string;
}
export interface BanNodeItem {
name: string
address: string | null
is_connected: boolean
last_seen: string | null
users_count: number
agent_stats: Record<string, unknown> | null
name: string;
address: string | null;
is_connected: boolean;
last_seen: string | null;
users_count: number;
agent_stats: Record<string, unknown> | null;
}
export interface BanNodesListResponse {
nodes: BanNodeItem[]
total: number
online: number
nodes: BanNodeItem[];
total: number;
online: number;
}
export interface BanAgentItem {
node_name: string
sent_total: number
dropped_total: number
batches_total: number
reconnects: number
failures: number
queue_size: number
queue_max: number
dedup_checked: number
dedup_skipped: number
filter_checked: number
filter_filtered: number
health: string
is_online: boolean
last_report: string | null
node_name: string;
sent_total: number;
dropped_total: number;
batches_total: number;
reconnects: number;
failures: number;
queue_size: number;
queue_max: number;
dedup_checked: number;
dedup_skipped: number;
filter_checked: number;
filter_filtered: number;
health: string;
is_online: boolean;
last_report: string | null;
}
export interface BanAgentsSummary {
total_agents: number
online_agents: number
total_sent: number
total_dropped: number
avg_queue_size: number
healthy_count: number
warning_count: number
critical_count: number
total_agents: number;
online_agents: number;
total_sent: number;
total_dropped: number;
avg_queue_size: number;
healthy_count: number;
warning_count: number;
critical_count: number;
}
export interface BanAgentsListResponse {
agents: BanAgentItem[]
summary: BanAgentsSummary | null
total: number
online: number
agents: BanAgentItem[];
summary: BanAgentsSummary | null;
total: number;
online: number;
}
export interface BanTrafficViolationItem {
id: number | null
username: string
email: string | null
violation_type: string
description: string | null
bytes_used: number
bytes_limit: number
detected_at: string
resolved: boolean
id: number | null;
username: string;
email: string | null;
violation_type: string;
description: string | null;
bytes_used: number;
bytes_limit: number;
detected_at: string;
resolved: boolean;
}
export interface BanTrafficViolationsResponse {
violations: BanTrafficViolationItem[]
total: number
violations: BanTrafficViolationItem[];
total: number;
}
export interface BanTrafficTopItem {
username: string
bytes_total: number
bytes_limit: number | null
over_limit: boolean
username: string;
bytes_total: number;
bytes_limit: number | null;
over_limit: boolean;
}
export interface BanTrafficResponse {
enabled: boolean
stats: Record<string, unknown> | null
top_users: BanTrafficTopItem[]
recent_violations: BanTrafficViolationItem[]
enabled: boolean;
stats: Record<string, unknown> | null;
top_users: BanTrafficTopItem[];
recent_violations: BanTrafficViolationItem[];
}
// === Settings Types ===
export interface BanSettingDefinition {
key: string
value: unknown
type: string
min_value: number | null
max_value: number | null
editable: boolean
description: string | null
category: string | null
key: string;
value: unknown;
type: string;
min_value: number | null;
max_value: number | null;
editable: boolean;
description: string | null;
category: string | null;
}
export interface BanSettingsResponse {
settings: BanSettingDefinition[]
settings: BanSettingDefinition[];
}
export interface BanWhitelistRequest {
username: string
username: string;
}
// === Report Types ===
export interface BanReportTopViolator {
username: string
count: number
username: string;
count: number;
}
export interface BanReportResponse {
period_hours: number
current_users: number
current_ips: number
punishment_stats: Record<string, unknown> | null
top_violators: BanReportTopViolator[]
period_hours: number;
current_users: number;
current_ips: number;
punishment_stats: Record<string, unknown> | null;
top_violators: BanReportTopViolator[];
}
// === Health Types ===
export interface BanHealthComponent {
name: string
status: string
message: string | null
details: Record<string, unknown> | null
name: string;
status: string;
message: string | null;
details: Record<string, unknown> | null;
}
export interface BanHealthResponse {
status: string
uptime: number | null
components: BanHealthComponent[]
status: string;
uptime: number | null;
components: BanHealthComponent[];
}
export interface BanHealthDetailedResponse {
status: string
uptime: number | null
components: Record<string, unknown>
status: string;
uptime: number | null;
components: Record<string, unknown>;
}
// === Agent History Types ===
export interface BanAgentHistoryItem {
timestamp: string
sent_total: number
dropped_total: number
queue_size: number
batches_total: number
timestamp: string;
sent_total: number;
dropped_total: number;
queue_size: number;
batches_total: number;
}
export interface BanAgentHistoryResponse {
node: string
hours: number
records: number
delta: Record<string, unknown> | null
first: Record<string, unknown> | null
last: Record<string, unknown> | null
history: BanAgentHistoryItem[]
node: string;
hours: number;
records: number;
delta: Record<string, unknown> | null;
first: Record<string, unknown> | null;
last: Record<string, unknown> | null;
history: BanAgentHistoryItem[];
}
// === API ===
@@ -272,174 +272,198 @@ export interface BanAgentHistoryResponse {
export const banSystemApi = {
// Status
getStatus: async (): Promise<BanSystemStatus> => {
const response = await apiClient.get('/cabinet/admin/ban-system/status')
return response.data
const response = await apiClient.get('/cabinet/admin/ban-system/status');
return response.data;
},
// Stats
getStats: async (): Promise<BanSystemStats> => {
const response = await apiClient.get('/cabinet/admin/ban-system/stats')
return response.data
const response = await apiClient.get('/cabinet/admin/ban-system/stats');
return response.data;
},
// Users
getUsers: async (params: {
offset?: number
limit?: number
status?: string
} = {}): Promise<BanUsersListResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/users', { params })
return response.data
getUsers: async (
params: {
offset?: number;
limit?: number;
status?: string;
} = {},
): Promise<BanUsersListResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/users', { params });
return response.data;
},
getUsersOverLimit: async (limit: number = 50): Promise<BanUsersListResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/users/over-limit', {
params: { limit }
})
return response.data
params: { limit },
});
return response.data;
},
searchUsers: async (query: string): Promise<BanUsersListResponse> => {
const response = await apiClient.get(`/cabinet/admin/ban-system/users/search/${encodeURIComponent(query)}`)
return response.data
const response = await apiClient.get(
`/cabinet/admin/ban-system/users/search/${encodeURIComponent(query)}`,
);
return response.data;
},
getUser: async (email: string): Promise<BanUserDetailResponse> => {
const response = await apiClient.get(`/cabinet/admin/ban-system/users/${encodeURIComponent(email)}`)
return response.data
const response = await apiClient.get(
`/cabinet/admin/ban-system/users/${encodeURIComponent(email)}`,
);
return response.data;
},
// Punishments
getPunishments: async (): Promise<BanPunishmentsListResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/punishments')
return response.data
const response = await apiClient.get('/cabinet/admin/ban-system/punishments');
return response.data;
},
unbanUser: async (userId: string): Promise<UnbanResponse> => {
const response = await apiClient.post(`/cabinet/admin/ban-system/punishments/${userId}/unban`)
return response.data
const response = await apiClient.post(`/cabinet/admin/ban-system/punishments/${userId}/unban`);
return response.data;
},
banUser: async (data: BanUserRequest): Promise<UnbanResponse> => {
const response = await apiClient.post('/cabinet/admin/ban-system/ban', data)
return response.data
const response = await apiClient.post('/cabinet/admin/ban-system/ban', data);
return response.data;
},
getPunishmentHistory: async (query: string, limit: number = 20): Promise<BanHistoryResponse> => {
const response = await apiClient.get(`/cabinet/admin/ban-system/history/${encodeURIComponent(query)}`, {
params: { limit }
})
return response.data
const response = await apiClient.get(
`/cabinet/admin/ban-system/history/${encodeURIComponent(query)}`,
{
params: { limit },
},
);
return response.data;
},
// Nodes
getNodes: async (): Promise<BanNodesListResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/nodes')
return response.data
const response = await apiClient.get('/cabinet/admin/ban-system/nodes');
return response.data;
},
// Agents
getAgents: async (params: {
search?: string
health?: string
status?: string
} = {}): Promise<BanAgentsListResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/agents', { params })
return response.data
getAgents: async (
params: {
search?: string;
health?: string;
status?: string;
} = {},
): Promise<BanAgentsListResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/agents', { params });
return response.data;
},
getAgentsSummary: async (): Promise<BanAgentsSummary> => {
const response = await apiClient.get('/cabinet/admin/ban-system/agents/summary')
return response.data
const response = await apiClient.get('/cabinet/admin/ban-system/agents/summary');
return response.data;
},
// Traffic violations
getTrafficViolations: async (limit: number = 50): Promise<BanTrafficViolationsResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/traffic/violations', {
params: { limit }
})
return response.data
params: { limit },
});
return response.data;
},
// Full Traffic
getTraffic: async (): Promise<BanTrafficResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/traffic')
return response.data
const response = await apiClient.get('/cabinet/admin/ban-system/traffic');
return response.data;
},
getTrafficTop: async (limit: number = 20): Promise<BanTrafficTopItem[]> => {
const response = await apiClient.get('/cabinet/admin/ban-system/traffic/top', {
params: { limit }
})
return response.data
params: { limit },
});
return response.data;
},
// Settings
getSettings: async (): Promise<BanSettingsResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/settings')
return response.data
const response = await apiClient.get('/cabinet/admin/ban-system/settings');
return response.data;
},
getSetting: async (key: string): Promise<BanSettingDefinition> => {
const response = await apiClient.get(`/cabinet/admin/ban-system/settings/${key}`)
return response.data
const response = await apiClient.get(`/cabinet/admin/ban-system/settings/${key}`);
return response.data;
},
setSetting: async (key: string, value: string): Promise<BanSettingDefinition> => {
const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}`, null, {
params: { value }
})
return response.data
params: { value },
});
return response.data;
},
toggleSetting: async (key: string): Promise<BanSettingDefinition> => {
const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}/toggle`)
return response.data
const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}/toggle`);
return response.data;
},
// Whitelist
whitelistAdd: async (username: string): Promise<UnbanResponse> => {
const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/add', { username })
return response.data
const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/add', {
username,
});
return response.data;
},
whitelistRemove: async (username: string): Promise<UnbanResponse> => {
const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/remove', { username })
return response.data
const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/remove', {
username,
});
return response.data;
},
// Reports
getReport: async (hours: number = 24): Promise<BanReportResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/report', {
params: { hours }
})
return response.data
params: { hours },
});
return response.data;
},
// Health
getHealth: async (): Promise<BanHealthResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/health')
return response.data
const response = await apiClient.get('/cabinet/admin/ban-system/health');
return response.data;
},
getHealthDetailed: async (): Promise<BanHealthDetailedResponse> => {
const response = await apiClient.get('/cabinet/admin/ban-system/health/detailed')
return response.data
const response = await apiClient.get('/cabinet/admin/ban-system/health/detailed');
return response.data;
},
// Agent History
getAgentHistory: async (nodeName: string, hours: number = 24): Promise<BanAgentHistoryResponse> => {
const response = await apiClient.get(`/cabinet/admin/ban-system/agents/${encodeURIComponent(nodeName)}/history`, {
params: { hours }
})
return response.data
getAgentHistory: async (
nodeName: string,
hours: number = 24,
): Promise<BanAgentHistoryResponse> => {
const response = await apiClient.get(
`/cabinet/admin/ban-system/agents/${encodeURIComponent(nodeName)}/history`,
{
params: { hours },
},
);
return response.data;
},
// User Punishment History
getUserHistory: async (email: string, limit: number = 20): Promise<BanHistoryResponse> => {
const response = await apiClient.get(`/cabinet/admin/ban-system/users/${encodeURIComponent(email)}/history`, {
params: { limit }
})
return response.data
const response = await apiClient.get(
`/cabinet/admin/ban-system/users/${encodeURIComponent(email)}/history`,
{
params: { limit },
},
}
);
return response.data;
},
};

View File

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

View File

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

View File

@@ -1,163 +1,175 @@
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
import { tokenStorage, isTokenExpired, tokenRefreshManager, safeRedirectToLogin } from '../utils/token'
import { useBlockingStore } from '../store/blocking'
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import {
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
tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`)
tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`);
// CSRF token management
const CSRF_COOKIE_NAME = 'csrf_token'
const CSRF_HEADER_NAME = 'X-CSRF-Token'
const CSRF_COOKIE_NAME = 'csrf_token';
const CSRF_HEADER_NAME = 'X-CSRF-Token';
function getCsrfToken(): string | null {
if (typeof document === 'undefined') return null
const match = document.cookie.match(new RegExp(`(^| )${CSRF_COOKIE_NAME}=([^;]+)`))
return match ? match[2] : null
if (typeof document === 'undefined') return null;
const match = document.cookie.match(new RegExp(`(^| )${CSRF_COOKIE_NAME}=([^;]+)`));
return match ? match[2] : null;
}
function generateCsrfToken(): string {
const array = new Uint8Array(32)
crypto.getRandomValues(array)
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('')
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
}
function ensureCsrfToken(): string {
let token = getCsrfToken()
let token = getCsrfToken();
if (!token) {
token = generateCsrfToken()
token = generateCsrfToken();
// 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 => {
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) {
tokenStorage.setTelegramInitData(initData)
return initData
tokenStorage.setTelegramInitData(initData);
return initData;
}
return tokenStorage.getTelegramInitData()
}
return tokenStorage.getTelegramInitData();
};
export const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
})
});
// Request interceptor - add auth token with expiration check
apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
let token = tokenStorage.getAccessToken()
let token = tokenStorage.getAccessToken();
// Проверяем срок действия токена перед запросом
if (token && isTokenExpired(token)) {
// Используем централизованный менеджер для refresh
const newToken = await tokenRefreshManager.refreshAccessToken()
const newToken = await tokenRefreshManager.refreshAccessToken();
if (newToken) {
token = newToken
token = newToken;
} else {
// Refresh не удался - редирект на логин
tokenStorage.clearTokens()
safeRedirectToLogin()
return config
tokenStorage.clearTokens();
safeRedirectToLogin();
return config;
}
}
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`
config.headers.Authorization = `Bearer ${token}`;
}
const telegramInitData = getTelegramInitData()
const telegramInitData = getTelegramInitData();
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
const method = config.method?.toUpperCase()
const method = config.method?.toUpperCase();
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
export interface MaintenanceError {
code: 'maintenance'
message: string
reason?: string
code: 'maintenance';
message: string;
reason?: string;
}
export interface ChannelSubscriptionError {
code: 'channel_subscription_required'
message: string
channel_link?: string
code: 'channel_subscription_required';
message: string;
channel_link?: string;
}
export function isMaintenanceError(error: unknown): error is { response: { status: 503, data: { detail: MaintenanceError } } } {
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 isMaintenanceError(
error: unknown,
): error is { response: { status: 503; data: { detail: MaintenanceError } } } {
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 } } } {
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'
export function isChannelSubscriptionError(
error: unknown,
): error is { response: { status: 403; data: { detail: ChannelSubscriptionError } } } {
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)
apiClient.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// Handle maintenance mode (503)
if (isMaintenanceError(error)) {
const detail = (error.response?.data as { detail: MaintenanceError }).detail
const detail = (error.response?.data as { detail: MaintenanceError }).detail;
useBlockingStore.getState().setMaintenance({
message: detail.message,
reason: detail.reason,
})
return Promise.reject(error)
});
return Promise.reject(error);
}
// Handle channel subscription required (403)
if (isChannelSubscriptionError(error)) {
const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail
const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail;
useBlockingStore.getState().setChannelSubscription({
message: detail.message,
channel_link: detail.channel_link,
})
return Promise.reject(error)
});
return Promise.reject(error);
}
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
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 (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newToken}`
originalRequest.headers.Authorization = `Bearer ${newToken}`;
}
return apiClient(originalRequest)
return apiClient(originalRequest);
} else {
// Refresh не удался
tokenStorage.clearTokens()
safeRedirectToLogin()
tokenStorage.clearTokens();
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 {
id: number
slug: string
name: string
description: string | null
prize_days: number
is_available: boolean
already_played: boolean
id: number;
slug: string;
name: string;
description: string | null;
prize_days: number;
is_available: boolean;
already_played: boolean;
}
export interface ContestGameData {
round_id: number
game_type: string
game_data: Record<string, any>
instructions: string
round_id: number;
game_type: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
game_data: Record<string, any>;
instructions: string;
}
export interface ContestResult {
is_winner: boolean
message: string
prize_days?: number
is_winner: boolean;
message: string;
prize_days?: number;
}
export interface ContestsCountResponse {
count: number
count: number;
}
export const contestsApi = {
// Get count of available contests
getCount: async (): Promise<ContestsCountResponse> => {
const response = await apiClient.get<ContestsCountResponse>('/cabinet/contests/count')
return response.data
const response = await apiClient.get<ContestsCountResponse>('/cabinet/contests/count');
return response.data;
},
// Get list of available contests
getContests: async (): Promise<ContestInfo[]> => {
const response = await apiClient.get<ContestInfo[]>('/cabinet/contests')
return response.data
const response = await apiClient.get<ContestInfo[]>('/cabinet/contests');
return response.data;
},
// Get game data for a specific contest
getContestGame: async (roundId: number): Promise<ContestGameData> => {
const response = await apiClient.get<ContestGameData>(`/cabinet/contests/${roundId}`)
return response.data
const response = await apiClient.get<ContestGameData>(`/cabinet/contests/${roundId}`);
return response.data;
},
// Submit answer for a contest
@@ -51,7 +52,7 @@ export const contestsApi = {
const response = await apiClient.post<ContestResult>(`/cabinet/contests/${roundId}/answer`, {
round_id: roundId,
answer,
})
return response.data
});
return response.data;
},
}
};

View File

@@ -1,46 +1,46 @@
// Currency exchange rate API
// Uses free exchangerate.host API
import axios from 'axios'
import axios from 'axios';
interface ExchangeRates {
USD: number
CNY: number
IRR: number
USD: number;
CNY: number;
IRR: number;
}
interface CachedRates {
rates: ExchangeRates
timestamp: number
rates: ExchangeRates;
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)
const FALLBACK_RATES: ExchangeRates = {
USD: 100, // 1 USD = 100 RUB
CNY: 14, // 1 CNY = 14 RUB
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 = {
// Get all exchange rates (RUB to other currencies)
getExchangeRates: async (): Promise<ExchangeRates> => {
// Check cache first
if (cachedRates && Date.now() - cachedRates.timestamp < CACHE_DURATION) {
return cachedRates.rates
return cachedRates.rates;
}
try {
// Try primary API (exchangerate.host) - get RUB based rates
const response = await axios.get<{
success?: boolean
rates?: { USD?: number; CNY?: number; IRR?: number }
success?: boolean;
rates?: { USD?: number; CNY?: number; IRR?: number };
}>('https://api.exchangerate.host/latest', {
params: { base: 'RUB', symbols: 'USD,CNY,IRR' },
})
});
if (response.data.success && response.data.rates) {
// 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,
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,
}
cachedRates = { rates, timestamp: Date.now() }
return rates
};
cachedRates = { rates, timestamp: Date.now() };
return rates;
}
// Try backup API (open.er-api.com)
const backupResponse = await axios.get<{
rates?: { USD?: number; CNY?: number; IRR?: number }
}>('https://open.er-api.com/v6/latest/RUB')
rates?: { USD?: number; CNY?: number; IRR?: number };
}>('https://open.er-api.com/v6/latest/RUB');
if (backupResponse.data.rates) {
const rates: ExchangeRates = {
USD: backupResponse.data.rates.USD ? 1 / backupResponse.data.rates.USD : FALLBACK_RATES.USD,
CNY: backupResponse.data.rates.CNY ? 1 / backupResponse.data.rates.CNY : FALLBACK_RATES.CNY,
IRR: backupResponse.data.rates.IRR ? 1 / backupResponse.data.rates.IRR : FALLBACK_RATES.IRR,
}
cachedRates = { rates, timestamp: Date.now() }
return rates
USD: backupResponse.data.rates.USD
? 1 / backupResponse.data.rates.USD
: FALLBACK_RATES.USD,
CNY: backupResponse.data.rates.CNY
? 1 / backupResponse.data.rates.CNY
: 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
return FALLBACK_RATES;
} catch (error) {
console.warn('Failed to fetch exchange rates, using fallback:', error)
return FALLBACK_RATES
console.warn('Failed to fetch exchange rates, using fallback:', error);
return FALLBACK_RATES;
}
},
// Convert RUB to target currency
convertFromRub: (rubAmount: number, targetCurrency: keyof ExchangeRates, rates: ExchangeRates): number => {
const rate = rates[targetCurrency]
convertFromRub: (
rubAmount: number,
targetCurrency: keyof ExchangeRates,
rates: ExchangeRates,
): number => {
const rate = rates[targetCurrency];
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
convertToRub: (amount: number, sourceCurrency: keyof ExchangeRates, rates: ExchangeRates): number => {
const rate = rates[sourceCurrency]
convertToRub: (
amount: number,
sourceCurrency: keyof ExchangeRates,
rates: ExchangeRates,
): number => {
const rate = rates[sourceCurrency];
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 { authApi } from './auth'
export { subscriptionApi } from './subscription'
export { balanceApi } from './balance'
export { referralApi } from './referral'
export { ticketsApi } from './tickets'
export { contestsApi } from './contests'
export { pollsApi } from './polls'
export { promoApi } from './promo'
export { notificationsApi } from './notifications'
export { infoApi } from './info'
export { adminSettingsApi } from './adminSettings'
export { apiClient } from './client';
export { authApi } from './auth';
export { subscriptionApi } from './subscription';
export { balanceApi } from './balance';
export { referralApi } from './referral';
export { ticketsApi } from './tickets';
export { contestsApi } from './contests';
export { pollsApi } from './polls';
export { promoApi } from './promo';
export { notificationsApi } from './notifications';
export { infoApi } from './info';
export { adminSettingsApi } from './adminSettings';

View File

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

View File

@@ -1,22 +1,22 @@
import axios, { AxiosError } from 'axios'
import axios, { AxiosError } from 'axios';
export interface MiniappCreatePaymentPayload {
initData: string
method: string
amountKopeks?: number | null
option?: string | null
initData: string;
method: string;
amountKopeks?: number | null;
option?: string | null;
}
export interface MiniappCreatePaymentResponse {
payment_url: string
amount_kopeks?: number
extra?: Record<string, unknown>
payment_url: string;
amount_kopeks?: number;
extra?: Record<string, unknown>;
}
export const miniappApi = {
// Create payment inside Telegram Mini App (same flow as miniapp/index.html)
createPayment: async (
payload: MiniappCreatePaymentPayload
payload: MiniappCreatePaymentPayload,
): Promise<MiniappCreatePaymentResponse> => {
try {
const response = await axios.post<MiniappCreatePaymentResponse>(
@@ -29,15 +29,16 @@ export const miniappApi = {
},
{
headers: { 'Content-Type': 'application/json' },
}
)
return response.data
},
);
return response.data;
} catch (error) {
const axiosError = error as AxiosError<{ detail?: string; message?: string }>
const message = axiosError.response?.data?.detail
|| axiosError.response?.data?.message
|| 'Failed to create payment'
throw new Error(message)
const axiosError = error as AxiosError<{ detail?: string; message?: string }>;
const message =
axiosError.response?.data?.detail ||
axiosError.response?.data?.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 {
subscription_expiry_enabled: boolean
subscription_expiry_days: number
traffic_warning_enabled: boolean
traffic_warning_percent: number
balance_low_enabled: boolean
balance_low_threshold: number
news_enabled: boolean
promo_offers_enabled: boolean
subscription_expiry_enabled: boolean;
subscription_expiry_days: number;
traffic_warning_enabled: boolean;
traffic_warning_percent: number;
balance_low_enabled: boolean;
balance_low_threshold: number;
news_enabled: boolean;
promo_offers_enabled: boolean;
}
export interface NotificationSettingsUpdate {
subscription_expiry_enabled?: boolean
subscription_expiry_days?: number
traffic_warning_enabled?: boolean
traffic_warning_percent?: number
balance_low_enabled?: boolean
balance_low_threshold?: number
news_enabled?: boolean
promo_offers_enabled?: boolean
subscription_expiry_enabled?: boolean;
subscription_expiry_days?: number;
traffic_warning_enabled?: boolean;
traffic_warning_percent?: number;
balance_low_enabled?: boolean;
balance_low_threshold?: number;
news_enabled?: boolean;
promo_offers_enabled?: boolean;
}
export const notificationsApi = {
// Get notification settings
getSettings: async (): Promise<NotificationSettings> => {
const response = await apiClient.get<NotificationSettings>('/cabinet/notifications')
return response.data
const response = await apiClient.get<NotificationSettings>('/cabinet/notifications');
return response.data;
},
// Update notification settings
updateSettings: async (settings: NotificationSettingsUpdate): Promise<NotificationSettings> => {
const response = await apiClient.patch<NotificationSettings>('/cabinet/notifications', settings)
return response.data
const response = await apiClient.patch<NotificationSettings>(
'/cabinet/notifications',
settings,
);
return response.data;
},
// Send test notification
sendTestNotification: async (): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.post<{ success: boolean; message: string }>(
'/cabinet/notifications/test'
)
return response.data
'/cabinet/notifications/test',
);
return response.data;
},
// Get notification history
getHistory: async (limit = 20, offset = 0): Promise<{
notifications: any[]
total: number
limit: number
offset: number
getHistory: async (
limit = 20,
offset = 0,
): Promise<{
notifications: unknown[];
total: number;
limit: number;
offset: number;
}> => {
const response = await apiClient.get('/cabinet/notifications/history', {
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 {
id: number
text: string
order: number
id: number;
text: string;
order: number;
}
export interface PollQuestion {
id: number
text: string
order: number
options: PollOption[]
id: number;
text: string;
order: number;
options: PollOption[];
}
export interface PollInfo {
id: number
response_id: number
title: string
description: string | null
total_questions: number
answered_questions: number
is_completed: boolean
reward_amount: number | null
id: number;
response_id: number;
title: string;
description: string | null;
total_questions: number;
answered_questions: number;
is_completed: boolean;
reward_amount: number | null;
}
export interface PollStartResponse {
response_id: number
current_question_index: number
total_questions: number
question: PollQuestion
response_id: number;
current_question_index: number;
total_questions: number;
question: PollQuestion;
}
export interface PollAnswerResponse {
success: boolean
is_completed: boolean
next_question: PollQuestion | null
current_question_index: number | null
total_questions: number
reward_granted: number | null
message: string | null
success: boolean;
is_completed: boolean;
next_question: PollQuestion | null;
current_question_index: number | null;
total_questions: number;
reward_granted: number | null;
message: string | null;
}
export interface PollsCountResponse {
count: number
count: number;
}
export const pollsApi = {
// Get count of available polls
getCount: async (): Promise<PollsCountResponse> => {
const response = await apiClient.get<PollsCountResponse>('/cabinet/polls/count')
return response.data
const response = await apiClient.get<PollsCountResponse>('/cabinet/polls/count');
return response.data;
},
// Get available polls
getPolls: async (): Promise<PollInfo[]> => {
const response = await apiClient.get<PollInfo[]>('/cabinet/polls')
return response.data
const response = await apiClient.get<PollInfo[]>('/cabinet/polls');
return response.data;
},
// Get poll details
getPollDetails: async (responseId: number): Promise<PollInfo> => {
const response = await apiClient.get<PollInfo>(`/cabinet/polls/${responseId}`)
return response.data
const response = await apiClient.get<PollInfo>(`/cabinet/polls/${responseId}`);
return response.data;
},
// Start or continue poll
startPoll: async (responseId: number): Promise<PollStartResponse> => {
const response = await apiClient.post<PollStartResponse>(`/cabinet/polls/${responseId}/start`)
return response.data
const response = await apiClient.post<PollStartResponse>(`/cabinet/polls/${responseId}/start`);
return response.data;
},
// Answer a question
answerQuestion: async (
responseId: number,
questionId: number,
optionId: number
optionId: number,
): Promise<PollAnswerResponse> => {
const response = await apiClient.post<PollAnswerResponse>(
`/cabinet/polls/${responseId}/questions/${questionId}/answer`,
{ option_id: optionId }
)
return response.data
{ option_id: optionId },
);
return response.data;
},
}
};

View File

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

View File

@@ -1,139 +1,140 @@
import apiClient from './client'
import apiClient from './client';
// ============== Types ==============
export interface PromoOfferUserInfo {
id: number
telegram_id: number
username: string | null
first_name: string | null
last_name: string | null
full_name: string | null
id: number;
telegram_id: number;
username: string | null;
first_name: string | null;
last_name: string | null;
full_name: string | null;
}
export interface PromoOfferSubscriptionInfo {
id: number
status: string
is_trial: boolean
start_date: string
end_date: string
autopay_enabled: boolean
id: number;
status: string;
is_trial: boolean;
start_date: string;
end_date: string;
autopay_enabled: boolean;
}
export interface PromoOffer {
id: number
user_id: number
subscription_id: number | null
notification_type: string
discount_percent: number
bonus_amount_kopeks: number
expires_at: string
claimed_at: string | null
is_active: boolean
effect_type: string
extra_data: Record<string, any>
created_at: string
updated_at: string
user: PromoOfferUserInfo | null
subscription: PromoOfferSubscriptionInfo | null
id: number;
user_id: number;
subscription_id: number | null;
notification_type: string;
discount_percent: number;
bonus_amount_kopeks: number;
expires_at: string;
claimed_at: string | null;
is_active: boolean;
effect_type: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
extra_data: Record<string, any>;
created_at: string;
updated_at: string;
user: PromoOfferUserInfo | null;
subscription: PromoOfferSubscriptionInfo | null;
}
export interface PromoOfferListResponse {
items: PromoOffer[]
total: number
limit: number
offset: number
items: PromoOffer[];
total: number;
limit: number;
offset: number;
}
export interface PromoOfferBroadcastRequest {
notification_type: string
valid_hours: number
discount_percent?: number
bonus_amount_kopeks?: number
effect_type?: string
extra_data?: Record<string, any>
target?: string
user_id?: number
telegram_id?: number
notification_type: string;
valid_hours: number;
discount_percent?: number;
bonus_amount_kopeks?: number;
effect_type?: string;
extra_data?: Record<string, unknown>;
target?: string;
user_id?: number;
telegram_id?: number;
// Telegram notification options
send_notification?: boolean
message_text?: string
button_text?: string
send_notification?: boolean;
message_text?: string;
button_text?: string;
}
export interface PromoOfferBroadcastResponse {
created_offers: number
user_ids: number[]
target: string | null
notifications_sent: number
notifications_failed: number
created_offers: number;
user_ids: number[];
target: string | null;
notifications_sent: number;
notifications_failed: number;
}
export interface PromoOfferTemplate {
id: number
name: string
offer_type: string
message_text: string
button_text: string
valid_hours: number
discount_percent: number
bonus_amount_kopeks: number
active_discount_hours: number | null
test_duration_hours: number | null
test_squad_uuids: string[]
is_active: boolean
created_by: number | null
created_at: string
updated_at: string
id: number;
name: string;
offer_type: string;
message_text: string;
button_text: string;
valid_hours: number;
discount_percent: number;
bonus_amount_kopeks: number;
active_discount_hours: number | null;
test_duration_hours: number | null;
test_squad_uuids: string[];
is_active: boolean;
created_by: number | null;
created_at: string;
updated_at: string;
}
export interface PromoOfferTemplateListResponse {
items: PromoOfferTemplate[]
items: PromoOfferTemplate[];
}
export interface PromoOfferTemplateUpdateRequest {
name?: string
message_text?: string
button_text?: string
valid_hours?: number
discount_percent?: number
bonus_amount_kopeks?: number
active_discount_hours?: number
test_duration_hours?: number
test_squad_uuids?: string[]
is_active?: boolean
name?: string;
message_text?: string;
button_text?: string;
valid_hours?: number;
discount_percent?: number;
bonus_amount_kopeks?: number;
active_discount_hours?: number;
test_duration_hours?: number;
test_squad_uuids?: string[];
is_active?: boolean;
}
export interface PromoOfferLogOfferInfo {
id: number
notification_type: string | null
discount_percent: number | null
bonus_amount_kopeks: number | null
effect_type: string | null
expires_at: string | null
claimed_at: string | null
is_active: boolean | null
id: number;
notification_type: string | null;
discount_percent: number | null;
bonus_amount_kopeks: number | null;
effect_type: string | null;
expires_at: string | null;
claimed_at: string | null;
is_active: boolean | null;
}
export interface PromoOfferLog {
id: number
user_id: number | null
offer_id: number | null
action: string
source: string | null
percent: number | null
effect_type: string | null
details: Record<string, any>
created_at: string
user: PromoOfferUserInfo | null
offer: PromoOfferLogOfferInfo | null
id: number;
user_id: number | null;
offer_id: number | null;
action: string;
source: string | null;
percent: number | null;
effect_type: string | null;
details: Record<string, unknown>;
created_at: string;
user: PromoOfferUserInfo | null;
offer: PromoOfferLogOfferInfo | null;
}
export interface PromoOfferLogListResponse {
items: PromoOfferLog[]
total: number
limit: number
offset: number
items: PromoOfferLog[];
total: number;
limit: number;
offset: number;
}
// Target segments for broadcast
@@ -154,9 +155,9 @@ export const TARGET_SEGMENTS = {
custom_week: 'Зарегистрированы за неделю',
custom_month: 'Зарегистрированы за месяц',
custom_active_today: 'Активны сегодня',
} as const
} as const;
export type TargetSegment = keyof typeof TARGET_SEGMENTS
export type TargetSegment = keyof typeof TARGET_SEGMENTS;
// Offer type configurations
export const OFFER_TYPE_CONFIG = {
@@ -178,56 +179,61 @@ export const OFFER_TYPE_CONFIG = {
effect: 'percent_discount',
description: 'Скидка для новых пользователей',
},
} as const
} as const;
export type OfferType = keyof typeof OFFER_TYPE_CONFIG
export type OfferType = keyof typeof OFFER_TYPE_CONFIG;
// ============== API ==============
export const promoOffersApi = {
// Get list of promo offers
getOffers: async (params?: {
limit?: number
offset?: number
user_id?: number
is_active?: boolean
limit?: number;
offset?: number;
user_id?: number;
is_active?: boolean;
}): Promise<PromoOfferListResponse> => {
const response = await apiClient.get('/cabinet/admin/promo-offers', { params })
return response.data
const response = await apiClient.get('/cabinet/admin/promo-offers', { params });
return response.data;
},
// Broadcast offer to multiple users
broadcastOffer: async (data: PromoOfferBroadcastRequest): Promise<PromoOfferBroadcastResponse> => {
const response = await apiClient.post('/cabinet/admin/promo-offers/broadcast', data)
return response.data
broadcastOffer: async (
data: PromoOfferBroadcastRequest,
): Promise<PromoOfferBroadcastResponse> => {
const response = await apiClient.post('/cabinet/admin/promo-offers/broadcast', data);
return response.data;
},
// Get promo offer logs
getLogs: async (params?: {
limit?: number
offset?: number
user_id?: number
action?: string
limit?: number;
offset?: number;
user_id?: number;
action?: string;
}): Promise<PromoOfferLogListResponse> => {
const response = await apiClient.get('/cabinet/admin/promo-offers/logs', { params })
return response.data
const response = await apiClient.get('/cabinet/admin/promo-offers/logs', { params });
return response.data;
},
// Get all templates
getTemplates: async (): Promise<PromoOfferTemplateListResponse> => {
const response = await apiClient.get('/cabinet/admin/promo-offers/templates')
return response.data
const response = await apiClient.get('/cabinet/admin/promo-offers/templates');
return response.data;
},
// Get single template
getTemplate: async (id: number): Promise<PromoOfferTemplate> => {
const response = await apiClient.get(`/cabinet/admin/promo-offers/templates/${id}`)
return response.data
const response = await apiClient.get(`/cabinet/admin/promo-offers/templates/${id}`);
return response.data;
},
// Update template
updateTemplate: async (id: number, data: PromoOfferTemplateUpdateRequest): Promise<PromoOfferTemplate> => {
const response = await apiClient.patch(`/cabinet/admin/promo-offers/templates/${id}`, data)
return response.data
updateTemplate: async (
id: number,
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 ==============
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 {
id: number
code: string
type: PromoCodeType
balance_bonus_kopeks: number
balance_bonus_rubles: number
subscription_days: number
max_uses: number
current_uses: number
uses_left: number
is_active: boolean
is_valid: boolean
first_purchase_only: boolean
valid_from: string
valid_until: string | null
promo_group_id: number | null
created_by: number | null
created_at: string
updated_at: string
id: number;
code: string;
type: PromoCodeType;
balance_bonus_kopeks: number;
balance_bonus_rubles: number;
subscription_days: number;
max_uses: number;
current_uses: number;
uses_left: number;
is_active: boolean;
is_valid: boolean;
first_purchase_only: boolean;
valid_from: string;
valid_until: string | null;
promo_group_id: number | null;
created_by: number | null;
created_at: string;
updated_at: string;
}
export interface PromoCodeRecentUse {
id: number
user_id: number
user_username: string | null
user_full_name: string | null
user_telegram_id: number | null
used_at: string
id: number;
user_id: number;
user_username: string | null;
user_full_name: string | null;
user_telegram_id: number | null;
used_at: string;
}
export interface PromoCodeDetail extends PromoCode {
total_uses: number
today_uses: number
recent_uses: PromoCodeRecentUse[]
total_uses: number;
today_uses: number;
recent_uses: PromoCodeRecentUse[];
}
export interface PromoCodeListResponse {
items: PromoCode[]
total: number
limit: number
offset: number
items: PromoCode[];
total: number;
limit: number;
offset: number;
}
export interface PromoCodeCreateRequest {
code: string
type: PromoCodeType
balance_bonus_kopeks?: number
subscription_days?: number
max_uses?: number
valid_from?: string
valid_until?: string | null
is_active?: boolean
first_purchase_only?: boolean
promo_group_id?: number | null
code: string;
type: PromoCodeType;
balance_bonus_kopeks?: number;
subscription_days?: number;
max_uses?: number;
valid_from?: string;
valid_until?: string | null;
is_active?: boolean;
first_purchase_only?: boolean;
promo_group_id?: number | null;
}
export interface PromoCodeUpdateRequest {
code?: string
type?: PromoCodeType
balance_bonus_kopeks?: number
subscription_days?: number
max_uses?: number
valid_from?: string
valid_until?: string | null
is_active?: boolean
first_purchase_only?: boolean
promo_group_id?: number | null
code?: string;
type?: PromoCodeType;
balance_bonus_kopeks?: number;
subscription_days?: number;
max_uses?: number;
valid_from?: string;
valid_until?: string | null;
is_active?: boolean;
first_purchase_only?: boolean;
promo_group_id?: number | null;
}
// ============== PromoGroup Types ==============
export interface PromoGroup {
id: number
name: string
server_discount_percent: number
traffic_discount_percent: number
device_discount_percent: number
period_discounts: Record<number, number>
auto_assign_total_spent_kopeks: number | null
apply_discounts_to_addons: boolean
is_default: boolean
members_count: number
created_at: string | null
updated_at: string | null
id: number;
name: string;
server_discount_percent: number;
traffic_discount_percent: number;
device_discount_percent: number;
period_discounts: Record<number, number>;
auto_assign_total_spent_kopeks: number | null;
apply_discounts_to_addons: boolean;
is_default: boolean;
members_count: number;
created_at: string | null;
updated_at: string | null;
}
export interface PromoGroupListResponse {
items: PromoGroup[]
total: number
limit: number
offset: number
items: PromoGroup[];
total: number;
limit: number;
offset: number;
}
export interface PromoGroupCreateRequest {
name: string
server_discount_percent?: number
traffic_discount_percent?: number
device_discount_percent?: number
period_discounts?: Record<number, number>
auto_assign_total_spent_kopeks?: number | null
apply_discounts_to_addons?: boolean
is_default?: boolean
name: string;
server_discount_percent?: number;
traffic_discount_percent?: number;
device_discount_percent?: number;
period_discounts?: Record<number, number>;
auto_assign_total_spent_kopeks?: number | null;
apply_discounts_to_addons?: boolean;
is_default?: boolean;
}
export interface PromoGroupUpdateRequest {
name?: string
server_discount_percent?: number
traffic_discount_percent?: number
device_discount_percent?: number
period_discounts?: Record<number, number>
auto_assign_total_spent_kopeks?: number | null
apply_discounts_to_addons?: boolean
is_default?: boolean
name?: string;
server_discount_percent?: number;
traffic_discount_percent?: number;
device_discount_percent?: number;
period_discounts?: Record<number, number>;
auto_assign_total_spent_kopeks?: number | null;
apply_discounts_to_addons?: boolean;
is_default?: boolean;
}
// ============== API ==============
@@ -124,58 +129,58 @@ export interface PromoGroupUpdateRequest {
export const promocodesApi = {
// Promocodes
getPromocodes: async (params?: {
limit?: number
offset?: number
is_active?: boolean
limit?: number;
offset?: number;
is_active?: boolean;
}): Promise<PromoCodeListResponse> => {
const response = await apiClient.get('/cabinet/admin/promocodes', { params })
return response.data
const response = await apiClient.get('/cabinet/admin/promocodes', { params });
return response.data;
},
getPromocode: async (id: number): Promise<PromoCodeDetail> => {
const response = await apiClient.get(`/cabinet/admin/promocodes/${id}`)
return response.data
const response = await apiClient.get(`/cabinet/admin/promocodes/${id}`);
return response.data;
},
createPromocode: async (data: PromoCodeCreateRequest): Promise<PromoCode> => {
const response = await apiClient.post('/cabinet/admin/promocodes', data)
return response.data
const response = await apiClient.post('/cabinet/admin/promocodes', data);
return response.data;
},
updatePromocode: async (id: number, data: PromoCodeUpdateRequest): Promise<PromoCode> => {
const response = await apiClient.patch(`/cabinet/admin/promocodes/${id}`, data)
return response.data
const response = await apiClient.patch(`/cabinet/admin/promocodes/${id}`, data);
return response.data;
},
deletePromocode: async (id: number): Promise<void> => {
await apiClient.delete(`/cabinet/admin/promocodes/${id}`)
await apiClient.delete(`/cabinet/admin/promocodes/${id}`);
},
// Promo Groups
getPromoGroups: async (params?: {
limit?: number
offset?: number
limit?: number;
offset?: number;
}): Promise<PromoGroupListResponse> => {
const response = await apiClient.get('/cabinet/admin/promo-groups', { params })
return response.data
const response = await apiClient.get('/cabinet/admin/promo-groups', { params });
return response.data;
},
getPromoGroup: async (id: number): Promise<PromoGroup> => {
const response = await apiClient.get(`/cabinet/admin/promo-groups/${id}`)
return response.data
const response = await apiClient.get(`/cabinet/admin/promo-groups/${id}`);
return response.data;
},
createPromoGroup: async (data: PromoGroupCreateRequest): Promise<PromoGroup> => {
const response = await apiClient.post('/cabinet/admin/promo-groups', data)
return response.data
const response = await apiClient.post('/cabinet/admin/promo-groups', data);
return response.data;
},
updatePromoGroup: async (id: number, data: PromoGroupUpdateRequest): Promise<PromoGroup> => {
const response = await apiClient.patch(`/cabinet/admin/promo-groups/${id}`, data)
return response.data
const response = await apiClient.patch(`/cabinet/admin/promo-groups/${id}`, data);
return response.data;
},
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 type { ReferralInfo, ReferralTerms, PaginatedResponse } from '../types'
import apiClient from './client';
import type { ReferralInfo, ReferralTerms, PaginatedResponse } from '../types';
interface ReferralItem {
id: number
username: string | null
first_name: string | null
created_at: string
has_subscription: boolean
has_paid: boolean
id: number;
username: string | null;
first_name: string | null;
created_at: string;
has_subscription: boolean;
has_paid: boolean;
}
interface ReferralEarning {
id: number
amount_kopeks: number
amount_rubles: number
reason: string
referral_username: string | null
referral_first_name: string | null
created_at: string
id: number;
amount_kopeks: number;
amount_rubles: number;
reason: string;
referral_username: string | null;
referral_first_name: string | null;
created_at: string;
}
interface ReferralEarningsList extends PaginatedResponse<ReferralEarning> {
total_amount_kopeks: number
total_amount_rubles: number
total_amount_kopeks: number;
total_amount_rubles: number;
}
export const referralApi = {
// Get referral info
getReferralInfo: async (): Promise<ReferralInfo> => {
const response = await apiClient.get<ReferralInfo>('/cabinet/referral')
return response.data
const response = await apiClient.get<ReferralInfo>('/cabinet/referral');
return response.data;
},
// Get referral list
getReferralList: async (params?: {
page?: number
per_page?: number
page?: number;
per_page?: number;
}): Promise<PaginatedResponse<ReferralItem>> => {
const response = await apiClient.get<PaginatedResponse<ReferralItem>>('/cabinet/referral/list', {
const response = await apiClient.get<PaginatedResponse<ReferralItem>>(
'/cabinet/referral/list',
{
params,
})
return response.data
},
);
return response.data;
},
// Get referral earnings
getReferralEarnings: async (params?: {
page?: number
per_page?: number
page?: number;
per_page?: number;
}): Promise<ReferralEarningsList> => {
const response = await apiClient.get<ReferralEarningsList>('/cabinet/referral/earnings', {
params,
})
return response.data
});
return response.data;
},
// Get referral terms
getReferralTerms: async (): Promise<ReferralTerms> => {
const response = await apiClient.get<ReferralTerms>('/cabinet/referral/terms')
return response.data
const response = await apiClient.get<ReferralTerms>('/cabinet/referral/terms');
return response.data;
},
}
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,64 +1,80 @@
import apiClient from './client'
import type { TicketNotificationList, UnreadCountResponse } from '../types'
import apiClient from './client';
import type { TicketNotificationList, UnreadCountResponse } from '../types';
export const ticketNotificationsApi = {
// 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', {
params: { unread_only: unreadOnly, limit, offset }
})
return response.data
params: { unread_only: unreadOnly, limit, offset },
});
return response.data;
},
getUnreadCount: async (): Promise<UnreadCountResponse> => {
const response = await apiClient.get('/cabinet/tickets/notifications/unread-count')
return response.data
const response = await apiClient.get('/cabinet/tickets/notifications/unread-count');
return response.data;
},
markAsRead: async (notificationId: number): Promise<{ success: boolean }> => {
const response = await apiClient.post(`/cabinet/tickets/notifications/${notificationId}/read`)
return response.data
const response = await apiClient.post(`/cabinet/tickets/notifications/${notificationId}/read`);
return response.data;
},
markAllAsRead: async (): Promise<{ success: boolean; marked_count: number }> => {
const response = await apiClient.post('/cabinet/tickets/notifications/read-all')
return response.data
const response = await apiClient.post('/cabinet/tickets/notifications/read-all');
return response.data;
},
markTicketAsRead: async (ticketId: number): Promise<{ success: boolean; marked_count: number }> => {
const response = await apiClient.post(`/cabinet/tickets/notifications/ticket/${ticketId}/read`)
return response.data
markTicketAsRead: async (
ticketId: number,
): Promise<{ success: boolean; marked_count: number }> => {
const response = await apiClient.post(`/cabinet/tickets/notifications/ticket/${ticketId}/read`);
return response.data;
},
// Admin notifications
getAdminNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise<TicketNotificationList> => {
const params: Record<string, unknown> = { limit, offset }
getAdminNotifications: async (
unreadOnly = false,
limit = 50,
offset = 0,
): Promise<TicketNotificationList> => {
const params: Record<string, unknown> = { limit, offset };
if (unreadOnly) {
params.unread_only = true
params.unread_only = true;
}
const response = await apiClient.get('/cabinet/admin/tickets/notifications', { params })
return response.data
const response = await apiClient.get('/cabinet/admin/tickets/notifications', { params });
return response.data;
},
getAdminUnreadCount: async (): Promise<UnreadCountResponse> => {
const response = await apiClient.get('/cabinet/admin/tickets/notifications/unread-count')
return response.data
const response = await apiClient.get('/cabinet/admin/tickets/notifications/unread-count');
return response.data;
},
markAdminAsRead: async (notificationId: number): Promise<{ success: boolean }> => {
const response = await apiClient.post(`/cabinet/admin/tickets/notifications/${notificationId}/read`)
return response.data
const response = await apiClient.post(
`/cabinet/admin/tickets/notifications/${notificationId}/read`,
);
return response.data;
},
markAllAdminAsRead: async (): Promise<{ success: boolean; marked_count: number }> => {
const response = await apiClient.post('/cabinet/admin/tickets/notifications/read-all')
return response.data
const response = await apiClient.post('/cabinet/admin/tickets/notifications/read-all');
return response.data;
},
markAdminTicketAsRead: async (ticketId: number): Promise<{ success: boolean; marked_count: number }> => {
const response = await apiClient.post(`/cabinet/admin/tickets/notifications/ticket/${ticketId}/read`)
return response.data
markAdminTicketAsRead: async (
ticketId: number,
): 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 type { Ticket, TicketDetail, TicketMessage, PaginatedResponse } from '../types'
import apiClient from './client';
import type { Ticket, TicketDetail, TicketMessage, PaginatedResponse } from '../types';
// Media upload response type
interface MediaUploadResponse {
media_type: string
file_id: string
file_unique_id: string | null
media_url: string
media_type: string;
file_id: string;
file_unique_id: string | null;
media_url: string;
}
// Media parameters for ticket messages
interface MediaParams {
media_type?: string
media_file_id?: string
media_caption?: string
media_type?: string;
media_file_id?: string;
media_caption?: string;
}
export const ticketsApi = {
// Get tickets list
getTickets: async (params?: {
page?: number
per_page?: number
status?: string
page?: number;
per_page?: number;
status?: string;
}): Promise<PaginatedResponse<Ticket>> => {
const response = await apiClient.get<PaginatedResponse<Ticket>>('/cabinet/tickets', {
params,
})
return response.data
});
return response.data;
},
// Create ticket with optional media
createTicket: async (
title: string,
message: string,
media?: MediaParams
media?: MediaParams,
): Promise<TicketDetail> => {
const response = await apiClient.post<TicketDetail>('/cabinet/tickets', {
title,
message,
...media,
})
return response.data
});
return response.data;
},
// Get ticket detail
getTicket: async (ticketId: number): Promise<TicketDetail> => {
const response = await apiClient.get<TicketDetail>(`/cabinet/tickets/${ticketId}`)
return response.data
const response = await apiClient.get<TicketDetail>(`/cabinet/tickets/${ticketId}`);
return response.data;
},
// Add message to ticket with optional media
addMessage: async (
ticketId: number,
message: string,
media?: MediaParams
media?: MediaParams,
): Promise<TicketMessage> => {
const response = await apiClient.post<TicketMessage>(`/cabinet/tickets/${ticketId}/messages`, {
message,
...media,
})
return response.data
});
return response.data;
},
// Upload media file for tickets
uploadMedia: async (file: File, mediaType: string = 'photo'): Promise<MediaUploadResponse> => {
const formData = new FormData()
formData.append('file', file)
formData.append('media_type', mediaType)
const formData = new FormData();
formData.append('file', file);
formData.append('media_type', mediaType);
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
return response.data
});
return response.data;
},
// Get media URL for display
getMediaUrl: (fileId: string): string => {
const baseUrl = import.meta.env.VITE_API_URL || ''
return `${baseUrl}/cabinet/media/${fileId}`
const baseUrl = import.meta.env.VITE_API_URL || '';
return `${baseUrl}/cabinet/media/${fileId}`;
},
}
};

View File

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

View File

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

View File

@@ -1,238 +1,286 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { createPortal } from 'react-dom';
interface ColorPickerProps {
value: string
onChange: (color: string) => void
label: string
description?: string
disabled?: boolean
value: string;
onChange: (color: string) => void;
label: string;
description?: string;
disabled?: boolean;
}
// Check if running in Telegram WebApp
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
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
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: { r: 0, g: 0, b: 0 }
}
: { r: 0, g: 0, b: 0 };
};
// Convert RGB to hex
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
const rgbToHsl = (r: number, g: number, b: number): { h: number; s: number; l: number } => {
r /= 255; g /= 255; b /= 255
const max = Math.max(r, g, b), min = Math.min(r, g, b)
let h = 0, s = 0
const l = (max + min) / 2
r /= 255;
g /= 255;
b /= 255;
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) {
const d = max - min
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break
case g: h = ((b - r) / d + 2) / 6; break
case b: h = ((r - g) / d + 4) / 6; break
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
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
const hslToRgb = (h: number, s: number, l: number): { r: number; g: number; b: number } => {
h /= 360; s /= 100; l /= 100
let r, g, b
h /= 360;
s /= 100;
l /= 100;
let r, g, b;
if (s === 0) {
r = g = b = l
r = g = b = l;
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1/6) return p + (q - p) * 6 * t
if (t < 1/2) return q
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
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)
}
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
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);
}
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) };
};
const PRESET_COLORS = [
'#3b82f6', '#ef4444', '#22c55e', '#f59e0b',
'#8b5cf6', '#ec4899', '#06b6d4', '#14b8a6',
'#84cc16', '#f97316', '#6366f1', '#a855f7',
]
'#3b82f6',
'#ef4444',
'#22c55e',
'#f59e0b',
'#8b5cf6',
'#ec4899',
'#06b6d4',
'#14b8a6',
'#84cc16',
'#f97316',
'#6366f1',
'#a855f7',
];
export function ColorPicker({ value, onChange, label, description, disabled }: ColorPickerProps) {
const [isOpen, setIsOpen] = useState(false)
const [localValue, setLocalValue] = useState(value)
const [isOpen, setIsOpen] = useState(false);
const [localValue, setLocalValue] = useState(value);
const [hsl, setHsl] = useState(() => {
const rgb = hexToRgb(value)
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 rgb = hexToRgb(value);
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 buttonRef = useRef<HTMLButtonElement>(null)
const pickerRef = useRef<HTMLDivElement>(null)
const colorInputRef = useRef<HTMLInputElement>(null)
const buttonRef = useRef<HTMLButtonElement>(null);
const pickerRef = useRef<HTMLDivElement>(null);
const colorInputRef = useRef<HTMLInputElement>(null);
const isTelegram = useMemo(() => isTelegramWebApp(), [])
const isTelegram = useMemo(() => isTelegramWebApp(), []);
// Sync with external value
useEffect(() => {
setLocalValue(value)
const rgb = hexToRgb(value)
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
}, [value])
setLocalValue(value);
const rgb = hexToRgb(value);
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
}, [value]);
// Calculate picker position
const updatePosition = useCallback(() => {
if (!buttonRef.current) return
if (!buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect()
const pickerHeight = 320
const pickerWidth = 280
const padding = 12
const rect = buttonRef.current.getBoundingClientRect();
const pickerHeight = 320;
const pickerWidth = 280;
const padding = 12;
// Check if there's space below
const spaceBelow = window.innerHeight - rect.bottom
const spaceAbove = rect.top
const openUp = spaceBelow < pickerHeight + padding && spaceAbove > spaceBelow
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const openUp = spaceBelow < pickerHeight + padding && spaceAbove > spaceBelow;
// Calculate left position (ensure it stays in viewport)
let left = rect.left
let left = rect.left;
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({
top: openUp ? rect.top - pickerHeight - 8 : rect.bottom + 8,
left,
openUp
})
}, [])
openUp,
});
}, []);
// Open picker
const handleOpen = useCallback(() => {
if (disabled) return
updatePosition()
setIsOpen(true)
}, [disabled, updatePosition])
if (disabled) return;
updatePosition();
setIsOpen(true);
}, [disabled, updatePosition]);
// Close picker
const handleClose = useCallback(() => {
setIsOpen(false)
}, [])
setIsOpen(false);
}, []);
// Handle click outside
useEffect(() => {
if (!isOpen) return
if (!isOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (
pickerRef.current && !pickerRef.current.contains(e.target as Node) &&
buttonRef.current && !buttonRef.current.contains(e.target as Node)
pickerRef.current &&
!pickerRef.current.contains(e.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(e.target as Node)
) {
handleClose()
}
handleClose();
}
};
const handleScroll = () => handleClose()
const handleResize = () => updatePosition()
const handleScroll = () => handleClose();
const handleResize = () => updatePosition();
document.addEventListener('mousedown', handleClickOutside)
document.addEventListener('touchstart', handleClickOutside as EventListener)
window.addEventListener('scroll', handleScroll, true)
window.addEventListener('resize', handleResize)
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchstart', handleClickOutside as EventListener);
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', handleResize);
return () => {
document.removeEventListener('mousedown', handleClickOutside)
document.removeEventListener('touchstart', handleClickOutside as EventListener)
window.removeEventListener('scroll', handleScroll, true)
window.removeEventListener('resize', handleResize)
}
}, [isOpen, handleClose, updatePosition])
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchstart', handleClickOutside as EventListener);
window.removeEventListener('scroll', handleScroll, true);
window.removeEventListener('resize', handleResize);
};
}, [isOpen, handleClose, updatePosition]);
// Update color from HSL
const updateColorFromHsl = useCallback((newHsl: { h: number; s: number; l: number }) => {
const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l)
const hex = rgbToHex(rgb.r, rgb.g, rgb.b)
setHsl(newHsl)
setLocalValue(hex)
onChange(hex)
}, [onChange])
const updateColorFromHsl = useCallback(
(newHsl: { h: number; s: number; l: number }) => {
const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l);
const hex = rgbToHex(rgb.r, rgb.g, rgb.b);
setHsl(newHsl);
setLocalValue(hex);
onChange(hex);
},
[onChange],
);
// Handle hue change
const handleHueChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
updateColorFromHsl({ ...hsl, h: parseInt(e.target.value) })
}, [hsl, updateColorFromHsl])
const handleHueChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateColorFromHsl({ ...hsl, h: parseInt(e.target.value) });
},
[hsl, updateColorFromHsl],
);
// Handle saturation change
const handleSaturationChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
updateColorFromHsl({ ...hsl, s: parseInt(e.target.value) })
}, [hsl, updateColorFromHsl])
const handleSaturationChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateColorFromHsl({ ...hsl, s: parseInt(e.target.value) });
},
[hsl, updateColorFromHsl],
);
// Handle lightness change
const handleLightnessChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
updateColorFromHsl({ ...hsl, l: parseInt(e.target.value) })
}, [hsl, updateColorFromHsl])
const handleLightnessChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
updateColorFromHsl({ ...hsl, l: parseInt(e.target.value) });
},
[hsl, updateColorFromHsl],
);
// Handle native color input
const handleColorInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const newColor = e.target.value
setLocalValue(newColor)
const rgb = hexToRgb(newColor)
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
onChange(newColor)
}, [onChange])
const handleColorInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newColor = e.target.value;
setLocalValue(newColor);
const rgb = hexToRgb(newColor);
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
onChange(newColor);
},
[onChange],
);
// Handle hex input
const handleHexInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
let newValue = e.target.value
const handleHexInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
let newValue = e.target.value;
if (newValue && !newValue.startsWith('#')) {
newValue = '#' + newValue
newValue = '#' + newValue;
}
if (newValue === '' || newValue.match(/^#[0-9A-Fa-f]{0,6}$/)) {
setLocalValue(newValue)
setLocalValue(newValue);
if (newValue.match(/^#[0-9A-Fa-f]{6}$/)) {
const rgb = hexToRgb(newValue)
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
onChange(newValue)
const rgb = hexToRgb(newValue);
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
onChange(newValue);
}
}
}, [onChange])
},
[onChange],
);
// Handle preset click
const handlePresetClick = useCallback((color: string) => {
setLocalValue(color)
const rgb = hexToRgb(color)
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
onChange(color)
handleClose()
}, [onChange, handleClose])
const handlePresetClick = useCallback(
(color: string) => {
setLocalValue(color);
const rgb = hexToRgb(color);
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
onChange(color);
handleClose();
},
[onChange, handleClose],
);
// Picker content
const pickerContent = isOpen ? (
<div
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={{
top: pickerPosition.top,
left: pickerPosition.left,
@@ -240,13 +288,10 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
onClick={(e) => e.stopPropagation()}
>
{/* Color preview header */}
<div
className="h-16 w-full"
style={{ backgroundColor: localValue || '#000000' }}
/>
<div className="h-16 w-full" style={{ backgroundColor: localValue || '#000000' }} />
{/* Controls */}
<div className="p-4 space-y-4">
<div className="space-y-4 p-4">
{/* Hue slider */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
@@ -259,9 +304,10 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
max="360"
value={hsl.h}
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={{
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>
@@ -278,7 +324,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
max="100"
value={hsl.s}
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={{
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"
value={hsl.l}
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={{
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"
value={localValue}
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"
maxLength={7}
/>
</div>
{/* Presets */}
<div className="pt-2 border-t border-dark-700">
<span className="text-xs font-medium text-dark-400 block mb-2">Presets</span>
<div className="border-t border-dark-700 pt-2">
<span className="mb-2 block text-xs font-medium text-dark-400">Presets</span>
<div className="grid grid-cols-6 gap-1.5">
{PRESET_COLORS.map((preset) => (
<button
key={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()
? '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>
) : null
) : null;
return (
<div className="relative min-w-0 overflow-hidden">
<label className="block text-sm font-medium text-dark-200 mb-1 truncate">{label}</label>
{description && <p className="text-xs text-dark-500 mb-2 truncate">{description}</p>}
<label className="mb-1 block truncate text-sm font-medium text-dark-200">{label}</label>
{description && <p className="mb-2 truncate text-xs text-dark-500">{description}</p>}
<div className="flex items-center gap-2">
{/* Color preview button */}
@@ -352,7 +398,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
type="button"
onClick={handleOpen}
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' }}
title={localValue}
/>
@@ -363,7 +409,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
value={localValue}
onChange={handleHexInputChange}
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"
maxLength={7}
/>
@@ -383,11 +429,21 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
type="button"
onClick={() => colorInputRef.current?.click()}
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"
>
<svg className="w-5 h-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
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>
</button>
</>
@@ -397,5 +453,5 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
{/* Render picker in portal */}
{typeof document !== 'undefined' && createPortal(pickerContent, document.body)}
</div>
)
);
}

View File

@@ -1,55 +1,63 @@
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { subscriptionApi } from '../api/subscription'
import { useTelegramWebApp } from '../hooks/useTelegramWebApp'
import type { AppInfo, AppConfig, LocalizedText } from '../types'
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { subscriptionApi } from '../api/subscription';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import type { AppInfo, AppConfig, LocalizedText } from '../types';
interface ConnectionModalProps {
onClose: () => void
onClose: () => void;
}
// Icons
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" />
</svg>
)
);
const CopyIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
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" />
</svg>
)
);
const LinkIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
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" />
</svg>
)
);
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" />
</svg>
)
);
// App icons
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="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" />
@@ -57,409 +65,473 @@ const HappIcon = () => (
<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" />
</svg>
)
);
const ClashMetaIcon = () => (
<svg className="w-6 h-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"/>
<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"
/>
</svg>
)
);
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
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>
)
);
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" />
</svg>
)
);
const getAppIcon = (appName: string): React.ReactNode => {
const name = appName.toLowerCase()
if (name.includes('happ')) return <HappIcon />
if (name.includes('shadowrocket') || name.includes('rocket')) return <ShadowrocketIcon />
if (name.includes('streisand')) return <StreisandIcon />
if (name.includes('clash') || name.includes('meta') || name.includes('verge')) return <ClashMetaIcon />
return <span className="text-lg">📦</span>
}
const name = appName.toLowerCase();
if (name.includes('happ')) return <HappIcon />;
if (name.includes('shadowrocket') || name.includes('rocket')) return <ShadowrocketIcon />;
if (name.includes('streisand')) return <StreisandIcon />;
if (name.includes('clash') || name.includes('meta') || name.includes('verge'))
return <ClashMetaIcon />;
return <span className="text-lg">📦</span>;
};
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV']
const dangerousSchemes = ['javascript:', 'data:', 'vbscript:', 'file:']
const platformOrder = ['ios', 'android', 'windows', 'macos', 'linux', 'androidTV', 'appleTV'];
// 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 {
if (!url) return false
const lowerUrl = url.toLowerCase().trim()
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false
return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://')
if (!url) return false;
const lowerUrl = url.toLowerCase().trim();
if (dangerousSchemes.some((scheme) => lowerUrl.startsWith(scheme))) return false;
return lowerUrl.startsWith('http://') || lowerUrl.startsWith('https://');
}
function isValidDeepLink(url: string | undefined): boolean {
if (!url) return false
const lowerUrl = url.toLowerCase().trim()
if (dangerousSchemes.some(scheme => lowerUrl.startsWith(scheme))) return false
return lowerUrl.includes('://')
if (!url) return false;
const lowerUrl = url.toLowerCase().trim();
if (dangerousSchemes.some((scheme) => lowerUrl.startsWith(scheme))) return false;
return lowerUrl.includes('://');
}
function detectPlatform(): string | null {
if (typeof window === 'undefined' || !navigator?.userAgent) return null
const ua = navigator.userAgent.toLowerCase()
if (/iphone|ipad|ipod/.test(ua)) return 'ios'
if (/android/.test(ua)) return /tv|television/.test(ua) ? 'androidTV' : 'android'
if (/macintosh|mac os x/.test(ua)) return 'macos'
if (/windows/.test(ua)) return 'windows'
if (/linux/.test(ua)) return 'linux'
return null
if (typeof window === 'undefined' || !navigator?.userAgent) return null;
const ua = navigator.userAgent.toLowerCase();
if (/iphone|ipad|ipod/.test(ua)) return 'ios';
if (/android/.test(ua)) return /tv|television/.test(ua) ? 'androidTV' : 'android';
if (/macintosh|mac os x/.test(ua)) return 'macos';
if (/windows/.test(ua)) return 'windows';
if (/linux/.test(ua)) return 'linux';
return null;
}
function useIsMobile() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === 'undefined') return false
return window.innerWidth < 768
})
if (typeof window === 'undefined') return false;
return window.innerWidth < 768;
});
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 768)
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
return isMobile
const check = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check);
}, []);
return isMobile;
}
export default function ConnectionModal({ onClose }: ConnectionModalProps) {
const { t, i18n } = useTranslation()
const [selectedApp, setSelectedApp] = useState<AppInfo | null>(null)
const [copied, setCopied] = useState(false)
const [showAppSelector, setShowAppSelector] = useState(false)
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null)
const { t, i18n } = useTranslation();
const [selectedApp, setSelectedApp] = useState<AppInfo | null>(null);
const [copied, setCopied] = useState(false);
const [showAppSelector, setShowAppSelector] = useState(false);
const [selectedPlatform, setSelectedPlatform] = useState<string | null>(null);
const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp()
const isMobileScreen = useIsMobile()
const isMobile = isMobileScreen
const scrollContainerRef = useRef<HTMLDivElement>(null)
const { isTelegramWebApp, isFullscreen, safeAreaInset, contentSafeAreaInset, webApp } =
useTelegramWebApp();
const isMobileScreen = useIsMobile();
const isMobile = isMobileScreen;
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Ref для хранения актуального обработчика BackButton (фикс мигания)
const backButtonHandlerRef = useRef<() => void>(() => {})
const backButtonHandlerRef = useRef<() => void>(() => {});
// Prevent scroll events from bubbling to parent/Telegram
const handleScrollContainerWheel = useCallback((e: React.WheelEvent) => {
const container = e.currentTarget
const { scrollTop, scrollHeight, clientHeight } = container
const isAtTop = scrollTop === 0
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1
const container = e.currentTarget;
const { scrollTop, scrollHeight, clientHeight } = container;
const isAtTop = scrollTop === 0;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1;
// Prevent scroll propagation when not at boundaries, or when scrolling away from boundary
if ((!isAtTop && !isAtBottom) ||
(isAtTop && e.deltaY > 0) ||
(isAtBottom && e.deltaY < 0)) {
e.stopPropagation()
if ((!isAtTop && !isAtBottom) || (isAtTop && e.deltaY > 0) || (isAtBottom && e.deltaY < 0)) {
e.stopPropagation();
}
}, [])
}, []);
const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0
const safeTop = isTelegramWebApp ? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + (isFullscreen ? 45 : 0) : 0
const safeBottom = isTelegramWebApp
? 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'],
queryFn: () => subscriptionApi.getAppConfig(),
})
});
const detectedPlatform = useMemo(() => detectPlatform(), [])
const detectedPlatform = useMemo(() => detectPlatform(), []);
useEffect(() => {
if (!appConfig?.platforms || selectedApp) return
let platform = detectedPlatform
if (!appConfig?.platforms || selectedApp) return;
let platform = detectedPlatform;
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
const apps = appConfig.platforms[platform]
const app = apps.find(a => a.isFeatured) || apps[0]
if (app) setSelectedApp(app)
}, [appConfig, detectedPlatform, selectedApp])
if (!platform || !appConfig.platforms[platform]?.length) return;
const apps = appConfig.platforms[platform];
const app = apps.find((a) => a.isFeatured) || apps[0];
if (app) setSelectedApp(app);
}, [appConfig, detectedPlatform, selectedApp]);
const handleClose = useCallback(() => {
onClose()
}, [onClose])
onClose();
}, [onClose]);
const handleBack = useCallback(() => {
if (selectedPlatform) {
setSelectedPlatform(null)
setSelectedPlatform(null);
} else {
setShowAppSelector(false)
setShowAppSelector(false);
}
}, [selectedPlatform])
}, [selectedPlatform]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
if (showAppSelector) handleBack()
else handleClose()
e.preventDefault();
if (showAppSelector) handleBack();
else handleClose();
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleClose, handleBack, showAppSelector])
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleClose, handleBack, showAppSelector]);
// Обновляем ref при изменении логики (без перезапуска эффекта BackButton)
useEffect(() => {
backButtonHandlerRef.current = showAppSelector ? handleBack : handleClose
}, [showAppSelector, handleBack, handleClose])
backButtonHandlerRef.current = showAppSelector ? handleBack : handleClose;
}, [showAppSelector, handleBack, handleClose]);
// Управление BackButton — эффект запускается только при mount/unmount
// Используем стабильный обработчик через ref, чтобы избежать мигания
useEffect(() => {
if (!webApp?.BackButton) return
if (!webApp?.BackButton) return;
const stableHandler = () => {
backButtonHandlerRef.current()
}
backButtonHandlerRef.current();
};
webApp.BackButton.show()
webApp.BackButton.onClick(stableHandler)
webApp.BackButton.show();
webApp.BackButton.onClick(stableHandler);
return () => {
webApp.BackButton.offClick(stableHandler)
webApp.BackButton.hide()
}
}, [webApp]) // Только webApp в зависимостях!
webApp.BackButton.offClick(stableHandler);
webApp.BackButton.hide();
};
}, [webApp]); // Только webApp в зависимостях!
useEffect(() => {
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = '' }
}, [])
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}, []);
const getLocalizedText = (text: LocalizedText | undefined): string => {
if (!text) return ''
const lang = i18n.language || 'en'
return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || ''
}
if (!text) return '';
const lang = i18n.language || 'en';
return text[lang] || text['en'] || text['ru'] || Object.values(text)[0] || '';
};
const availablePlatforms = useMemo(() => {
if (!appConfig?.platforms) return []
const available = platformOrder.filter(key => appConfig.platforms[key]?.length > 0)
if (!appConfig?.platforms) return [];
const available = platformOrder.filter((key) => appConfig.platforms[key]?.length > 0);
if (detectedPlatform && available.includes(detectedPlatform)) {
return [detectedPlatform, ...available.filter(p => p !== detectedPlatform)]
return [detectedPlatform, ...available.filter((p) => p !== detectedPlatform)];
}
return available
}, [appConfig, detectedPlatform])
return available;
}, [appConfig, detectedPlatform]);
const copySubscriptionLink = async () => {
if (!appConfig?.subscriptionUrl) return
if (!appConfig?.subscriptionUrl) return;
try {
await navigator.clipboard.writeText(appConfig.subscriptionUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
await navigator.clipboard.writeText(appConfig.subscriptionUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
const textarea = document.createElement('textarea')
textarea.value = appConfig.subscriptionUrl
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const textarea = document.createElement('textarea');
textarea.value = appConfig.subscriptionUrl;
document.body.appendChild(textarea);
textarea.select();
document.execCommand('copy');
document.body.removeChild(textarea);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleConnect = (app: AppInfo) => {
if (!app.deepLink || !isValidDeepLink(app.deepLink)) return
const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en'
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
if (!app.deepLink || !isValidDeepLink(app.deepLink)) return;
const lang = i18n.language?.startsWith('ru') ? 'ru' : 'en';
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;
if (tg?.openLink) {
try {
tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true })
return
} catch { /* fallback */ }
tg.openLink(redirectUrl, { try_instant_view: false, try_browser: true });
return;
} catch {
/* fallback */
}
window.location.href = redirectUrl
}
window.location.href = redirectUrl;
};
// Wrapper component
const Wrapper = ({ children }: { children: React.ReactNode }) => {
if (isMobile) {
const content = (
<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={{
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}
</div>
)
if (typeof document !== 'undefined') return createPortal(content, document.body)
return content
);
if (typeof document !== 'undefined') return createPortal(content, document.body);
return content;
}
// Desktop centered - positioned higher
return (
<div className="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[8vh]" onClick={handleClose}>
<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"
onClick={e => e.stopPropagation()}
className="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[8vh]"
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}
</div>
</div>
)
}
);
};
// Loading
if (isLoading) {
return (
<Wrapper>
<div className="flex-1 flex 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="flex flex-1 items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-[3px] border-accent-500/30 border-t-accent-500" />
</div>
</Wrapper>
)
);
}
// Error
if (error || !appConfig) {
return (
<Wrapper>
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center">
<p className="text-dark-300 text-lg mb-4">{t('common.error')}</p>
<button onClick={handleClose} className="btn-primary px-6 py-2">{t('common.close')}</button>
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
<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>
</div>
</Wrapper>
)
);
}
// No subscription
if (!appConfig.hasSubscription) {
return (
<Wrapper>
<div className="flex-1 flex 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>
<p className="text-dark-400 mb-4">{t('subscription.connection.noSubscription')}</p>
<button onClick={handleClose} className="btn-primary px-6 py-2">{t('common.close')}</button>
<div className="flex flex-1 flex-col items-center justify-center p-8 text-center">
<h3 className="mb-2 text-xl font-bold text-dark-100">
{t('subscription.connection.title')}
</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>
</Wrapper>
)
);
}
// App selector
if (showAppSelector) {
const platformNames: Record<string, string> = {
ios: 'iOS', android: 'Android', windows: 'Windows',
macos: 'macOS', linux: 'Linux', androidTV: 'Android TV', appleTV: 'Apple TV'
}
ios: 'iOS',
android: 'Android',
windows: 'Windows',
macos: 'macOS',
linux: 'Linux',
androidTV: 'Android TV',
appleTV: 'Apple TV',
};
const platformIcons: Record<string, React.ReactNode> = {
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" />
</svg>
),
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" />
</svg>
),
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" />
</svg>
),
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" />
</svg>
),
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" />
</svg>
),
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" />
</svg>
),
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" />
</svg>
),
}
};
// Step 1: Platform selection
if (!selectedPlatform) {
return (
<Wrapper>
<div className="flex items-center gap-3 p-4 border-b border-dark-800">
<button onClick={handleBack} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-300">
<div className="flex items-center gap-3 border-b border-dark-800 p-4">
<button
onClick={handleBack}
className="-ml-2 rounded-xl p-2 text-dark-300 hover:bg-dark-800"
>
<BackIcon />
</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 className="p-4 space-y-2">
{availablePlatforms.map(platform => {
const apps = appConfig.platforms[platform]
if (!apps?.length) return null
const isCurrentPlatform = platform === detectedPlatform
const appCount = apps.length
<div className="space-y-2 p-4">
{availablePlatforms.map((platform) => {
const apps = appConfig.platforms[platform];
if (!apps?.length) return null;
const isCurrentPlatform = platform === detectedPlatform;
const appCount = apps.length;
return (
<button
key={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
? 'bg-accent-500/10 ring-1 ring-accent-500/30'
: 'bg-dark-800/50 hover:bg-dark-800'
}`}
>
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
isCurrentPlatform ? 'bg-accent-500/20 text-accent-400' : 'bg-dark-700 text-dark-300'
}`}>
<div
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>}
</div>
<div className="flex-1 text-left">
<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}
</span>
{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')}
</span>
)}
</div>
<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>
</div>
<ChevronIcon />
</button>
)
);
})}
</div>
</Wrapper>
)
);
}
// Step 2: App selection for chosen platform
const apps = appConfig.platforms[selectedPlatform] || []
const isCurrentPlatform = selectedPlatform === detectedPlatform
const apps = appConfig.platforms[selectedPlatform] || [];
const isCurrentPlatform = selectedPlatform === detectedPlatform;
return (
<Wrapper>
<div className="flex items-center gap-3 p-4 border-b border-dark-800">
<button onClick={handleBack} className="p-2 -ml-2 rounded-xl hover:bg-dark-800 text-dark-300">
<div className="flex items-center gap-3 border-b border-dark-800 p-4">
<button
onClick={handleBack}
className="-ml-2 rounded-xl p-2 text-dark-300 hover:bg-dark-800"
>
<BackIcon />
</button>
<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 && (
<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>
@@ -470,13 +542,17 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
onWheel={handleScrollContainerWheel}
>
<div className="grid grid-cols-3 gap-3">
{apps.map(app => {
const isSelected = selectedApp?.id === app.id
{apps.map((app) => {
const isSelected = selectedApp?.id === app.id;
return (
<button
key={app.id}
onClick={() => { setSelectedApp(app); setShowAppSelector(false); setSelectedPlatform(null) }}
className={`relative flex flex-col items-center p-4 rounded-xl transition-all active:scale-95 ${
onClick={() => {
setSelectedApp(app);
setShowAppSelector(false);
setSelectedPlatform(null);
}}
className={`relative flex flex-col items-center rounded-xl p-4 transition-all active:scale-95 ${
isSelected
? 'bg-accent-500/15 ring-2 ring-accent-500/50'
: 'bg-dark-800/50 hover:bg-dark-800'
@@ -484,85 +560,107 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
>
{/* Featured badge */}
{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">
<svg className="w-3.5 h-3.5 text-white" fill="currentColor" viewBox="0 0 20 20">
<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="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" />
</svg>
</div>
)}
{/* 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
? 'bg-accent-500/20 text-accent-400'
: 'bg-dark-700/80 text-dark-300'
}`}>
}`}
>
{getAppIcon(app.name)}
</div>
{/* 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'
}`}>
}`}
>
{app.name}
</span>
{/* Selection indicator */}
{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>
)
);
})}
</div>
</div>
</Wrapper>
)
);
}
// Main view
return (
<Wrapper>
<div className="p-4 border-b border-dark-800">
<div className="flex items-center justify-between mb-3">
<h2 className="font-bold text-dark-100 text-lg">{t('subscription.connection.title')}</h2>
<button onClick={handleClose} className="p-2 -mr-2 rounded-xl hover:bg-dark-800 text-dark-400">
<div className="border-b border-dark-800 p-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-bold text-dark-100">{t('subscription.connection.title')}</h2>
<button
onClick={handleClose}
className="-mr-2 rounded-xl p-2 text-dark-400 hover:bg-dark-800"
>
<CloseIcon />
</button>
</div>
<button
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)}
</div>
<div className="flex-1 text-left">
<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>
<ChevronIcon />
</button>
</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' }}
onWheel={handleScrollContainerWheel}
>
{selectedApp?.installationStep && (
<div className="space-y-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="font-medium text-dark-100">{t('subscription.connection.installApp')}</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">
1
</span>
<span className="font-medium text-dark-100">
{t('subscription.connection.installApp')}
</span>
</div>
<p className="text-dark-400 text-sm ml-8">{getLocalizedText(selectedApp.installationStep.description)}</p>
{selectedApp.installationStep.buttons && selectedApp.installationStep.buttons.length > 0 && (
<div className="flex flex-wrap gap-2 ml-8">
{selectedApp.installationStep.buttons.filter(btn => isValidExternalUrl(btn.buttonLink)).map((btn, idx) => (
<p className="ml-8 text-sm text-dark-400">
{getLocalizedText(selectedApp.installationStep.description)}
</p>
{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
key={idx}
href={btn.buttonLink}
target="_blank"
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)}
</a>
@@ -575,15 +673,21 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
{selectedApp?.addSubscriptionStep && (
<div className="space-y-3">
<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="font-medium text-dark-100">{t('subscription.connection.addSubscription')}</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">
2
</span>
<span className="font-medium text-dark-100">
{t('subscription.connection.addSubscription')}
</span>
</div>
<p className="text-dark-400 text-sm ml-8">{getLocalizedText(selectedApp.addSubscriptionStep.description)}</p>
<div className="space-y-2 ml-8">
<p className="ml-8 text-sm text-dark-400">
{getLocalizedText(selectedApp.addSubscriptionStep.description)}
</p>
<div className="ml-8 space-y-2">
{selectedApp.deepLink && (
<button
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 />
{t('subscription.connection.addToApp', { appName: selectedApp.name })}
@@ -593,14 +697,16 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
{!appConfig?.hideLink && (
<button
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
? 'border-success-500 bg-success-500/10 text-success-400'
: 'border-dark-600 text-dark-300 hover:bg-dark-800'
}`}
>
{copied ? <CheckIcon /> : <CopyIcon />}
{copied ? t('subscription.connection.copied') : t('subscription.connection.copyLink')}
{copied
? t('subscription.connection.copied')
: t('subscription.connection.copyLink')}
</button>
)}
</div>
@@ -610,13 +716,19 @@ export default function ConnectionModal({ onClose }: ConnectionModalProps) {
{selectedApp?.connectAndUseStep && (
<div className="space-y-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="font-medium text-dark-100">{t('subscription.connection.connectVpn')}</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">
3
</span>
<span className="font-medium text-dark-100">
{t('subscription.connection.connectVpn')}
</span>
</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>
</Wrapper>
)
);
}

View File

@@ -1,20 +1,20 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useQuery } from '@tanstack/react-query'
import { balanceApi } from '../api/balance'
import { useCurrency } from '../hooks/useCurrency'
import TopUpModal from './TopUpModal'
import type { PaymentMethod } from '../types'
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { balanceApi } from '../api/balance';
import { useCurrency } from '../hooks/useCurrency';
import TopUpModal from './TopUpModal';
import type { PaymentMethod } from '../types';
interface InsufficientBalancePromptProps {
/** Amount missing in kopeks */
missingAmountKopeks: number
missingAmountKopeks: number;
/** Optional custom message */
message?: string
message?: string;
/** Compact mode for inline use */
compact?: boolean
compact?: boolean;
/** Additional className */
className?: string
className?: string;
}
export default function InsufficientBalancePrompt({
@@ -23,40 +23,55 @@ export default function InsufficientBalancePrompt({
compact = false,
className = '',
}: InsufficientBalancePromptProps) {
const { t } = useTranslation()
const { formatAmount, currencySymbol } = useCurrency()
const [showMethodSelect, setShowMethodSelect] = useState(false)
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null)
const { t } = useTranslation();
const { formatAmount, currencySymbol } = useCurrency();
const [showMethodSelect, setShowMethodSelect] = useState(false);
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null);
const { data: paymentMethods } = useQuery({
queryKey: ['payment-methods'],
queryFn: balanceApi.getPaymentMethods,
enabled: showMethodSelect,
})
});
const missingRubles = missingAmountKopeks / 100
const displayAmount = formatAmount(missingRubles)
const missingRubles = missingAmountKopeks / 100;
const displayAmount = formatAmount(missingRubles);
const handleMethodSelect = (method: PaymentMethod) => {
setSelectedMethod(method)
setShowMethodSelect(false)
}
setSelectedMethod(method);
setShowMethodSelect(false);
};
if (compact) {
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">
<svg className="w-4 h-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
className="h-4 w-4 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
<span>
{message || t('balance.insufficientFunds')}: <span className="font-semibold">{displayAmount} {currencySymbol}</span>
{message || t('balance.insufficientFunds')}:{' '}
<span className="font-semibold">
{displayAmount} {currencySymbol}
</span>
</span>
</div>
<button
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')}
</button>
@@ -78,37 +93,54 @@ export default function InsufficientBalancePrompt({
/>
)}
</>
)
);
}
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="w-10 h-10 rounded-xl bg-error-500/20 flex items-center justify-center flex-shrink-0">
<svg className="w-5 h-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" />
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-error-500/20">
<svg
className="h-5 w-5 text-error-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
/>
</svg>
</div>
<div className="flex-1 min-w-0">
<div className="text-error-400 font-medium mb-1">
{t('balance.insufficientFunds')}
</div>
<div className="text-dark-300 text-sm">
{message || t('balance.topUpToComplete')}
</div>
<div className="min-w-0 flex-1">
<div className="mb-1 font-medium text-error-400">{t('balance.insufficientFunds')}</div>
<div className="text-sm text-dark-300">{message || t('balance.topUpToComplete')}</div>
<div className="mt-3 flex items-center gap-3">
<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>
<button
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" />
</svg>
{t('balance.topUpBalance')}
@@ -131,80 +163,115 @@ export default function InsufficientBalancePrompt({
/>
)}
</>
)
);
}
interface PaymentMethodModalProps {
paymentMethods: PaymentMethod[] | undefined
onSelect: (method: PaymentMethod) => void
onClose: () => void
paymentMethods: PaymentMethod[] | undefined;
onSelect: (method: PaymentMethod) => void;
onClose: () => void;
}
function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethodModalProps) {
const { t } = useTranslation()
const { formatAmount, currencySymbol } = useCurrency()
const { t } = useTranslation();
const { formatAmount, currencySymbol } = useCurrency();
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="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 */}
<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>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400" aria-label="Close">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<button
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" />
</svg>
</button>
</div>
{/* 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 ? (
<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>
) : 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')}
</div>
) : (
paymentMethods.map((method) => {
const methodKey = method.id.toLowerCase().replace(/-/g, '_')
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' })
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, {
defaultValue: '',
});
return (
<button
key={method.id}
disabled={!method.is_available}
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
? 'bg-dark-800 hover:bg-dark-700 active:bg-dark-600'
: '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">
<svg className="w-4 h-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" />
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-accent-500/20">
<svg
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>
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-dark-100 text-sm">{translatedName || method.name}</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium text-dark-100">
{translatedName || method.name}
</div>
<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>
<svg className="w-4 h-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
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>
</button>
)
);
})
)}
</div>
</div>
</div>
)
);
}

View File

@@ -1,53 +1,53 @@
import { useTranslation } from 'react-i18next'
import { useState, useRef, useEffect } from 'react'
import { useTranslation } from 'react-i18next';
import { useState, useRef, useEffect } from 'react';
const languages = [
{ code: 'ru', name: 'RU', flag: '🇷🇺', fullName: 'Русский' },
{ code: 'en', name: 'EN', flag: '🇬🇧', fullName: 'English' },
{ code: 'zh', name: 'ZH', flag: '🇨🇳', fullName: '中文' },
{ code: 'fa', name: 'FA', flag: '🇮🇷', fullName: 'فارسی' },
]
];
export default function LanguageSwitcher() {
const { i18n } = useTranslation()
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const { i18n } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
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(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const changeLanguage = (code: string) => {
i18n.changeLanguage(code)
i18n.changeLanguage(code);
// Set document direction for RTL languages
document.documentElement.dir = code === 'fa' ? 'rtl' : 'ltr'
setIsOpen(false)
}
document.documentElement.dir = code === 'fa' ? 'rtl' : 'ltr';
setIsOpen(false);
};
// Set initial direction on mount
useEffect(() => {
document.documentElement.dir = i18n.language === 'fa' ? 'rtl' : 'ltr'
}, [i18n.language])
document.documentElement.dir = i18n.language === 'fa' ? 'rtl' : 'ltr';
}, [i18n.language]);
return (
<div className="relative" ref={dropdownRef}>
<button
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"
>
<span>{currentLang.flag}</span>
<span className="font-medium text-dark-200">{currentLang.name}</span>
<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"
stroke="currentColor"
viewBox="0 0 24 24"
@@ -57,12 +57,12 @@ export default function LanguageSwitcher() {
</button>
{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) => (
<button
key={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
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-300 hover:bg-dark-700/50'
@@ -75,5 +75,5 @@ export default function LanguageSwitcher() {
</div>
)}
</div>
)
);
}

View File

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

View File

@@ -1,97 +1,111 @@
import { useState, useRef, useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { promoApi } from '../api/promo'
import { useState, useRef, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { promoApi } from '../api/promo';
const SparklesIcon = () => (
<svg className="w-4 h-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" />
<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"
/>
</svg>
)
);
const ClockIcon = () => (
<svg className="w-3.5 h-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
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>
)
);
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
let expires: Date
let expires: Date;
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
expires = new Date(expiresAt)
expires = new Date(expiresAt);
} else {
// 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 minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) {
const days = Math.floor(hours / 24)
return `${days}${t('promo.time.days')}`
const days = Math.floor(hours / 24);
return `${days}${t('promo.time.days')}`;
}
if (hours > 0) {
return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}`
}
return `${minutes}${t('promo.time.minutes')}`
return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}`;
}
return `${minutes}${t('promo.time.minutes')}`;
};
export default function PromoDiscountBadge() {
const { t } = useTranslation()
const navigate = useNavigate()
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const { t } = useTranslation();
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { data: activeDiscount } = useQuery({
queryKey: ['active-discount'],
queryFn: promoApi.getActiveDiscount,
staleTime: 30000,
refetchInterval: 60000, // Refresh every minute
})
});
// Close dropdown on click outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Don't render if no active discount
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 = () => {
setIsOpen(!isOpen)
}
setIsOpen(!isOpen);
};
const handleGoToSubscription = () => {
setIsOpen(false)
navigate('/subscription')
}
setIsOpen(false);
navigate('/subscription');
};
return (
<div className="relative" ref={dropdownRef}>
{/* Badge button */}
<button
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')}
>
<span className="font-bold text-success-400 text-sm">
<span className="text-sm font-bold text-success-400">
-{activeDiscount.discount_percent}%
</span>
</button>
@@ -101,21 +115,19 @@ export default function PromoDiscountBadge() {
<>
{/* Mobile backdrop */}
<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)}
/>
<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 */}
<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="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 />
</div>
<div>
<div className="font-semibold text-dark-100">
{t('promo.discountActive')}
</div>
<div className="font-semibold text-dark-100">{t('promo.discountActive')}</div>
<div className="text-2xl font-bold text-success-400">
-{activeDiscount.discount_percent}%
</div>
@@ -124,23 +136,24 @@ export default function PromoDiscountBadge() {
</div>
{/* Content */}
<div className="p-4 space-y-3">
<p className="text-sm text-dark-300">
{t('promo.discountDescription')}
</p>
<div className="space-y-3 p-4">
<p className="text-sm text-dark-300">{t('promo.discountDescription')}</p>
{/* Time remaining */}
{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 />
<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>
)}
{/* CTA Button */}
<button
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')}
</button>
@@ -149,5 +162,5 @@ export default function PromoDiscountBadge() {
</>
)}
</div>
)
);
}

View File

@@ -1,144 +1,165 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { promoApi, PromoOffer } from '../api/promo'
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { promoApi, PromoOffer } from '../api/promo';
// Icons
const GiftIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
const ClockIcon = () => (
<svg className="w-4 h-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" />
<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"
/>
</svg>
)
);
const SparklesIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
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" />
</svg>
)
);
const ServerIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
/>
</svg>
)
);
// Helper functions
const formatTimeLeft = (expiresAt: string): string => {
const now = new Date()
const now = new Date();
// Ensure UTC parsing - if no timezone specified, assume UTC
let expires: Date
let expires: Date;
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
expires = new Date(expiresAt)
expires = new Date(expiresAt);
} else {
// 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 minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) {
const days = Math.floor(hours / 24)
return `${days} дн.`
const days = Math.floor(hours / 24);
return `${days} дн.`;
}
if (hours > 0) {
return `${hours}ч ${minutes}м`
}
return `${minutes}м`
return `${hours}ч ${minutes}м`;
}
return `${minutes}м`;
};
const getOfferIcon = (effectType: string) => {
if (effectType === 'test_access') return <ServerIcon />
return <SparklesIcon />
}
if (effectType === 'test_access') return <ServerIcon />;
return <SparklesIcon />;
};
const getOfferTitle = (offer: PromoOffer): string => {
if (offer.effect_type === 'test_access') {
return 'Тестовый доступ'
return 'Тестовый доступ';
}
if (offer.discount_percent) {
return `Скидка ${offer.discount_percent}%`
}
return 'Специальное предложение'
return `Скидка ${offer.discount_percent}%`;
}
return 'Специальное предложение';
};
const getOfferDescription = (offer: PromoOffer): string => {
if (offer.effect_type === 'test_access') {
const squads = offer.extra_data?.test_squad_uuids?.length || 0
return squads > 0 ? `Доступ к ${squads} серверам` : 'Доступ к дополнительным серверам'
}
return 'Активируйте скидку на покупку подписки'
const squads = offer.extra_data?.test_squad_uuids?.length || 0;
return squads > 0 ? `Доступ к ${squads} серверам` : 'Доступ к дополнительным серверам';
}
return 'Активируйте скидку на покупку подписки';
};
interface PromoOffersSectionProps {
className?: string
className?: string;
}
export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) {
const queryClient = useQueryClient()
const [claimingId, setClaimingId] = useState<number | null>(null)
const [successMessage, setSuccessMessage] = useState<string | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const queryClient = useQueryClient();
const [claimingId, setClaimingId] = useState<number | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Fetch available offers
const { data: offers = [], isLoading: offersLoading } = useQuery({
queryKey: ['promo-offers'],
queryFn: promoApi.getOffers,
staleTime: 30000,
})
});
// Fetch active discount
const { data: activeDiscount } = useQuery({
queryKey: ['active-discount'],
queryFn: promoApi.getActiveDiscount,
staleTime: 30000,
})
});
// Claim offer mutation
const claimMutation = useMutation({
mutationFn: promoApi.claimOffer,
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['promo-offers'] })
queryClient.invalidateQueries({ queryKey: ['active-discount'] })
queryClient.invalidateQueries({ queryKey: ['subscription'] })
setSuccessMessage(result.message)
setClaimingId(null)
setTimeout(() => setSuccessMessage(null), 5000)
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
queryClient.invalidateQueries({ queryKey: ['subscription'] });
setSuccessMessage(result.message);
setClaimingId(null);
setTimeout(() => setSuccessMessage(null), 5000);
},
onError: (error: any) => {
setErrorMessage(error.response?.data?.detail || 'Не удалось активировать предложение')
setClaimingId(null)
setTimeout(() => setErrorMessage(null), 5000)
onError: (error: unknown) => {
const axiosErr = error as { response?: { data?: { detail?: string } } };
setErrorMessage(axiosErr.response?.data?.detail || 'Не удалось активировать предложение');
setClaimingId(null);
setTimeout(() => setErrorMessage(null), 5000);
},
})
});
const handleClaim = (offerId: number) => {
setClaimingId(offerId)
setErrorMessage(null)
setSuccessMessage(null)
claimMutation.mutate(offerId)
}
setClaimingId(offerId);
setErrorMessage(null);
setSuccessMessage(null);
claimMutation.mutate(offerId);
};
// 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
if (!offersLoading && availableOffers.length === 0 && (!activeDiscount || !activeDiscount.is_active)) {
return null
if (
!offersLoading &&
availableOffers.length === 0 &&
(!activeDiscount || !activeDiscount.is_active)
) {
return null;
}
return (
@@ -147,15 +168,15 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
{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="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 />
</div>
<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">
Скидка {activeDiscount.discount_percent}% активна
</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>
</div>
@@ -174,14 +195,14 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
{/* Success/Error Messages */}
{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 />
<span>{successMessage}</span>
</div>
)}
{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}
</div>
)}
@@ -192,26 +213,22 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
{availableOffers.map((offer) => (
<div
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="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)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-dark-100">
{getOfferTitle(offer)}
</h3>
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<h3 className="font-semibold text-dark-100">{getOfferTitle(offer)}</h3>
{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>
)}
</div>
<p className="text-sm text-dark-400 mb-3">
{getOfferDescription(offer)}
</p>
<p className="mb-3 text-sm text-dark-400">{getOfferDescription(offer)}</p>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-1 text-xs text-dark-500">
<ClockIcon />
@@ -220,11 +237,11 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
<button
onClick={() => handleClaim(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 ? (
<>
<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>
</>
) : (
@@ -246,14 +263,14 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
{offersLoading && (
<div className="card">
<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="h-5 w-32 bg-dark-700 rounded animate-pulse" />
<div className="h-4 w-48 bg-dark-700 rounded animate-pulse" />
<div className="h-5 w-32 animate-pulse rounded bg-dark-700" />
<div className="h-4 w-48 animate-pulse rounded bg-dark-700" />
</div>
</div>
</div>
)}
</div>
)
);
}

View File

@@ -1,47 +1,45 @@
import { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
interface TelegramLoginButtonProps {
botUsername: string
botUsername: string;
}
export default function TelegramLoginButton({
botUsername,
}: TelegramLoginButtonProps) {
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
export default function TelegramLoginButton({ botUsername }: TelegramLoginButtonProps) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
// Load widget script
useEffect(() => {
if (!containerRef.current || !botUsername) return
if (!containerRef.current || !botUsername) return;
// Clear previous widget using safe DOM API
while (containerRef.current.firstChild) {
containerRef.current.removeChild(containerRef.current.firstChild)
containerRef.current.removeChild(containerRef.current.firstChild);
}
// 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
const script = document.createElement('script')
script.src = 'https://telegram.org/js/telegram-widget.js?22'
script.setAttribute('data-telegram-login', botUsername)
script.setAttribute('data-size', 'large')
script.setAttribute('data-radius', '8')
script.setAttribute('data-auth-url', redirectUrl)
script.setAttribute('data-request-access', 'write')
script.async = true
const script = document.createElement('script');
script.src = 'https://telegram.org/js/telegram-widget.js?22';
script.setAttribute('data-telegram-login', botUsername);
script.setAttribute('data-size', 'large');
script.setAttribute('data-radius', '8');
script.setAttribute('data-auth-url', redirectUrl);
script.setAttribute('data-request-access', 'write');
script.async = true;
containerRef.current.appendChild(script)
}, [botUsername])
containerRef.current.appendChild(script);
}, [botUsername]);
if (!botUsername || botUsername === 'your_bot') {
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')}
</div>
)
);
}
return (
@@ -51,19 +49,19 @@ export default function TelegramLoginButton({
{/* Fallback link for mobile */}
<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
href={`https://t.me/${botUsername}`}
target="_blank"
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" />
</svg>
@{botUsername}
</a>
</div>
</div>
)
);
}

View File

@@ -1,106 +1,122 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { COLOR_PRESETS, ColorPreset } from '../data/colorPresets'
import { hexToHsl, hslToHex, isValidHex, HSLColor } from '../utils/colorConversion'
import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme'
import { applyThemeColors } from '../hooks/useThemeColors'
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { COLOR_PRESETS, ColorPreset } from '../data/colorPresets';
import { hexToHsl, hslToHex, isValidHex, HSLColor } from '../utils/colorConversion';
import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme';
import { applyThemeColors } from '../hooks/useThemeColors';
interface ThemeBentoPickerProps {
currentColors: ThemeColors
onColorsChange: (colors: ThemeColors) => void
onSave: () => void
isSaving: boolean
currentColors: ThemeColors;
onColorsChange: (colors: ThemeColors) => void;
onSave: () => void;
isSaving: boolean;
}
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" />
</svg>
)
);
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" />
</svg>
)
);
const MoonIcon = () => (
<svg className="w-4 h-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" />
<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"
/>
</svg>
)
);
const SunIcon = () => (
<svg className="w-4 h-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" />
<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"
/>
</svg>
)
);
const StatusIcon = () => (
<svg className="w-4 h-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" />
<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"
/>
</svg>
)
);
const PaletteIcon = () => (
<svg className="w-4 h-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" />
<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"
/>
</svg>
)
);
function PresetCard({
preset,
isSelected,
onClick,
}: {
preset: ColorPreset
isSelected: boolean
onClick: () => void
preset: ColorPreset;
isSelected: boolean;
onClick: () => void;
}) {
const { i18n } = useTranslation()
const isRu = i18n.language === 'ru'
const { i18n } = useTranslation();
const isRu = i18n.language === 'ru';
return (
<button
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
? 'bg-dark-800/90 border-2 border-accent-500 shadow-lg shadow-accent-500/20 scale-[1.02] z-10'
: 'bg-dark-900/60 border border-dark-700/50 hover:bg-dark-800/70 hover:border-dark-600/60 hover:scale-[1.01]'
? 'z-10 scale-[1.02] border-2 border-accent-500 bg-dark-800/90 shadow-lg shadow-accent-500/20'
: 'border border-dark-700/50 bg-dark-900/60 hover:scale-[1.01] hover:border-dark-600/60 hover:bg-dark-800/70'
}`}
>
<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 }}
>
<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 }}
/>
<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 }}
/>
<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 }}
/>
</div>
<div className="flex items-center justify-between gap-2">
<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}
</h4>
</div>
{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 />
</div>
)}
</div>
</button>
)
);
}
function HSLSlider({
@@ -111,18 +127,18 @@ function HSLSlider({
gradient,
suffix = '',
}: {
label: string
value: number
onChange: (value: number) => void
max: number
gradient: string
suffix?: string
label: string;
value: number;
onChange: (value: number) => void;
max: number;
gradient: string;
suffix?: string;
}) {
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<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}
{suffix}
</span>
@@ -133,11 +149,11 @@ function HSLSlider({
max={max}
value={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 }}
/>
</div>
)
);
}
function CompactColorInput({
@@ -145,45 +161,45 @@ function CompactColorInput({
value,
onChange,
}: {
label: string
value: string
onChange: (color: string) => void
label: string;
value: string;
onChange: (color: string) => void;
}) {
const [localValue, setLocalValue] = useState(value)
const [isEditing, setIsEditing] = useState(false)
const [localValue, setLocalValue] = useState(value);
const [isEditing, setIsEditing] = useState(false);
useEffect(() => {
setLocalValue(value)
}, [value])
setLocalValue(value);
}, [value]);
const handleChange = (newValue: string) => {
let formatted = newValue.toUpperCase()
let formatted = newValue.toUpperCase();
if (!formatted.startsWith('#')) {
formatted = '#' + formatted
formatted = '#' + formatted;
}
setLocalValue(formatted)
setLocalValue(formatted);
if (isValidHex(formatted)) {
onChange(formatted)
}
onChange(formatted);
}
};
const handleBlur = () => {
setIsEditing(false)
setIsEditing(false);
if (!isValidHex(localValue)) {
setLocalValue(value)
}
setLocalValue(value);
}
};
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
type="button"
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 }}
/>
<div className="flex-1 min-w-0">
<label className="text-[10px] uppercase tracking-wide text-dark-500 block leading-none mb-0.5">
<div className="min-w-0 flex-1">
<label className="mb-0.5 block text-[10px] uppercase leading-none tracking-wide text-dark-500">
{label}
</label>
{isEditing ? (
@@ -194,21 +210,21 @@ function CompactColorInput({
onBlur={handleBlur}
onKeyDown={(e) => e.key === 'Enter' && handleBlur()}
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}
/>
) : (
<button
type="button"
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()}
</button>
)}
</div>
</div>
)
);
}
function CollapsibleSection({
@@ -219,24 +235,24 @@ function CollapsibleSection({
children,
badge,
}: {
title: string
icon: React.ReactNode
isOpen: boolean
onToggle: () => void
children: React.ReactNode
badge?: string
title: string;
icon: React.ReactNode;
isOpen: boolean;
onToggle: () => void;
children: React.ReactNode;
badge?: string;
}) {
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
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="text-dark-400">{icon}</div>
<span className="text-sm font-medium text-dark-200">{title}</span>
{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}
</span>
)}
@@ -254,11 +270,11 @@ function CollapsibleSection({
}`}
>
<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>
)
);
}
export function ThemeBentoPicker({
@@ -267,67 +283,67 @@ export function ThemeBentoPicker({
onSave,
isSaving,
}: ThemeBentoPickerProps) {
const { t } = useTranslation()
const { t } = useTranslation();
const [hsl, setHsl] = useState<HSLColor>(() => hexToHsl(currentColors.accent))
const [hexInput, setHexInput] = useState(currentColors.accent)
const [hasChanges, setHasChanges] = useState(false)
const [hsl, setHsl] = useState<HSLColor>(() => hexToHsl(currentColors.accent));
const [hexInput, setHexInput] = useState(currentColors.accent);
const [hasChanges, setHasChanges] = useState(false);
const [isPresetsOpen, setIsPresetsOpen] = useState(false)
const [isAccentOpen, setIsAccentOpen] = useState(false)
const [isDarkOpen, setIsDarkOpen] = useState(false)
const [isLightOpen, setIsLightOpen] = useState(false)
const [isStatusOpen, setIsStatusOpen] = useState(false)
const [isPresetsOpen, setIsPresetsOpen] = useState(false);
const [isAccentOpen, setIsAccentOpen] = useState(false);
const [isDarkOpen, setIsDarkOpen] = useState(false);
const [isLightOpen, setIsLightOpen] = useState(false);
const [isStatusOpen, setIsStatusOpen] = useState(false);
const selectedPresetId = useMemo(() => {
const match = COLOR_PRESETS.find(
(p) =>
p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() &&
p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() &&
p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase()
)
return match?.id ?? null
}, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground])
p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase(),
);
return match?.id ?? null;
}, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground]);
useEffect(() => {
setHsl(hexToHsl(currentColors.accent))
setHexInput(currentColors.accent)
}, [currentColors.accent])
setHsl(hexToHsl(currentColors.accent));
setHexInput(currentColors.accent);
}, [currentColors.accent]);
const updateColor = useCallback(
(key: keyof ThemeColors, value: string) => {
const newColors = { ...currentColors, [key]: value }
onColorsChange(newColors)
applyThemeColors(newColors)
setHasChanges(true)
const newColors = { ...currentColors, [key]: value };
onColorsChange(newColors);
applyThemeColors(newColors);
setHasChanges(true);
},
[currentColors, onColorsChange]
)
[currentColors, onColorsChange],
);
const updateAccentFromHsl = useCallback(
(newHsl: HSLColor) => {
setHsl(newHsl)
const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l)
setHexInput(newHex)
updateColor('accent', newHex)
setHsl(newHsl);
const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l);
setHexInput(newHex);
updateColor('accent', newHex);
},
[updateColor]
)
[updateColor],
);
const handleHexInputChange = (value: string) => {
setHexInput(value)
setHexInput(value);
if (isValidHex(value)) {
const newHsl = hexToHsl(value)
setHsl(newHsl)
updateColor('accent', value)
}
const newHsl = hexToHsl(value);
setHsl(newHsl);
updateColor('accent', value);
}
};
const handlePresetSelect = (preset: ColorPreset) => {
onColorsChange(preset.colors)
applyThemeColors(preset.colors)
setHasChanges(true)
}
onColorsChange(preset.colors);
applyThemeColors(preset.colors);
setHasChanges(true);
};
const hueGradient = useMemo(() => {
return `linear-gradient(to right,
@@ -338,23 +354,23 @@ export function ThemeBentoPicker({
hsl(240, ${hsl.s}%, ${hsl.l}%),
hsl(300, ${hsl.s}%, ${hsl.l}%),
hsl(360, ${hsl.s}%, ${hsl.l}%)
)`
}, [hsl.s, hsl.l])
)`;
}, [hsl.s, hsl.l]);
const saturationGradient = useMemo(() => {
return `linear-gradient(to right,
hsl(${hsl.h}, 0%, ${hsl.l}%),
hsl(${hsl.h}, 100%, ${hsl.l}%)
)`
}, [hsl.h, hsl.l])
)`;
}, [hsl.h, hsl.l]);
const lightnessGradient = useMemo(() => {
return `linear-gradient(to right,
hsl(${hsl.h}, ${hsl.s}%, 0%),
hsl(${hsl.h}, ${hsl.s}%, 50%),
hsl(${hsl.h}, ${hsl.s}%, 100%)
)`
}, [hsl.h, hsl.s])
)`;
}, [hsl.h, hsl.s]);
return (
<div className="space-y-5">
@@ -365,9 +381,13 @@ export function ThemeBentoPicker({
isOpen={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) => (
<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
preset={preset}
isSelected={selectedPresetId === preset.id}
@@ -379,7 +399,7 @@ export function ThemeBentoPicker({
</CollapsibleSection>
<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')}
</h3>
@@ -392,13 +412,13 @@ export function ThemeBentoPicker({
>
<div className="space-y-4">
<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={{
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 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()}
</div>
</div>
@@ -431,7 +451,7 @@ export function ThemeBentoPicker({
/>
<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')}
</label>
<input
@@ -440,7 +460,7 @@ export function ThemeBentoPicker({
onChange={(e) => handleHexInputChange(e.target.value)}
placeholder="#3b82f6"
maxLength={7}
className="input w-full text-sm font-mono uppercase"
className="input w-full font-mono text-sm uppercase"
/>
</div>
</div>
@@ -532,8 +552,8 @@ export function ThemeBentoPicker({
</CollapsibleSection>
</div>
<div className="rounded-2xl bg-dark-900/50 border border-dark-700/40 p-4">
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-3">
<div className="rounded-2xl border border-dark-700/40 bg-dark-900/50 p-4">
<h4 className="mb-3 text-xs font-medium uppercase tracking-wide text-dark-400">
{t('theme.preview', 'Preview')}
</h4>
<div className="flex flex-wrap gap-2">
@@ -548,12 +568,12 @@ export function ThemeBentoPicker({
</div>
{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">
{isSaving ? t('common.saving', 'Saving...') : t('common.save', 'Save')}
</button>
</div>
)}
</div>
)
);
}

View File

@@ -1,50 +1,55 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { ticketNotificationsApi } from '../api/ticketNotifications'
import { useAuthStore } from '../store/auth'
import { useToast } from './Toast'
import { useWebSocket, WSMessage } from '../hooks/useWebSocket'
import { useTelegramWebApp } from '../hooks/useTelegramWebApp'
import type { TicketNotification } from '../types'
import { useState, useRef, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ticketNotificationsApi } from '../api/ticketNotifications';
import { useAuthStore } from '../store/auth';
import { useToast } from './Toast';
import { useWebSocket, WSMessage } from '../hooks/useWebSocket';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import type { TicketNotification } from '../types';
const BellIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
/>
</svg>
)
);
const CheckIcon = () => (
<svg className="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" />
</svg>
)
);
interface TicketNotificationBellProps {
isAdmin?: boolean
isAdmin?: boolean;
}
export default function TicketNotificationBell({ isAdmin = false }: TicketNotificationBellProps) {
const { t } = useTranslation()
const navigate = useNavigate()
const queryClient = useQueryClient()
const { isAuthenticated } = useAuthStore()
const { showToast } = useToast()
const [isOpen, setIsOpen] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp()
const { t } = useTranslation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { isAuthenticated } = useAuthStore();
const { showToast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
// Calculate dropdown top position (account for fullscreen safe area + TG buttons)
const dropdownTop = isFullscreen
? 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
const showWSNotificationToast = useCallback((message: WSMessage) => {
const isNewTicket = message.type === 'ticket.new'
const isAdminReply = message.type === 'ticket.admin_reply'
const isUserReply = message.type === 'ticket.user_reply'
const showWSNotificationToast = useCallback(
(message: WSMessage) => {
const isNewTicket = message.type === 'ticket.new';
const isAdminReply = message.type === 'ticket.admin_reply';
const isUserReply = message.type === 'ticket.user_reply';
const icon = isNewTicket ? (
<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>
)
);
const ticketTitle = message.title || ''
const ticketTitle = message.title || '';
let toastTitle: string
let toastMessage: string
let toastTitle: string;
let toastMessage: string;
if (isNewTicket) {
toastTitle = t('notifications.newTicketTitle', 'New Ticket')
toastMessage = message.message || t('notifications.newTicket', 'New ticket: {{title}}', { title: ticketTitle })
toastTitle = t('notifications.newTicketTitle', 'New Ticket');
toastMessage =
message.message ||
t('notifications.newTicket', 'New ticket: {{title}}', { title: ticketTitle });
} else if (isUserReply) {
toastTitle = t('notifications.newUserReplyTitle', 'User Reply')
toastMessage = message.message || t('notifications.newUserReply', 'User replied in ticket: {{title}}', { title: ticketTitle })
toastTitle = t('notifications.newUserReplyTitle', 'User Reply');
toastMessage =
message.message ||
t('notifications.newUserReply', 'User replied in ticket: {{title}}', {
title: ticketTitle,
});
} else {
toastTitle = t('notifications.newReplyTitle', 'New Reply')
toastMessage = message.message || t('notifications.newReply', 'New reply in ticket: {{title}}', { title: ticketTitle })
toastTitle = t('notifications.newReplyTitle', 'New Reply');
toastMessage =
message.message ||
t('notifications.newReply', 'New reply in ticket: {{title}}', { title: ticketTitle });
}
showToast({
@@ -76,134 +89,164 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
message: toastMessage,
icon,
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,
})
}, [showToast, navigate, isAdmin, t])
});
},
[showToast, navigate, isAdmin, t],
);
// Handle WebSocket message
const handleWSMessage = useCallback((message: WSMessage) => {
const handleWSMessage = useCallback(
(message: WSMessage) => {
// Check if this notification is relevant for this user type
const isAdminNotification = message.type === 'ticket.new' || message.type === 'ticket.user_reply'
const isUserNotification = message.type === 'ticket.admin_reply'
const isAdminNotification =
message.type === 'ticket.new' || message.type === 'ticket.user_reply';
const isUserNotification = message.type === 'ticket.admin_reply';
if ((isAdmin && isAdminNotification) || (!isAdmin && isUserNotification)) {
// Show toast
showWSNotificationToast(message)
showWSNotificationToast(message);
// Invalidate queries to refresh count and list
queryClient.invalidateQueries({
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count']
})
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
});
queryClient.invalidateQueries({
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications']
})
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
});
}
}, [isAdmin, showWSNotificationToast, queryClient])
},
[isAdmin, showWSNotificationToast, queryClient],
);
// WebSocket connection
useWebSocket({
onMessage: handleWSMessage,
})
});
// Fetch unread count (with slower polling as fallback when WS disconnects)
const { data: unreadData } = useQuery({
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
queryFn: isAdmin ? ticketNotificationsApi.getAdminUnreadCount : ticketNotificationsApi.getUnreadCount,
queryFn: isAdmin
? ticketNotificationsApi.getAdminUnreadCount
: ticketNotificationsApi.getUnreadCount,
enabled: isAuthenticated,
refetchInterval: 60000, // Poll every 60 seconds as fallback
staleTime: 30000,
})
});
// Fetch notifications when dropdown is open
const { data: notificationsData, isLoading } = useQuery({
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
queryFn: () => isAdmin
queryFn: () =>
isAdmin
? ticketNotificationsApi.getAdminNotifications(false, 10)
: ticketNotificationsApi.getNotifications(false, 10),
enabled: isAuthenticated && isOpen,
staleTime: 5000,
})
});
// Mark all as read mutation
const markAllReadMutation = useMutation({
mutationFn: isAdmin ? ticketNotificationsApi.markAllAdminAsRead : ticketNotificationsApi.markAllAsRead,
mutationFn: isAdmin
? ticketNotificationsApi.markAllAdminAsRead
: ticketNotificationsApi.markAllAsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] })
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] })
queryClient.invalidateQueries({
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
});
queryClient.invalidateQueries({
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
});
},
})
});
// Mark single as read mutation
const markReadMutation = useMutation({
mutationFn: isAdmin ? ticketNotificationsApi.markAdminAsRead : ticketNotificationsApi.markAsRead,
mutationFn: isAdmin
? ticketNotificationsApi.markAdminAsRead
: ticketNotificationsApi.markAsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] })
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] })
queryClient.invalidateQueries({
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
});
queryClient.invalidateQueries({
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
});
},
})
});
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleNotificationClick = (notification: TicketNotification) => {
if (!notification.is_read) {
markReadMutation.mutate(notification.id)
}
setIsOpen(false)
navigate(isAdmin ? `/admin/tickets?ticket=${notification.ticket_id}` : `/support?ticket=${notification.ticket_id}`)
markReadMutation.mutate(notification.id);
}
setIsOpen(false);
navigate(
isAdmin
? `/admin/tickets?ticket=${notification.ticket_id}`
: `/support?ticket=${notification.ticket_id}`,
);
};
const formatTime = (dateStr: string) => {
const date = new Date(dateStr)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return t('notifications.justNow', 'Just now')
if (diffMins < 60) return t('notifications.minutesAgo', '{{count}} min ago', { count: diffMins })
if (diffHours < 24) return t('notifications.hoursAgo', '{{count}} h ago', { count: diffHours })
return t('notifications.daysAgo', '{{count}} d ago', { count: diffDays })
}
if (diffMins < 1) return t('notifications.justNow', 'Just now');
if (diffMins < 60)
return t('notifications.minutesAgo', '{{count}} min ago', { count: diffMins });
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) => {
switch (type) {
case 'new_ticket':
return <span className="text-lg">🎫</span>
return <span className="text-lg">🎫</span>;
case 'admin_reply':
return <span className="text-lg">💬</span>
return <span className="text-lg">💬</span>;
case 'user_reply':
return <span className="text-lg">📨</span>
return <span className="text-lg">📨</span>;
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 (
<div className="relative" ref={dropdownRef}>
{/* Bell button */}
<button
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')}
>
<BellIcon />
{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}
</span>
)}
@@ -212,13 +255,13 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
{/* Dropdown */}
{isOpen && (
<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' : ''
}`}
style={isFullscreen ? { top: `${dropdownTop}px` } : undefined}
>
{/* 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">
{t('notifications.ticketNotifications', 'Ticket Notifications')}
</h3>
@@ -226,7 +269,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
<button
onClick={() => markAllReadMutation.mutate()}
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 />
{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">
{isLoading ? (
<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>
) : notificationsData?.items && notificationsData.items.length > 0 ? (
notificationsData.items.map((notification: TicketNotification) => (
<button
key={notification.id}
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' : ''
}`}
>
<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)}
</div>
<div className="flex-1 min-w-0">
<p className={`text-sm leading-relaxed ${!notification.is_read ? 'text-dark-100 font-medium' : 'text-dark-300'}`}>
<div className="min-w-0 flex-1">
<p
className={`text-sm leading-relaxed ${!notification.is_read ? 'font-medium text-dark-100' : 'text-dark-300'}`}
>
{notification.message}
</p>
<p className="text-xs text-dark-500 mt-1">
<p className="mt-1 text-xs text-dark-500">
{formatTime(notification.created_at)}
</p>
</div>
{!notification.is_read && (
<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>
@@ -271,23 +316,25 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
))
) : (
<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 />
</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>
{/* Footer */}
{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
onClick={() => {
setIsOpen(false)
navigate(isAdmin ? '/admin/tickets' : '/support')
setIsOpen(false);
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')}
</button>
@@ -296,5 +343,5 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
</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 {
type?: 'success' | 'error' | 'info' | 'warning'
message: string
title?: string
icon?: ReactNode
duration?: number
onClick?: () => void
type?: 'success' | 'error' | 'info' | 'warning';
message: string;
title?: string;
icon?: ReactNode;
duration?: number;
onClick?: () => void;
}
interface Toast extends ToastOptions {
id: number
id: number;
}
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() {
const context = useContext(ToastContext)
const context = useContext(ToastContext);
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 }) {
const [toasts, setToasts] = useState<Toast[]>([])
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
const [toasts, setToasts] = useState<Toast[]>([]);
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const showToast = useCallback((options: ToastOptions) => {
const id = Date.now() + Math.random() // Avoid ID collision
const toast: Toast = { id, duration: 5000, type: 'info', ...options }
const id = Date.now() + Math.random(); // Avoid ID collision
const toast: Toast = { id, duration: 5000, type: 'info', ...options };
setToasts(prev => [...prev, toast])
setToasts((prev) => [...prev, toast]);
const timer = setTimeout(() => {
setToasts(prev => prev.filter(t => t.id !== id))
timersRef.current.delete(id)
}, toast.duration)
setToasts((prev) => prev.filter((t) => t.id !== id));
timersRef.current.delete(id);
}, toast.duration);
timersRef.current.set(id, timer)
}, [])
timersRef.current.set(id, timer);
}, []);
const removeToast = useCallback((id: number) => {
// Clear timer when manually removing
const timer = timersRef.current.get(id)
const timer = timersRef.current.get(id);
if (timer) {
clearTimeout(timer)
timersRef.current.delete(id)
clearTimeout(timer);
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
useEffect(() => {
const timers = timersRef.current
const timers = timersRef.current;
return () => {
timers.forEach(timer => clearTimeout(timer))
timers.clear()
}
}, [])
timers.forEach((timer) => clearTimeout(timer));
timers.clear();
};
}, []);
return (
<ToastContext.Provider value={{ showToast }}>
{children}
{/* 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) => (
<ToastItem
key={toast.id}
toast={toast}
onClose={() => removeToast(toast.id)}
/>
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
))}
</div>
</ToastContext.Provider>
)
);
}
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
const handleClick = () => {
if (toast.onClick) {
toast.onClick()
onClose()
}
toast.onClick();
onClose();
}
};
const typeStyles = {
success: {
@@ -115,81 +120,105 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
icon: 'text-accent-400',
iconBg: 'bg-accent-500/20',
},
}
};
const style = typeStyles[toast.type || 'info']
const style = typeStyles[toast.type || 'info'];
const defaultIcons = {
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" />
</svg>
),
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" />
</svg>
),
warning: (
<svg className="w-5 h-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
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
),
info: (
<svg className="w-5 h-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
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
}
};
return (
<div
className={`
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
`}
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`}
onClick={handleClick}
>
{/* 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="flex gap-3">
{/* 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']}
</div>
{/* Content */}
<div className="flex-1 min-w-0 pt-0.5">
<div className="min-w-0 flex-1 pt-0.5">
{toast.title && (
<p className="text-sm font-semibold text-dark-100 mb-0.5">
{toast.title}
</p>
<p className="mb-0.5 text-sm font-semibold text-dark-100">{toast.title}</p>
)}
<p className="text-sm text-dark-300 leading-relaxed">
{toast.message}
</p>
<p className="text-sm leading-relaxed text-dark-300">{toast.message}</p>
</div>
{/* Close button */}
<button
onClick={(e) => {
e.stopPropagation()
onClose()
e.stopPropagation();
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" />
</svg>
</button>
@@ -206,5 +235,5 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
</div>
</div>
</div>
)
);
}

View File

@@ -1,285 +1,345 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import { useMutation } from '@tanstack/react-query'
import { balanceApi } from '../api/balance'
import { useCurrency } from '../hooks/useCurrency'
import { useTelegramWebApp } from '../hooks/useTelegramWebApp'
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'
import type { PaymentMethod } from '../types'
import BentoCard from './ui/BentoCard'
import { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useMutation } from '@tanstack/react-query';
import { balanceApi } from '../api/balance';
import { useCurrency } from '../hooks/useCurrency';
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
import type { PaymentMethod } from '../types';
import BentoCard from './ui/BentoCard';
// Icons
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" />
</svg>
)
);
const WalletIcon = () => (
<svg className="w-6 h-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" />
<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"
/>
</svg>
)
);
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" />
</svg>
)
);
const CardIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
const CryptoIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
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" />
</svg>
)
);
const ExternalLinkIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
const CopyIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
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" />
</svg>
)
);
interface TopUpModalProps {
method: PaymentMethod
onClose: () => void
initialAmountRubles?: number
method: PaymentMethod;
onClose: () => void;
initialAmountRubles?: number;
}
function useIsMobile() {
const [isMobile, setIsMobile] = useState(() => {
if (typeof window === 'undefined') return false
return window.innerWidth < 640
})
if (typeof window === 'undefined') return false;
return window.innerWidth < 640;
});
useEffect(() => {
const check = () => setIsMobile(window.innerWidth < 640)
window.addEventListener('resize', check)
return () => window.removeEventListener('resize', check)
}, [])
return isMobile
const check = () => setIsMobile(window.innerWidth < 640);
window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check);
}, []);
return isMobile;
}
// Get method icon based on method type
const getMethodIcon = (methodId: string) => {
const id = methodId.toLowerCase()
if (id.includes('stars')) return <StarIcon />
if (id.includes('crypto') || id.includes('ton') || id.includes('usdt')) return <CryptoIcon />
return <CardIcon />
}
const id = methodId.toLowerCase();
if (id.includes('stars')) return <StarIcon />;
if (id.includes('crypto') || id.includes('ton') || id.includes('usdt')) return <CryptoIcon />;
return <CardIcon />;
};
export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) {
const { t } = useTranslation()
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency()
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp()
const inputRef = useRef<HTMLInputElement>(null)
const isMobileScreen = useIsMobile()
const { t } = useTranslation();
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } =
useCurrency();
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp();
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 => {
if (!initialAmountRubles || initialAmountRubles <= 0) return ''
const converted = convertAmount(initialAmountRubles)
return (targetCurrency === 'IRR' || targetCurrency === 'RUB')
if (!initialAmountRubles || initialAmountRubles <= 0) return '';
const converted = convertAmount(initialAmountRubles);
return targetCurrency === 'IRR' || targetCurrency === 'RUB'
? Math.ceil(converted).toString()
: converted.toFixed(2)
}
: converted.toFixed(2);
};
const [amount, setAmount] = useState(getInitialAmount)
const [error, setError] = useState<string | null>(null)
const [amount, setAmount] = useState(getInitialAmount);
const [error, setError] = useState<string | null>(null);
const [selectedOption, setSelectedOption] = useState<string | null>(
method.options && method.options.length > 0 ? method.options[0].id : null
)
const [paymentUrl, setPaymentUrl] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [isInputFocused, setIsInputFocused] = useState(false)
method.options && method.options.length > 0 ? method.options[0].id : null,
);
const [paymentUrl, setPaymentUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [isInputFocused, setIsInputFocused] = useState(false);
const handleClose = useCallback(() => {
onClose()
}, [onClose])
onClose();
}, [onClose]);
// Keyboard: Escape to close (PC)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault()
handleClose()
e.preventDefault();
handleClose();
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [handleClose])
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [handleClose]);
// Telegram back button (Android)
useEffect(() => {
if (!webApp?.BackButton) return
webApp.BackButton.show()
webApp.BackButton.onClick(handleClose)
if (!webApp?.BackButton) return;
webApp.BackButton.show();
webApp.BackButton.onClick(handleClose);
return () => {
webApp.BackButton.offClick(handleClose)
webApp.BackButton.hide()
}
}, [webApp, handleClose])
webApp.BackButton.offClick(handleClose);
webApp.BackButton.hide();
};
}, [webApp, handleClose]);
// Scroll lock
useEffect(() => {
const scrollY = window.scrollY
const scrollY = window.scrollY;
const preventScroll = (e: TouchEvent) => {
const target = e.target as HTMLElement
if (target.closest('[data-modal-content]')) return
e.preventDefault()
}
const target = e.target as HTMLElement;
if (target.closest('[data-modal-content]')) return;
e.preventDefault();
};
const preventWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement
if (target.closest('[data-modal-content]')) return
e.preventDefault()
}
document.addEventListener('touchmove', preventScroll, { passive: false })
document.addEventListener('wheel', preventWheel, { passive: false })
document.body.style.overflow = 'hidden'
const target = e.target as HTMLElement;
if (target.closest('[data-modal-content]')) return;
e.preventDefault();
};
document.addEventListener('touchmove', preventScroll, { passive: false });
document.addEventListener('wheel', preventWheel, { passive: false });
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('touchmove', preventScroll)
document.removeEventListener('wheel', preventWheel)
document.body.style.overflow = ''
window.scrollTo(0, scrollY)
}
}, [])
document.removeEventListener('touchmove', preventScroll);
document.removeEventListener('wheel', preventWheel);
document.body.style.overflow = '';
window.scrollTo(0, scrollY);
};
}, []);
const hasOptions = method.options && method.options.length > 0
const minRubles = method.min_amount_kopeks / 100
const maxRubles = method.max_amount_kopeks / 100
const methodKey = method.id.toLowerCase().replace(/-/g, '_')
const isStarsMethod = methodKey.includes('stars')
const methodName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name
const hasOptions = method.options && method.options.length > 0;
const minRubles = method.min_amount_kopeks / 100;
const maxRubles = method.max_amount_kopeks / 100;
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
const isStarsMethod = methodKey.includes('stars');
const methodName =
t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name;
const starsPaymentMutation = useMutation({
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
onSuccess: (data) => {
const webApp = window.Telegram?.WebApp
if (!data.invoice_url) { setError('Сервер не вернул ссылку на оплату'); return }
if (!webApp?.openInvoice) { setError('Оплата Stars доступна только в Telegram Mini App'); return }
const webApp = window.Telegram?.WebApp;
if (!data.invoice_url) {
setError('Сервер не вернул ссылку на оплату');
return;
}
if (!webApp?.openInvoice) {
setError('Оплата Stars доступна только в Telegram Mini App');
return;
}
try {
webApp.openInvoice(data.invoice_url, (status) => {
if (status === 'paid') { setError(null); onClose() }
else if (status === 'failed') { setError(t('wheel.starsPaymentFailed')) }
})
} catch (e) { setError('Ошибка: ' + String(e)) }
if (status === 'paid') {
setError(null);
onClose();
} else if (status === 'failed') {
setError(t('wheel.starsPaymentFailed'));
}
});
} catch (e) {
setError('Ошибка: ' + String(e));
}
},
onError: (err: unknown) => {
const axiosError = err as { response?: { data?: { detail?: string }, status?: number } }
setError(`Ошибка: ${axiosError?.response?.data?.detail || 'Не удалось создать счёт'}`)
const axiosError = err as { response?: { data?: { detail?: string }; status?: number } };
setError(`Ошибка: ${axiosError?.response?.data?.detail || 'Не удалось создать счёт'}`);
},
})
});
const topUpMutation = useMutation<{
payment_id: string; payment_url?: string; 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),
const topUpMutation = useMutation<
{
payment_id: string;
payment_url?: string;
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) => {
const redirectUrl = data.payment_url || (data as any).invoice_url
const redirectUrl = data.payment_url || data.invoice_url;
if (redirectUrl) {
// Always show the payment link for user to click manually
// This ensures it works on all platforms including iOS Safari
setPaymentUrl(redirectUrl)
setPaymentUrl(redirectUrl);
}
},
onError: (err: unknown) => {
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || ''
setError(detail.includes('not yet implemented') ? t('balance.useBot') : (detail || t('common.error')))
const detail =
(err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '';
setError(
detail.includes('not yet implemented') ? t('balance.useBot') : detail || t('common.error'),
);
},
})
});
const handleSubmit = () => {
setError(null)
setPaymentUrl(null)
inputRef.current?.blur()
setError(null);
setPaymentUrl(null);
inputRef.current?.blur();
if (!checkRateLimit(RATE_LIMIT_KEYS.PAYMENT, 3, 30000)) {
setError('Подождите ' + getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) + ' сек.')
return
setError('Подождите ' + getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) + ' сек.');
return;
}
if (hasOptions && !selectedOption) { setError('Выберите способ'); return }
const amountCurrency = parseFloat(amount)
if (isNaN(amountCurrency) || amountCurrency <= 0) { setError('Введите сумму'); return }
const amountRubles = convertToRub(amountCurrency)
if (hasOptions && !selectedOption) {
setError('Выберите способ');
return;
}
const amountCurrency = parseFloat(amount);
if (isNaN(amountCurrency) || amountCurrency <= 0) {
setError('Введите сумму');
return;
}
const amountRubles = convertToRub(amountCurrency);
if (amountRubles < minRubles || amountRubles > maxRubles) {
setError(`Сумма: ${minRubles} ${maxRubles}`); return
setError(`Сумма: ${minRubles} ${maxRubles}`);
return;
}
const amountKopeks = Math.round(amountRubles * 100)
if (isStarsMethod) { starsPaymentMutation.mutate(amountKopeks) }
else { topUpMutation.mutate(amountKopeks) }
const amountKopeks = Math.round(amountRubles * 100);
if (isStarsMethod) {
starsPaymentMutation.mutate(amountKopeks);
} else {
topUpMutation.mutate(amountKopeks);
}
};
const quickAmounts = [100, 300, 500, 1000].filter((a) => a >= minRubles && a <= maxRubles)
const currencyDecimals = (targetCurrency === 'IRR' || targetCurrency === 'RUB') ? 0 : 2
const getQuickValue = (rub: number) => (targetCurrency === 'IRR')
const quickAmounts = [100, 300, 500, 1000].filter((a) => a >= minRubles && a <= maxRubles);
const currencyDecimals = targetCurrency === 'IRR' || targetCurrency === 'RUB' ? 0 : 2;
const getQuickValue = (rub: number) =>
targetCurrency === 'IRR'
? Math.round(convertAmount(rub)).toString()
: convertAmount(rub).toFixed(currencyDecimals)
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending
: convertAmount(rub).toFixed(currencyDecimals);
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending;
const handleCopyUrl = async () => {
if (!paymentUrl) return
if (!paymentUrl) return;
try {
await navigator.clipboard.writeText(paymentUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
await navigator.clipboard.writeText(paymentUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (e) {
console.warn('Failed to copy:', e)
console.warn('Failed to copy:', e);
}
}
};
// Auto-focus input - works on mobile in Telegram WebApp
useEffect(() => {
const timer = setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus()
inputRef.current.focus();
if (isMobileScreen) {
inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}, 100)
return () => clearTimeout(timer)
}, [])
}, 100);
return () => clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Content JSX - shared between mobile and desktop
const contentJSX = (
<div className="space-y-5">
{/* Header icon and method */}
<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
? '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'
}`}>
<div className="w-7 h-7 flex items-center justify-center">
{getMethodIcon(method.id)}
</div>
}`}
>
<div className="flex h-7 w-7 items-center justify-center">{getMethodIcon(method.id)}</div>
</div>
<div className="flex-1">
<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}
type="button"
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
? '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}
{selectedOption === opt.id && (
<span className="absolute top-1.5 right-1.5">
<span className="w-2 h-2 rounded-full bg-accent-500 block" />
<span className="absolute right-1.5 top-1.5">
<span className="block h-2 w-2 rounded-full bg-accent-500" />
</span>
)}
</button>
@@ -321,11 +381,13 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
<div className="space-y-2">
<label className="text-sm font-medium text-dark-400">{t('balance.enterAmount')}</label>
<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
? 'ring-2 ring-accent-500/50 bg-dark-800'
: 'bg-dark-800/70 border border-dark-700/50'
}`}>
? 'bg-dark-800 ring-2 ring-accent-500/50'
: 'border border-dark-700/50 bg-dark-800/70'
}`}
>
<input
ref={inputRef}
type="number"
@@ -335,9 +397,14 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
onChange={(e) => setAmount(e.target.value)}
onFocus={() => setIsInputFocused(true)}
onBlur={() => setIsInputFocused(false)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSubmit() } }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSubmit();
}
}}
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"
autoFocus
/>
@@ -349,16 +416,16 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
type="button"
onClick={handleSubmit}
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
? 'bg-dark-700 text-dark-500 cursor-not-allowed'
? 'cursor-not-allowed bg-dark-700 text-dark-500'
: 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-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 ? (
<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 />
@@ -373,39 +440,54 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
{quickAmounts.length > 0 && (
<div className="grid grid-cols-4 gap-2">
{quickAmounts.map((a) => {
const val = getQuickValue(a)
const isSelected = amount === val
const val = getQuickValue(a);
const isSelected = amount === val;
return (
<BentoCard
key={a}
as="button"
type="button"
onClick={() => { setAmount(val); inputRef.current?.blur() }}
onClick={() => {
setAmount(val);
inputRef.current?.blur();
}}
hover
glow={isSelected}
className={`flex flex-col items-center justify-center py-3 px-2 ${
isSelected
? 'border-accent-500/50 bg-accent-500/10'
: ''
className={`flex flex-col items-center justify-center px-2 py-3 ${
isSelected ? '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)}
</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}
</span>
</BentoCard>
)
);
})}
</div>
)}
{/* Error message */}
{error && (
<div className="flex items-center gap-2 p-3 rounded-xl bg-error-500/10 border border-error-500/20">
<svg className="w-5 h-5 text-error-400 shrink-0" 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" />
<div className="flex items-center gap-2 rounded-xl border border-error-500/20 bg-error-500/10 p-3">
<svg
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>
<span className="text-sm text-error-400">{error}</span>
</div>
@@ -413,14 +495,19 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
{/* Payment link display - shown when URL is received */}
{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">
<CheckIcon />
<span className="font-semibold">{t('balance.paymentReady', 'Ссылка на оплату готова')}</span>
<span className="font-semibold">
{t('balance.paymentReady', 'Ссылка на оплату готова')}
</span>
</div>
<p className="text-sm text-dark-400">
{t('balance.clickToOpenPayment', 'Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке')}
{t(
'balance.clickToOpenPayment',
'Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке',
)}
</p>
{/* 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}
target="_blank"
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 />
<span>{t('balance.openPaymentPage', 'Открыть страницу оплаты')}</span>
@@ -436,13 +523,13 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
{/* Copy and link display */}
<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">
<p className="text-xs text-dark-500 truncate">{paymentUrl}</p>
<div className="min-w-0 flex-1 rounded-lg border border-dark-700/50 bg-dark-800/70 px-3 py-2">
<p className="truncate text-xs text-dark-500">{paymentUrl}</p>
</div>
<button
type="button"
onClick={handleCopyUrl}
className={`shrink-0 p-2.5 rounded-lg transition-colors ${
className={`shrink-0 rounded-lg p-2.5 transition-colors ${
copied
? 'bg-success-500/20 text-success-400'
: '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>
)
);
// Render modal based on screen size - NO nested components!
const modalContent = isMobileScreen ? (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-[9998] bg-black/70"
onClick={handleClose}
/>
<div className="fixed inset-0 z-[9998] bg-black/70" onClick={handleClose} />
{/* Bottom sheet */}
<div
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"
style={{ paddingBottom: safeBottom ? `${safeBottom + 20}px` : 'max(20px, env(safe-area-inset-bottom))' }}
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))',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Handle bar */}
<div className="flex justify-center pt-3 pb-1">
<div className="w-10 h-1 rounded-full bg-dark-600" />
<div className="flex justify-center pb-1 pt-3">
<div className="h-1 w-10 rounded-full bg-dark-600" />
</div>
{/* Header */}
<div className="flex items-center justify-between px-5 py-2">
<div className="flex items-center gap-2">
<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>
<button
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 />
</button>
</div>
{/* 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 */}
<div className="px-5 py-5 overflow-y-auto">
{contentJSX}
</div>
<div className="overflow-y-auto px-5 py-5">{contentJSX}</div>
</div>
</>
) : (
@@ -507,35 +593,33 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
>
<div
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()}
>
{/* 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="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 />
</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>
<button
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 />
</button>
</div>
{/* Content */}
<div className="p-6">
{contentJSX}
<div className="p-6">{contentJSX}</div>
</div>
</div>
</div>
)
);
if (typeof document !== 'undefined') {
return createPortal(modalContent, document.body)
return createPortal(modalContent, document.body);
}
return modalContent
return modalContent;
}

View File

@@ -1,80 +1,80 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { brandingApi } from '../../api/branding'
import { CheckIcon, CloseIcon } from './icons'
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { brandingApi } from '../../api/branding';
import { CheckIcon, CloseIcon } from './icons';
export function AnalyticsTab() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { t } = useTranslation();
const queryClient = useQueryClient();
// Editing states
const [editingYandex, setEditingYandex] = useState(false)
const [editingGoogleId, setEditingGoogleId] = useState(false)
const [editingGoogleLabel, setEditingGoogleLabel] = useState(false)
const [yandexValue, setYandexValue] = useState('')
const [googleIdValue, setGoogleIdValue] = useState('')
const [googleLabelValue, setGoogleLabelValue] = useState('')
const [error, setError] = useState<string | null>(null)
const [editingYandex, setEditingYandex] = useState(false);
const [editingGoogleId, setEditingGoogleId] = useState(false);
const [editingGoogleLabel, setEditingGoogleLabel] = useState(false);
const [yandexValue, setYandexValue] = useState('');
const [googleIdValue, setGoogleIdValue] = useState('');
const [googleLabelValue, setGoogleLabelValue] = useState('');
const [error, setError] = useState<string | null>(null);
// Query
const { data: analytics } = useQuery({
queryKey: ['analytics-counters'],
queryFn: brandingApi.getAnalyticsCounters,
})
});
// Mutation
const updateMutation = useMutation({
mutationFn: brandingApi.updateAnalyticsCounters,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['analytics-counters'] })
setError(null)
queryClient.invalidateQueries({ queryKey: ['analytics-counters'] });
setError(null);
},
onError: (err: unknown) => {
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
setError(detail || t('common.error'))
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
setError(detail || t('common.error'));
},
})
});
const handleSaveYandex = () => {
updateMutation.mutate(
{ yandex_metrika_id: yandexValue.trim() },
{ onSuccess: () => setEditingYandex(false) },
)
}
);
};
const handleSaveGoogleId = () => {
updateMutation.mutate(
{ google_ads_id: googleIdValue.trim() },
{ onSuccess: () => setEditingGoogleId(false) },
)
}
);
};
const handleSaveGoogleLabel = () => {
updateMutation.mutate(
{ google_ads_label: googleLabelValue.trim() },
{ onSuccess: () => setEditingGoogleLabel(false) },
)
}
);
};
const yandexActive = Boolean(analytics?.yandex_metrika_id)
const googleActive = Boolean(analytics?.google_ads_id)
const yandexActive = Boolean(analytics?.yandex_metrika_id);
const googleActive = Boolean(analytics?.google_ads_id);
return (
<div className="space-y-6">
{/* Error message */}
{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}
</div>
)}
{/* Yandex Metrika */}
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
<div className="flex items-center justify-between mb-1">
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<div className="mb-1 flex items-center justify-between">
<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">
<svg className="w-5 h-5 text-yellow-400" viewBox="0 0 24 24" fill="currentColor">
<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="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" />
</svg>
</div>
@@ -82,16 +82,18 @@ export function AnalyticsTab() {
{t('admin.settings.yandexMetrika')}
</h3>
</div>
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
yandexActive
? 'bg-success-500/15 text-success-400'
: '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={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
yandexActive ? 'bg-success-500/15 text-success-400' : 'bg-dark-700/50 text-dark-500'
}`}
>
<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')}
</span>
</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')}
</p>
@@ -106,73 +108,84 @@ export function AnalyticsTab() {
value={yandexValue}
onChange={(e) => setYandexValue(e.target.value.replace(/\D/g, ''))}
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
/>
<button
onClick={handleSaveYandex}
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 />
</button>
<button
onClick={() => { setEditingYandex(false); setError(null) }}
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
onClick={() => {
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 />
</button>
</div>
) : (
<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')}
</span>
<button
onClick={() => {
setYandexValue(analytics?.yandex_metrika_id || '')
setEditingYandex(true)
setError(null)
setYandexValue(analytics?.yandex_metrika_id || '');
setEditingYandex(true);
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}>
<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
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</button>
</div>
)}
<p className="text-xs text-dark-500">
{t('admin.settings.yandexIdHint')}
</p>
<p className="text-xs text-dark-500">{t('admin.settings.yandexIdHint')}</p>
</div>
</div>
{/* Google Ads */}
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
<div className="flex items-center justify-between mb-1">
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<div className="mb-1 flex items-center justify-between">
<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">
<svg className="w-5 h-5 text-blue-400" viewBox="0 0 24 24" fill="currentColor">
<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="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" />
</svg>
</div>
<h3 className="text-lg font-semibold text-dark-100">
{t('admin.settings.googleAds')}
</h3>
<h3 className="text-lg font-semibold text-dark-100">{t('admin.settings.googleAds')}</h3>
</div>
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
googleActive
? 'bg-success-500/15 text-success-400'
: '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={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
googleActive ? 'bg-success-500/15 text-success-400' : 'bg-dark-700/50 text-dark-500'
}`}
>
<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')}
</span>
</div>
<p className="text-sm text-dark-400 mb-5 ml-[52px]">
{t('admin.settings.googleAdsDesc')}
</p>
<p className="mb-5 ml-[52px] text-sm text-dark-400">{t('admin.settings.googleAdsDesc')}</p>
<div className="space-y-5">
{/* Conversion ID */}
@@ -187,45 +200,58 @@ export function AnalyticsTab() {
value={googleIdValue}
onChange={(e) => setGoogleIdValue(e.target.value)}
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
/>
<button
onClick={handleSaveGoogleId}
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 />
</button>
<button
onClick={() => { setEditingGoogleId(false); setError(null) }}
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
onClick={() => {
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 />
</button>
</div>
) : (
<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')}
</span>
<button
onClick={() => {
setGoogleIdValue(analytics?.google_ads_id || '')
setEditingGoogleId(true)
setError(null)
setGoogleIdValue(analytics?.google_ads_id || '');
setEditingGoogleId(true);
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}>
<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
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</button>
</div>
)}
<p className="text-xs text-dark-500">
{t('admin.settings.googleIdHint')}
</p>
<p className="text-xs text-dark-500">{t('admin.settings.googleIdHint')}</p>
</div>
{/* Conversion Label */}
@@ -240,55 +266,66 @@ export function AnalyticsTab() {
value={googleLabelValue}
onChange={(e) => setGoogleLabelValue(e.target.value)}
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
/>
<button
onClick={handleSaveGoogleLabel}
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 />
</button>
<button
onClick={() => { setEditingGoogleLabel(false); setError(null) }}
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
onClick={() => {
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 />
</button>
</div>
) : (
<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')}
</span>
<button
onClick={() => {
setGoogleLabelValue(analytics?.google_ads_label || '')
setEditingGoogleLabel(true)
setError(null)
setGoogleLabelValue(analytics?.google_ads_label || '');
setEditingGoogleLabel(true);
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}>
<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
className="h-4 w-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg>
</button>
</div>
)}
<p className="text-xs text-dark-500">
{t('admin.settings.googleLabelHint')}
</p>
<p className="text-xs text-dark-500">{t('admin.settings.googleLabelHint')}</p>
</div>
</div>
</div>
{/* Info block */}
<div className="p-4 rounded-2xl bg-dark-800/30 border border-dark-700/30">
<p className="text-sm text-dark-500 leading-relaxed">
{t('admin.settings.analyticsHint')}
</p>
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-4">
<p className="text-sm leading-relaxed text-dark-500">{t('admin.settings.analyticsHint')}</p>
</div>
</div>
)
);
}

View File

@@ -1,124 +1,130 @@
import { useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { brandingApi, setCachedBranding } from '../../api/branding'
import { setCachedAnimationEnabled } from '../AnimatedBackground'
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp'
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons'
import { Toggle } from './Toggle'
import { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { brandingApi, setCachedBranding } from '../../api/branding';
import { setCachedAnimationEnabled } from '../AnimatedBackground';
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp';
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons';
import { Toggle } from './Toggle';
interface BrandingTabProps {
accentColor?: string
accentColor?: string;
}
export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const { t } = useTranslation();
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [editingName, setEditingName] = useState(false)
const [newName, setNewName] = useState('')
const [editingName, setEditingName] = useState(false);
const [newName, setNewName] = useState('');
// Queries
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: brandingApi.getBranding,
})
});
const { data: animationSettings } = useQuery({
queryKey: ['animation-enabled'],
queryFn: brandingApi.getAnimationEnabled,
})
});
const { data: fullscreenSettings } = useQuery({
queryKey: ['fullscreen-enabled'],
queryFn: brandingApi.getFullscreenEnabled,
})
});
const { data: emailAuthSettings } = useQuery({
queryKey: ['email-auth-enabled'],
queryFn: brandingApi.getEmailAuthEnabled,
})
});
// Mutations
const updateBrandingMutation = useMutation({
mutationFn: brandingApi.updateName,
onSuccess: (data) => {
setCachedBranding(data)
queryClient.invalidateQueries({ queryKey: ['branding'] })
setEditingName(false)
setCachedBranding(data);
queryClient.invalidateQueries({ queryKey: ['branding'] });
setEditingName(false);
},
})
});
const uploadLogoMutation = useMutation({
mutationFn: brandingApi.uploadLogo,
onSuccess: (data) => {
setCachedBranding(data)
queryClient.invalidateQueries({ queryKey: ['branding'] })
setCachedBranding(data);
queryClient.invalidateQueries({ queryKey: ['branding'] });
},
})
});
const deleteLogoMutation = useMutation({
mutationFn: brandingApi.deleteLogo,
onSuccess: (data) => {
setCachedBranding(data)
queryClient.invalidateQueries({ queryKey: ['branding'] })
setCachedBranding(data);
queryClient.invalidateQueries({ queryKey: ['branding'] });
},
})
});
const updateAnimationMutation = useMutation({
mutationFn: (enabled: boolean) => brandingApi.updateAnimationEnabled(enabled),
onSuccess: (data) => {
setCachedAnimationEnabled(data.enabled)
queryClient.invalidateQueries({ queryKey: ['animation-enabled'] })
setCachedAnimationEnabled(data.enabled);
queryClient.invalidateQueries({ queryKey: ['animation-enabled'] });
},
})
});
const updateFullscreenMutation = useMutation({
mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled),
onSuccess: (data) => {
setCachedFullscreenEnabled(data.enabled)
queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] })
setCachedFullscreenEnabled(data.enabled);
queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] });
},
})
});
const updateEmailAuthMutation = useMutation({
mutationFn: (enabled: boolean) => brandingApi.updateEmailAuthEnabled(enabled),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['email-auth-enabled'] })
queryClient.invalidateQueries({ queryKey: ['email-auth-enabled'] });
},
})
});
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
const file = e.target.files?.[0];
if (file) {
uploadLogoMutation.mutate(file)
}
uploadLogoMutation.mutate(file);
}
};
return (
<div className="space-y-6">
{/* Logo & Name */}
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.settings.logoAndName')}</h3>
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<h3 className="mb-4 text-lg font-semibold text-dark-100">
{t('admin.settings.logoAndName')}
</h3>
<div className="flex items-start gap-6">
{/* Logo */}
<div className="flex-shrink-0">
<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={{
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}dd)`
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}dd)`,
}}
>
{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'
)}
</div>
<div className="flex gap-2 mt-3">
<div className="mt-3 flex gap-2">
<input
ref={fileInputRef}
type="file"
@@ -129,7 +135,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
<button
onClick={() => fileInputRef.current?.click()}
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 />
</button>
@@ -137,7 +143,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
<button
onClick={() => deleteLogoMutation.mutate()}
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 />
</button>
@@ -147,39 +153,43 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
{/* Name */}
<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 ? (
<div className="flex gap-2">
<input
type="text"
value={newName}
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}
/>
<button
onClick={() => updateBrandingMutation.mutate(newName)}
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 />
</button>
<button
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 />
</button>
</div>
) : (
<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
onClick={() => {
setNewName(branding?.name ?? '')
setEditingName(true)
setNewName(branding?.name ?? '');
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 />
</button>
@@ -190,13 +200,17 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
</div>
{/* Animation & Fullscreen toggles */}
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.settings.interfaceOptions')}</h3>
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
<h3 className="mb-4 text-lg font-semibold text-dark-100">
{t('admin.settings.interfaceOptions')}
</h3>
<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>
<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>
</div>
<Toggle
@@ -206,19 +220,23 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
/>
</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>
<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>
</div>
<Toggle
checked={fullscreenSettings?.enabled ?? false}
onChange={() => updateFullscreenMutation.mutate(!(fullscreenSettings?.enabled ?? false))}
onChange={() =>
updateFullscreenMutation.mutate(!(fullscreenSettings?.enabled ?? false))
}
disabled={updateFullscreenMutation.isPending}
/>
</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>
<span className="font-medium text-dark-100">{t('admin.settings.emailAuth')}</span>
<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>
)
);
}

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,14 @@
import { useTranslation } from 'react-i18next'
import { SearchIcon, CloseIcon } from './icons'
import { useTranslation } from 'react-i18next';
import { SearchIcon, CloseIcon } from './icons';
interface SettingsSearchProps {
searchQuery: string
setSearchQuery: (query: string) => void
resultsCount?: number
searchQuery: string;
setSearchQuery: (query: string) => void;
resultsCount?: number;
}
export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchProps) {
const { t } = useTranslation()
const { t } = useTranslation();
return (
<>
@@ -19,7 +19,7 @@ export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchPr
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
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">
<SearchIcon />
@@ -27,18 +27,21 @@ export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchPr
{searchQuery && (
<button
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 />
</button>
)}
</div>
</>
)
);
}
export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit<SettingsSearchProps, 'resultsCount'>) {
const { t } = useTranslation()
export function SettingsSearchMobile({
searchQuery,
setSearchQuery,
}: Omit<SettingsSearchProps, 'resultsCount'>) {
const { t } = useTranslation();
return (
<div className="relative mt-3 sm:hidden">
@@ -47,7 +50,7 @@ export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit<Setti
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
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">
<SearchIcon />
@@ -55,26 +58,30 @@ export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit<Setti
{searchQuery && (
<button
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 />
</button>
)}
</div>
)
);
}
export function SettingsSearchResults({ searchQuery, resultsCount }: { searchQuery: string; resultsCount: number }) {
if (!searchQuery.trim()) return null
export function SettingsSearchResults({
searchQuery,
resultsCount,
}: {
searchQuery: string;
resultsCount: number;
}) {
if (!searchQuery.trim()) return null;
return (
<div className="mt-3 flex items-center gap-2 text-sm">
<span className="text-dark-400">
{resultsCount > 0 ? `Найдено: ${resultsCount}` : 'Ничего не найдено'}
</span>
{resultsCount > 0 && (
<span className="text-dark-500">по запросу «{searchQuery}»</span>
)}
{resultsCount > 0 && <span className="text-dark-500">по запросу «{searchQuery}»</span>}
</div>
)
);
}

View File

@@ -1,13 +1,13 @@
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { BackIcon, StarIcon, CloseIcon, MENU_SECTIONS } from './index'
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { BackIcon, StarIcon, CloseIcon, MENU_SECTIONS } from './index';
interface SettingsSidebarProps {
activeSection: string
setActiveSection: (section: string) => void
mobileMenuOpen: boolean
setMobileMenuOpen: (open: boolean) => void
favoritesCount: number
activeSection: string;
setActiveSection: (section: string) => void;
mobileMenuOpen: boolean;
setMobileMenuOpen: (open: boolean) => void;
favoritesCount: number;
}
export function SettingsSidebar({
@@ -15,27 +15,27 @@ export function SettingsSidebar({
setActiveSection,
mobileMenuOpen,
setMobileMenuOpen,
favoritesCount
favoritesCount,
}: SettingsSidebarProps) {
const { t } = useTranslation()
const { t } = useTranslation();
return (
<aside className={`
fixed lg:sticky lg:top-0 inset-y-0 left-0 z-50
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'}
`}>
<aside
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'} `}
>
{/* 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">
<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 />
</Link>
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
<button
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 />
</button>
@@ -43,39 +43,39 @@ export function SettingsSidebar({
</div>
{/* 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) => (
<div key={section.id}>
{sectionIdx > 0 && <div className="my-3 border-t border-dark-700/50" />}
{section.items.map((item) => {
const isActive = activeSection === item.id
const hasIcon = item.iconType === 'star'
const isActive = activeSection === item.id;
const hasIcon = item.iconType === 'star';
return (
<button
key={item.id}
onClick={() => {
setActiveSection(item.id)
setMobileMenuOpen(false)
setActiveSection(item.id);
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
? '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'} />}
<span className="font-medium">{t(`admin.settings.${item.id}`)}</span>
{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}
</span>
)}
</button>
)
);
})}
</div>
))}
</nav>
</aside>
)
);
}

View File

@@ -1,22 +1,22 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings'
import { ChevronDownIcon } from './icons'
import { SettingRow } from './SettingRow'
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
import { ChevronDownIcon } from './icons';
import { SettingRow } from './SettingRow';
interface CategoryGroup {
key: string
label: string
settings: SettingDefinition[]
key: string;
label: string;
settings: SettingDefinition[];
}
interface SettingsTabProps {
categories: CategoryGroup[]
searchQuery: string
filteredSettings: SettingDefinition[]
isFavorite: (key: string) => boolean
toggleFavorite: (key: string) => void
categories: CategoryGroup[];
searchQuery: string;
filteredSettings: SettingDefinition[];
isFavorite: (key: string) => boolean;
toggleFavorite: (key: string) => void;
}
export function SettingsTab({
@@ -24,49 +24,50 @@ export function SettingsTab({
searchQuery,
filteredSettings,
isFavorite,
toggleFavorite
toggleFavorite,
}: SettingsTabProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { t } = useTranslation();
const queryClient = useQueryClient();
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
const toggleSection = (key: string) => {
setExpandedSections(prev => {
const next = new Set(prev)
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key)
next.delete(key);
} else {
next.add(key)
}
return next
})
next.add(key);
}
return next;
});
};
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: () => {
queryClient.invalidateQueries({ queryKey: ['admin-settings'] })
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
},
})
});
const resetSettingMutation = useMutation({
mutationFn: (key: string) => adminSettingsApi.resetSetting(key),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-settings'] })
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
},
})
});
// If searching, show flat list
if (searchQuery) {
return (
<div className="space-y-4">
{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>
</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) => (
<SettingRow
key={setting.key}
@@ -82,46 +83,50 @@ export function SettingsTab({
</div>
)}
</div>
)
);
}
// Show accordion for subcategories
return (
<div className="space-y-3">
{categories.map((cat) => {
const isExpanded = expandedSections.has(cat.key)
const isExpanded = expandedSections.has(cat.key);
return (
<div
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 */}
<button
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">
<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}
</span>
</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 />
</div>
</button>
{/* Accordion content */}
{isExpanded && (
<div className="p-4 pt-0 border-t border-dark-700/30">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 pt-4">
<div className="border-t border-dark-700/30 p-4 pt-0">
<div className="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2">
{cat.settings.map((setting) => (
<SettingRow
key={setting.key}
setting={setting}
isFavorite={isFavorite(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)}
isUpdating={updateSettingMutation.isPending}
isResetting={resetSettingMutation.isPending}
@@ -131,14 +136,14 @@ export function SettingsTab({
</div>
)}
</div>
)
);
})}
{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>
</div>
)}
</div>
)
);
}

View File

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

View File

@@ -1,7 +1,7 @@
interface ToggleProps {
checked: boolean
onChange: () => void
disabled?: boolean
checked: boolean;
onChange: () => void;
disabled?: boolean;
}
export function Toggle({ checked, onChange, disabled }: ToggleProps) {
@@ -9,13 +9,15 @@ export function Toggle({ checked, onChange, disabled }: ToggleProps) {
<button
onClick={onChange}
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'
} ${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'
}`} />
}`}
/>
</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
export interface MenuItem {
id: string
iconType?: 'star' | null
categories?: string[]
id: string;
iconType?: 'star' | null;
categories?: string[];
}
export interface MenuSection {
id: string
items: MenuItem[]
id: string;
items: MenuItem[];
}
// Sidebar menu configuration
@@ -21,36 +21,215 @@ export const MENU_SECTIONS: MenuSection[] = [
{ id: 'branding', iconType: null },
{ id: 'theme', iconType: null },
{ id: 'analytics', iconType: null },
]
],
},
{
id: 'settings',
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: '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: '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: '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: '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
export interface ThemePreset {
id: string
colors: ThemeColors
id: string;
colors: ThemeColors;
}
// Theme presets
export const THEME_PRESETS: ThemePreset[] = [
{ 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: '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' } },
]
{
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: '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
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" />
</svg>
)
);
export const SearchIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg>
)
);
export const StarIcon = ({ filled }: { filled?: boolean }) => (
<svg className="w-5 h-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
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>
)
);
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" />
</svg>
)
);
export const UploadIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
export const TrashIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
<svg 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"
/>
</svg>
)
);
export const PencilIcon = () => (
<svg className="w-4 h-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 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>
)
);
export const RefreshIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
)
);
export const LockIcon = () => (
<svg className="w-4 h-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" />
<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"
/>
</svg>
)
);
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" />
</svg>
)
);
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" />
</svg>
)
);
export const SunIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
export const MoonIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
export const MenuIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
)
);
export const EditIcon = () => (
<svg className="w-4 h-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" />
<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"
/>
</svg>
)
);

View File

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

View File

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

View File

@@ -1,97 +1,99 @@
import { useState, useEffect, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useBlockingStore } from '../../store/blocking'
import { apiClient } from '../../api/client'
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking';
import { apiClient } from '../../api/client';
const CHECK_COOLDOWN_SECONDS = 5
const CHECK_COOLDOWN_SECONDS = 5;
export default function ChannelSubscriptionScreen() {
const { t } = useTranslation()
const { channelInfo, clearBlocking } = useBlockingStore()
const [isChecking, setIsChecking] = useState(false)
const [cooldown, setCooldown] = useState(0)
const [error, setError] = useState<string | null>(null)
const { t } = useTranslation();
const { channelInfo, clearBlocking } = useBlockingStore();
const [isChecking, setIsChecking] = useState(false);
const [cooldown, setCooldown] = useState(0);
const [error, setError] = useState<string | null>(null);
// Cooldown timer
useEffect(() => {
if (cooldown <= 0) return
if (cooldown <= 0) return;
const timer = setInterval(() => {
setCooldown((prev) => {
if (prev <= 1) {
clearInterval(timer)
return 0
clearInterval(timer);
return 0;
}
return prev - 1
})
}, 1000)
return prev - 1;
});
}, 1000);
return () => clearInterval(timer)
}, [cooldown])
return () => clearInterval(timer);
}, [cooldown]);
const openChannel = useCallback(() => {
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 () => {
if (isChecking || cooldown > 0) return
if (isChecking || cooldown > 0) return;
setIsChecking(true)
setError(null)
setIsChecking(true);
setError(null);
try {
// 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
clearBlocking()
window.location.reload()
clearBlocking();
window.location.reload();
} catch (err: unknown) {
// Check if it's still a channel subscription error
const error = err as { response?: { status?: number; data?: { detail?: { code?: string } } } }
if (error.response?.status === 403 && error.response?.data?.detail?.code === 'channel_subscription_required') {
setError(t('blocking.channel.notSubscribed', 'Вы ещё не подписались на канал'))
const error = err as {
response?: { status?: number; data?: { detail?: { code?: string } } };
};
if (
error.response?.status === 403 &&
error.response?.data?.detail?.code === 'channel_subscription_required'
) {
setError(t('blocking.channel.notSubscribed', 'Вы ещё не подписались на канал'));
} else {
// Other error - might be network issue
setError(t('blocking.channel.checkError', 'Ошибка проверки. Попробуйте позже.'))
setError(t('blocking.channel.checkError', 'Ошибка проверки. Попробуйте позже.'));
}
} finally {
setIsChecking(false)
setCooldown(CHECK_COOLDOWN_SECONDS)
setIsChecking(false);
setCooldown(CHECK_COOLDOWN_SECONDS);
}
}, [isChecking, cooldown, clearBlocking, t])
}, [isChecking, cooldown, clearBlocking, t]);
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">
{/* Icon */}
<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">
<svg
className="w-12 h-12 text-blue-400"
fill="currentColor"
viewBox="0 0 24 24"
>
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br from-blue-500/20 to-cyan-500/20">
<svg className="h-12 w-12 text-blue-400" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg>
</div>
</div>
{/* 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', 'Подписка на канал')}
</h1>
{/* Message */}
<p className="text-gray-400 mb-8 text-lg">
{channelInfo?.message || t('blocking.channel.defaultMessage', 'Для продолжения работы подпишитесь на наш канал')}
<p className="mb-8 text-lg text-gray-400">
{channelInfo?.message ||
t('blocking.channel.defaultMessage', 'Для продолжения работы подпишитесь на наш канал')}
</p>
{/* Error message */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4 mb-6">
<p className="text-red-400 text-sm">{error}</p>
<div className="mb-6 rounded-xl border border-red-500/30 bg-red-500/10 p-4">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
@@ -101,13 +103,9 @@ export default function ChannelSubscriptionScreen() {
<button
onClick={openChannel}
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"
>
<svg
className="w-5 h-5"
fill="currentColor"
viewBox="0 0 24 24"
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 className="h-5 w-5" 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>
{t('blocking.channel.openChannel', 'Открыть канал')}
@@ -117,27 +115,60 @@ export default function ChannelSubscriptionScreen() {
<button
onClick={checkSubscription}
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 ? (
<>
<svg className="animate-spin h-5 w-5" 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
className="h-5 w-5 animate-spin"
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>
{t('blocking.channel.checking', 'Проверяем...')}
</>
) : cooldown > 0 ? (
<>
<svg className="w-5 h-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
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>
{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">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
<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"
/>
</svg>
{t('blocking.channel.checkSubscription', 'Проверить подписку')}
</>
@@ -146,10 +177,10 @@ export default function ChannelSubscriptionScreen() {
</div>
{/* Hint */}
<p className="text-gray-500 text-sm mt-6">
<p className="mt-6 text-sm text-gray-500">
{t('blocking.channel.hint', 'После подписки нажмите кнопку проверки')}
</p>
</div>
</div>
)
);
}

View File

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

View File

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

View File

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

View File

@@ -1,274 +1,357 @@
import { Link, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useState, useEffect, useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { useAuthStore } from '../../store/auth'
import LanguageSwitcher from '../LanguageSwitcher'
import PromoDiscountBadge from '../PromoDiscountBadge'
import TicketNotificationBell from '../TicketNotificationBell'
import AnimatedBackground from '../AnimatedBackground'
import { contestsApi } from '../../api/contests'
import { pollsApi } from '../../api/polls'
import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo, isLogoPreloaded } from '../../api/branding'
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'
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useState, useEffect, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../../store/auth';
import LanguageSwitcher from '../LanguageSwitcher';
import PromoDiscountBadge from '../PromoDiscountBadge';
import TicketNotificationBell from '../TicketNotificationBell';
import AnimatedBackground from '../AnimatedBackground';
import { contestsApi } from '../../api/contests';
import { pollsApi } from '../../api/polls';
import {
brandingApi,
getCachedBranding,
setCachedBranding,
preloadLogo,
isLogoPreloaded,
} from '../../api/branding';
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
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V'
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
interface LayoutProps {
children: React.ReactNode
children: React.ReactNode;
}
// Icons as simple SVG components
const HomeIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
const SubscriptionIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
/>
</svg>
)
);
const WalletIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
/>
</svg>
)
);
const UsersIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
)
);
const ChatIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
const UserIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
const LogoutIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
// Theme toggle icons
const SunIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
const MoonIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
const MenuIcon = () => (
<svg className="w-6 h-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" />
<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"
/>
</svg>
)
);
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" />
</svg>
)
);
const GamepadIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
const ClipboardIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z"
/>
</svg>
)
);
const InfoIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
const CogIcon = () => (
<svg className="w-5 h-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" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
);
const WheelIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
export default function Layout({ children }: LayoutProps) {
const { t } = useTranslation()
const location = useLocation()
const { user, logout, isAdmin, isAuthenticated } = useAuthStore()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false)
const { toggleTheme, isDark } = useTheme()
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null)
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp()
const { t } = useTranslation();
const location = useLocation();
const { user, logout, isAdmin, isAuthenticated } = useAuthStore();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
const { toggleTheme, isDark } = useTheme();
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null);
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
// Pull to refresh (disabled when mobile menu is open)
const { isPulling, pullDistance, isRefreshing, progress } = usePullToRefresh({
disabled: mobileMenuOpen,
threshold: 80,
})
});
// Fetch enabled themes from API - same source of truth as AdminSettings
const { data: enabledThemes } = useQuery({
queryKey: ['enabled-themes'],
queryFn: themeColorsApi.getEnabledThemes,
staleTime: 1000 * 60 * 5, // 5 minutes
})
});
// 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
useEffect(() => {
try {
const tg = (window as any).Telegram?.WebApp
const photoUrl = tg?.initDataUnsafe?.user?.photo_url
const tg = (
window as unknown as {
Telegram?: { WebApp?: { initDataUnsafe?: { user?: { photo_url?: string } } } };
}
).Telegram?.WebApp;
const photoUrl = tg?.initDataUnsafe?.user?.photo_url;
if (photoUrl) {
setUserPhotoUrl(photoUrl)
setUserPhotoUrl(photoUrl);
}
} 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)
// 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
useEffect(() => {
if (!mobileMenuOpen) return
if (!mobileMenuOpen) return;
const body = document.body
const html = document.documentElement
const body = document.body;
const html = document.documentElement;
// Save original styles
const originalStyles = {
bodyOverflow: body.style.overflow,
htmlOverflow: html.style.overflow,
}
};
// Lock scroll - simple approach without body position manipulation
body.style.overflow = 'hidden'
html.style.overflow = 'hidden'
body.style.overflow = 'hidden';
html.style.overflow = 'hidden';
// Prevent touchmove on body (critical for mobile, especially Telegram Mini App)
const preventScroll = (e: TouchEvent) => {
const target = e.target as HTMLElement
const target = e.target as HTMLElement;
// Allow scroll inside menu content
if (target.closest('.mobile-menu-content')) return
e.preventDefault()
}
document.addEventListener('touchmove', preventScroll, { passive: false })
if (target.closest('.mobile-menu-content')) return;
e.preventDefault();
};
document.addEventListener('touchmove', preventScroll, { passive: false });
// Also prevent wheel scroll on desktop
const preventWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement
if (target.closest('.mobile-menu-content')) return
e.preventDefault()
}
document.addEventListener('wheel', preventWheel, { passive: false })
const target = e.target as HTMLElement;
if (target.closest('.mobile-menu-content')) return;
e.preventDefault();
};
document.addEventListener('wheel', preventWheel, { passive: false });
return () => {
// Restore original styles
body.style.overflow = originalStyles.bodyOverflow
html.style.overflow = originalStyles.htmlOverflow
body.style.overflow = originalStyles.bodyOverflow;
html.style.overflow = originalStyles.htmlOverflow;
// Remove listeners
document.removeEventListener('touchmove', preventScroll)
document.removeEventListener('wheel', preventWheel)
}
}, [mobileMenuOpen])
document.removeEventListener('touchmove', preventScroll);
document.removeEventListener('wheel', preventWheel);
};
}, [mobileMenuOpen]);
// Detect virtual keyboard by tracking focus on input elements
useEffect(() => {
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) {
setIsKeyboardOpen(true)
}
setIsKeyboardOpen(true);
}
};
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
if (!relatedTarget ||
if (
!relatedTarget ||
(relatedTarget.tagName !== 'INPUT' &&
relatedTarget.tagName !== 'TEXTAREA' &&
!relatedTarget.isContentEditable)) {
setIsKeyboardOpen(false)
}
!relatedTarget.isContentEditable)
) {
setIsKeyboardOpen(false);
}
};
document.addEventListener('focusin', handleFocusIn)
document.addEventListener('focusout', handleFocusOut)
document.addEventListener('focusin', handleFocusIn);
document.addEventListener('focusout', handleFocusOut);
return () => {
document.removeEventListener('focusin', handleFocusIn)
document.removeEventListener('focusout', handleFocusOut)
}
}, [])
document.removeEventListener('focusin', handleFocusIn);
document.removeEventListener('focusout', handleFocusOut);
};
}, []);
// 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
const { data: branding } = useQuery({
queryKey: ['branding'],
queryFn: async () => {
const data = await brandingApi.getBranding()
setCachedBranding(data) // Update cache
const data = await brandingApi.getBranding();
setCachedBranding(data); // Update cache
// Preload logo in background
preloadLogo(data)
return data
preloadLogo(data);
return data;
},
initialData: getCachedBranding() ?? undefined, // Use cached data immediately
staleTime: 60000, // 1 minute
refetchOnWindowFocus: true,
retry: 1,
})
});
// 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 logoLetter = branding?.logo_letter || FALLBACK_LOGO
const hasCustomLogo = branding?.has_custom_logo || false
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
const appName = branding ? branding.name : FALLBACK_NAME; // Empty string is valid (logo-only mode)
const logoLetter = branding?.logo_letter || FALLBACK_LOGO;
const hasCustomLogo = branding?.has_custom_logo || false;
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
// Set document title
useEffect(() => {
document.title = appName || 'VPN' // Fallback title if name is empty
}, [appName])
document.title = appName || 'VPN'; // Fallback title if name is empty
}, [appName]);
// Update favicon
useEffect(() => {
if (!logoUrl) return
const link = document.querySelector<HTMLLinkElement>("link[rel*='icon']") || document.createElement('link')
link.type = 'image/x-icon'
link.rel = 'shortcut icon'
link.href = logoUrl
document.getElementsByTagName('head')[0].appendChild(link)
}, [logoUrl])
// Fetch contests and polls counts to determine if they should be shown
const { data: contestsCount } = useQuery({
@@ -277,7 +360,7 @@ export default function Layout({ children }: LayoutProps) {
enabled: isAuthenticated,
staleTime: 60000, // 1 minute
retry: false,
})
});
const { data: pollsCount } = useQuery({
queryKey: ['polls-count'],
@@ -285,7 +368,7 @@ export default function Layout({ children }: LayoutProps) {
enabled: isAuthenticated,
staleTime: 60000, // 1 minute
retry: false,
})
});
// Fetch wheel config to check if enabled
const { data: wheelConfig } = useQuery({
@@ -294,7 +377,7 @@ export default function Layout({ children }: LayoutProps) {
enabled: isAuthenticated,
staleTime: 60000, // 1 minute
retry: false,
})
});
// Fetch referral terms to check if enabled
const { data: referralTerms } = useQuery({
@@ -303,7 +386,7 @@ export default function Layout({ children }: LayoutProps) {
enabled: isAuthenticated,
staleTime: 60000, // 1 minute
retry: false,
})
});
// Fetch active discount to determine mobile layout
const { data: activeDiscount } = useQuery({
@@ -311,86 +394,90 @@ export default function Layout({ children }: LayoutProps) {
queryFn: promoApi.getActiveDiscount,
enabled: isAuthenticated,
staleTime: 30000,
})
});
// 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 items = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
]
];
// Only show referral if program 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
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
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
}, [t, contestsCount, pollsCount, referralTerms])
return items;
}, [t, contestsCount, pollsCount, referralTerms]);
// Separate navItems for desktop that includes wheel (if enabled)
const desktopNavItems = useMemo(() => {
const items = [...navItems]
const items = [...navItems];
// Add wheel before info if enabled
if (wheelConfig?.is_enabled) {
const infoIndex = items.findIndex(item => item.path === '/info')
const infoIndex = items.findIndex((item) => item.path === '/info');
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 {
items.push({ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon })
items.push({ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon });
}
}
return items
}, [navItems, wheelConfig, t])
return items;
}, [navItems, wheelConfig, t]);
const adminNavItems = [
{ path: '/admin', label: t('admin.nav.title'), icon: CogIcon },
]
const adminNavItems = [{ path: '/admin', label: t('admin.nav.title'), icon: CogIcon }];
const isActive = (path: string) => location.pathname === path
const isAdminActive = () => location.pathname.startsWith('/admin')
const isActive = (path: string) => location.pathname === path;
const isAdminActive = () => location.pathname.startsWith('/admin');
return (
<div className="min-h-screen flex flex-col">
<div className="flex min-h-screen flex-col">
{/* Animated Background */}
<AnimatedBackground />
{/* Pull to refresh indicator */}
{(isPulling || isRefreshing) && (
<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={{
top: `calc(${Math.max(pullDistance, isRefreshing ? 40 : 0)}px + env(safe-area-inset-top, 0px) + 0.5rem)`,
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
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)` }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
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>
</div>
</div>
@@ -398,19 +485,30 @@ export default function Layout({ children }: LayoutProps) {
{/* 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={{
// 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 className="flex justify-between items-center h-16 lg:h-20">
<div
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 */}
<Link to="/" onClick={() => setMobileMenuOpen(false)} className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}>
<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">
<Link
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 */}
<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}
</span>
{/* Logo image with smooth fade-in */}
@@ -418,28 +516,28 @@ export default function Layout({ children }: LayoutProps) {
<img
src={logoUrl}
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)}
/>
)}
</div>
{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}
</span>
)}
</Link>
{/* Desktop Navigation */}
<nav className="hidden lg:flex items-center gap-1">
<nav className="hidden items-center gap-1 lg:flex">
{desktopNavItems.map((item) => (
<Link
key={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)
? 'text-accent-400 bg-accent-500/10'
: 'text-dark-400 hover:text-dark-100 hover:bg-dark-800/50'
? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-100'
}`}
>
<item.icon />
@@ -448,15 +546,15 @@ export default function Layout({ children }: LayoutProps) {
))}
{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) => (
<Link
key={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()
? 'text-warning-400 bg-warning-500/10'
: 'text-warning-500/70 hover:text-warning-400 hover:bg-warning-500/10'
? 'bg-warning-500/10 text-warning-400'
: 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400'
}`}
>
<item.icon />
@@ -473,21 +571,26 @@ export default function Layout({ children }: LayoutProps) {
{canToggle && (
<button
onClick={() => {
toggleTheme()
setMobileMenuOpen(false)
toggleTheme();
setMobileMenuOpen(false);
}}
className="relative p-2 rounded-xl transition-all duration-200
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"
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"
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 />
</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 />
</div>
</div>
@@ -501,17 +604,20 @@ export default function Layout({ children }: LayoutProps) {
<TicketNotificationBell isAdmin={isAdminActive()} />
</div>
{/* 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 />
</div>
{/* Profile - Desktop */}
<div className="hidden sm:flex items-center gap-3">
<div className="hidden items-center gap-3 sm:flex">
<Link
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 />
</div>
<span className="text-sm text-dark-300">
@@ -531,11 +637,13 @@ export default function Layout({ children }: LayoutProps) {
{/* Mobile menu button */}
<button
onClick={(e) => {
e.stopPropagation()
setMobileMenuOpen(!mobileMenuOpen)
e.stopPropagation();
setMobileMenuOpen(!mobileMenuOpen);
}}
className="lg:hidden btn-icon"
aria-label={mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'}
className="btn-icon lg:hidden"
aria-label={
mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'
}
aria-expanded={mobileMenuOpen}
>
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
@@ -543,7 +651,6 @@ export default function Layout({ children }: LayoutProps) {
</div>
</div>
</div>
</header>
{/* Spacer for fixed header - matches header height */}
@@ -553,17 +660,17 @@ export default function Layout({ children }: LayoutProps) {
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 */}
{mobileMenuOpen && (
<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={{
top: isFullscreen
? `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px`
: '64px'
: '64px',
}}
>
{/* Backdrop */}
@@ -573,23 +680,28 @@ export default function Layout({ children }: LayoutProps) {
/>
{/* 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 className="max-w-6xl mx-auto px-4 py-4">
<div
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 */}
<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">
{userPhotoUrl ? (
<img
src={userPhotoUrl}
alt="Avatar"
className="w-10 h-10 rounded-full object-cover"
className="h-10 w-10 rounded-full object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none'
e.currentTarget.nextElementSibling?.classList.remove('hidden')
e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden');
}}
/>
) : 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 />
</div>
<div>
@@ -622,7 +734,7 @@ export default function Layout({ children }: LayoutProps) {
{isAdmin && (
<>
<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')}
</div>
{adminNavItems.map((item) => (
@@ -630,7 +742,7 @@ export default function Layout({ children }: LayoutProps) {
key={item.path}
to={item.path}
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.label}
@@ -652,8 +764,8 @@ export default function Layout({ children }: LayoutProps) {
<button
onClick={() => {
setMobileMenuOpen(false)
logout()
setMobileMenuOpen(false);
logout();
}}
className="nav-item w-full text-error-400"
>
@@ -667,19 +779,20 @@ export default function Layout({ children }: LayoutProps) {
)}
{/* 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">
<div className="animate-fade-in">
{children}
</div>
<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">{children}</div>
</main>
{/* 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">
{navItems.filter(item =>
['/', '/subscription', '/balance', '/referral', '/support'].includes(item.path)
).map((item) => (
{navItems
.filter((item) =>
['/', '/subscription', '/balance', '/referral', '/support'].includes(item.path),
)
.map((item) => (
<Link
key={item.path}
to={item.path}
@@ -687,11 +800,11 @@ export default function Layout({ children }: LayoutProps) {
className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'}
>
<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>
))}
</div>
</nav>
</div>
)
);
}

View File

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

View File

@@ -1,28 +1,20 @@
interface BentoSkeletonProps {
className?: string
count?: number
className?: string;
count?: number;
}
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) {
return (
<>
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className={baseClasses}
style={{ '--stagger': i } as React.CSSProperties}
/>
<div key={i} className={baseClasses} style={{ '--stagger': i } as React.CSSProperties} />
))}
</>
)
);
}
return (
<div
className={baseClasses}
/>
)
return <div className={baseClasses} />;
}

View File

@@ -1,19 +1,19 @@
import { useEffect, useRef, useState, useMemo, memo } from 'react'
import type { WheelPrize } from '../../api/wheel'
import { useEffect, useRef, useState, useMemo, memo } from 'react';
import type { WheelPrize } from '../../api/wheel';
interface FortuneWheelProps {
prizes: WheelPrize[]
isSpinning: boolean
targetRotation: number | null
onSpinComplete: () => void
prizes: WheelPrize[];
isSpinning: boolean;
targetRotation: number | null;
onSpinComplete: () => void;
}
// Pre-generate sparkle positions to avoid recalculating on each render
const SPARKLE_POSITIONS = Array.from({ length: 8 }, (_, i) => ({
top: `${20 + (i * 10) % 60}%`,
left: `${15 + (i * 13) % 70}%`,
top: `${20 + ((i * 10) % 60)}%`,
left: `${15 + ((i * 13) % 70)}%`,
delay: `${i * 0.15}s`,
}))
}));
const FortuneWheel = memo(function FortuneWheel({
prizes,
@@ -21,141 +21,150 @@ const FortuneWheel = memo(function FortuneWheel({
targetRotation,
onSpinComplete,
}: FortuneWheelProps) {
const wheelRef = useRef<SVGGElement>(null)
const [currentRotation, setCurrentRotation] = useState(0)
const [lightPhase, setLightPhase] = useState(0)
const wheelRef = useRef<SVGGElement>(null);
const [currentRotation, setCurrentRotation] = useState(0);
const [lightPhase, setLightPhase] = useState(0);
// Animated lights effect - use phase instead of random array (less re-renders)
useEffect(() => {
if (isSpinning) {
const interval = setInterval(() => {
setLightPhase(p => (p + 1) % 3) // Just toggle phase 0-1-2
}, 250) // Slower interval = better performance
return () => clearInterval(interval)
setLightPhase((p) => (p + 1) % 3); // Just toggle phase 0-1-2
}, 250); // Slower interval = better performance
return () => clearInterval(interval);
} else {
setLightPhase(0)
setLightPhase(0);
}
}, [isSpinning])
}, [isSpinning]);
useEffect(() => {
if (isSpinning && targetRotation !== null && wheelRef.current) {
setCurrentRotation(targetRotation)
setCurrentRotation(targetRotation);
const timeout = setTimeout(() => {
onSpinComplete()
}, 5000)
onSpinComplete();
}, 5000);
return () => clearTimeout(timeout)
return () => clearTimeout(timeout);
}
}, [isSpinning, targetRotation, onSpinComplete])
}, [isSpinning, targetRotation, onSpinComplete]);
// Memoize light pattern calculation
const lightPattern = useMemo(() => {
return Array.from({ length: 20 }, (_, i) => {
if (!isSpinning) return i % 2 === 0
return (i + lightPhase) % 3 !== 0
})
}, [isSpinning, lightPhase])
if (!isSpinning) return i % 2 === 0;
return (i + lightPhase) % 3 !== 0;
});
}, [isSpinning, lightPhase]);
if (prizes.length === 0) {
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>
</div>
)
);
}
const size = 400
const center = size / 2
const outerRadius = size / 2 - 20
const innerRadius = outerRadius - 15
const prizeRadius = innerRadius - 5
const sectorAngle = 360 / prizes.length
const hubRadius = 45
const size = 400;
const center = size / 2;
const outerRadius = size / 2 - 20;
const innerRadius = outerRadius - 15;
const prizeRadius = innerRadius - 5;
const sectorAngle = 360 / prizes.length;
const hubRadius = 45;
const createSectorPath = (index: number) => {
const startAngle = (index * sectorAngle - 90) * (Math.PI / 180)
const endAngle = ((index + 1) * sectorAngle - 90) * (Math.PI / 180)
const startAngle = (index * sectorAngle - 90) * (Math.PI / 180);
const endAngle = ((index + 1) * sectorAngle - 90) * (Math.PI / 180);
const x1 = center + prizeRadius * Math.cos(startAngle)
const y1 = center + prizeRadius * Math.sin(startAngle)
const x2 = center + prizeRadius * Math.cos(endAngle)
const y2 = center + prizeRadius * Math.sin(endAngle)
const x1 = center + prizeRadius * Math.cos(startAngle);
const y1 = center + prizeRadius * Math.sin(startAngle);
const x2 = center + prizeRadius * Math.cos(endAngle);
const y2 = center + prizeRadius * Math.sin(endAngle);
const x1Inner = center + hubRadius * Math.cos(startAngle)
const y1Inner = center + hubRadius * Math.sin(startAngle)
const x2Inner = center + hubRadius * Math.cos(endAngle)
const y2Inner = center + hubRadius * Math.sin(endAngle)
const x1Inner = center + hubRadius * Math.cos(startAngle);
const y1Inner = center + hubRadius * Math.sin(startAngle);
const x2Inner = center + hubRadius * Math.cos(endAngle);
const y2Inner = center + hubRadius * Math.sin(endAngle);
const largeArc = sectorAngle > 180 ? 1 : 0
const largeArc = sectorAngle > 180 ? 1 : 0;
return `M ${x1Inner} ${y1Inner}
L ${x1} ${y1}
A ${prizeRadius} ${prizeRadius} 0 ${largeArc} 1 ${x2} ${y2}
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
const getEmojiPosition = (index: number) => {
const angle = ((index * sectorAngle + sectorAngle / 2) - 90) * (Math.PI / 180)
const emojiRadius = prizeRadius * 0.75
const angle = (index * sectorAngle + sectorAngle / 2 - 90) * (Math.PI / 180);
const emojiRadius = prizeRadius * 0.75;
return {
x: center + emojiRadius * Math.cos(angle),
y: center + emojiRadius * Math.sin(angle),
rotation: index * sectorAngle + sectorAngle / 2,
}
}
};
};
// Position for text - between hub and emoji
const getTextPosition = (index: number) => {
const angle = ((index * sectorAngle + sectorAngle / 2) - 90) * (Math.PI / 180)
const textRadius = prizeRadius * 0.45
const angle = (index * sectorAngle + sectorAngle / 2 - 90) * (Math.PI / 180);
const textRadius = prizeRadius * 0.45;
return {
x: center + textRadius * Math.cos(angle),
y: center + textRadius * Math.sin(angle),
rotation: index * sectorAngle + sectorAngle / 2,
}
}
};
};
// Alternate colors for sectors
const getSectorColors = (index: number, baseColor?: string) => {
if (baseColor) return baseColor
if (baseColor) return baseColor;
const colors = [
'#8B5CF6', '#EC4899', '#3B82F6', '#10B981',
'#F59E0B', '#EF4444', '#6366F1', '#14B8A6'
]
return colors[index % colors.length]
}
'#8B5CF6',
'#EC4899',
'#3B82F6',
'#10B981',
'#F59E0B',
'#EF4444',
'#6366F1',
'#14B8A6',
];
return colors[index % colors.length];
};
// Truncate text intelligently
const truncateText = (text: string, maxLen: number) => {
if (text.length <= maxLen) return text
return text.substring(0, maxLen - 1) + '..'
}
if (text.length <= maxLen) return text;
return text.substring(0, maxLen - 1) + '..';
};
// 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 (
<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 */}
<div
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={{
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)',
}}
/>
{/* 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={`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">
<defs>
@@ -166,7 +175,13 @@ const FortuneWheel = memo(function FortuneWheel({
<stop offset="100%" stopColor="#D97706" />
</linearGradient>
<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>
</defs>
<polygon
@@ -174,10 +189,7 @@ const FortuneWheel = memo(function FortuneWheel({
fill="url(#pointerGold)"
filter="url(#pointerGlow)"
/>
<polygon
points="22,50 6,16 22,4"
fill="rgba(255,255,255,0.3)"
/>
<polygon points="22,50 6,16 22,4" fill="rgba(255,255,255,0.3)" />
<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" />
@@ -187,22 +199,25 @@ const FortuneWheel = memo(function FortuneWheel({
{/* Main Wheel */}
<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>
{/* Sector gradients */}
{prizes.map((prize, index) => {
const color = getSectorColors(index, prize.color)
const color = getSectorColors(index, prize.color);
return (
<linearGradient
key={`grad-${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="50%" stopColor={color} stopOpacity="0.85" />
<stop offset="100%" stopColor={color} stopOpacity="0.7" />
</linearGradient>
)
);
})}
{/* Outer ring gradient */}
@@ -252,10 +267,10 @@ const FortuneWheel = memo(function FortuneWheel({
{/* LED lights on outer ring */}
{Array.from({ length: 20 }).map((_, i) => {
const angle = (i * 18 - 90) * (Math.PI / 180)
const dotX = center + outerRadius * Math.cos(angle)
const dotY = center + outerRadius * Math.sin(angle)
const isLit = lightPattern[i] ?? (i % 2 === 0)
const angle = (i * 18 - 90) * (Math.PI / 180);
const dotX = center + outerRadius * Math.cos(angle);
const dotY = center + outerRadius * Math.sin(angle);
const isLit = lightPattern[i] ?? i % 2 === 0;
return (
<g key={`led-${i}`}>
{isLit && (
@@ -277,7 +292,7 @@ const FortuneWheel = memo(function FortuneWheel({
strokeWidth="1"
/>
</g>
)
);
})}
{/* Rotating wheel group */}
@@ -286,9 +301,7 @@ const FortuneWheel = memo(function FortuneWheel({
style={{
transformOrigin: `${center}px ${center}px`,
transform: `rotate(${currentRotation}deg)`,
transition: isSpinning
? 'transform 5s cubic-bezier(0.15, 0.6, 0.1, 1)'
: 'none',
transition: isSpinning ? 'transform 5s cubic-bezier(0.15, 0.6, 0.1, 1)' : 'none',
}}
>
{/* Sectors */}
@@ -304,11 +317,11 @@ const FortuneWheel = memo(function FortuneWheel({
{/* Sector dividers */}
{prizes.map((_, index) => {
const angle = (index * sectorAngle - 90) * (Math.PI / 180)
const x1 = center + hubRadius * Math.cos(angle)
const y1 = center + hubRadius * Math.sin(angle)
const x2 = center + prizeRadius * Math.cos(angle)
const y2 = center + prizeRadius * Math.sin(angle)
const angle = (index * sectorAngle - 90) * (Math.PI / 180);
const x1 = center + hubRadius * Math.cos(angle);
const y1 = center + hubRadius * Math.sin(angle);
const x2 = center + prizeRadius * Math.cos(angle);
const y2 = center + prizeRadius * Math.sin(angle);
return (
<line
key={`divider-${index}`}
@@ -319,12 +332,12 @@ const FortuneWheel = memo(function FortuneWheel({
stroke="rgba(255,255,255,0.25)"
strokeWidth="2"
/>
)
);
})}
{/* Prize content - Emoji */}
{prizes.map((prize, index) => {
const pos = getEmojiPosition(index)
const pos = getEmojiPosition(index);
return (
<text
key={`emoji-${prize.id}`}
@@ -332,19 +345,19 @@ const FortuneWheel = memo(function FortuneWheel({
y={pos.y}
textAnchor="middle"
dominantBaseline="middle"
fontSize={prizes.length <= 6 ? "32" : "26"}
fontSize={prizes.length <= 6 ? '32' : '26'}
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
style={{ filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.5))' }}
>
{prize.emoji}
</text>
)
);
})}
{/* Prize content - Text */}
{prizes.map((prize, index) => {
const pos = getTextPosition(index)
const displayText = truncateText(prize.display_name, maxTextLength)
const pos = getTextPosition(index);
const displayText = truncateText(prize.display_name, maxTextLength);
return (
<text
key={`text-${prize.id}`}
@@ -352,7 +365,7 @@ const FortuneWheel = memo(function FortuneWheel({
y={pos.y}
textAnchor="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"
fill="#FFFFFF"
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
@@ -361,7 +374,7 @@ const FortuneWheel = memo(function FortuneWheel({
>
{displayText}
</text>
)
);
})}
</g>
@@ -386,13 +399,7 @@ const FortuneWheel = memo(function FortuneWheel({
/>
{/* Hub shine */}
<ellipse
cx={center - 10}
cy={center - 12}
rx={15}
ry={10}
fill="rgba(255,255,255,0.2)"
/>
<ellipse cx={center - 10} cy={center - 12} rx={15} ry={10} fill="rgba(255,255,255,0.2)" />
{/* Center button */}
<circle
@@ -422,7 +429,7 @@ const FortuneWheel = memo(function FortuneWheel({
{/* Spinning overlay glow */}
{isSpinning && (
<div
className="absolute inset-0 rounded-full pointer-events-none"
className="pointer-events-none absolute inset-0 rounded-full"
style={{
background: 'radial-gradient(circle, rgba(168, 85, 247, 0.25) 0%, transparent 50%)',
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 */}
{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) => (
<div
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={{
top: pos.top,
left: pos.left,
@@ -450,7 +457,7 @@ const FortuneWheel = memo(function FortuneWheel({
</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 {
id: string
name: string
nameRu: string
description: string
descriptionRu: string
colors: ThemeColors
id: string;
name: string;
nameRu: string;
description: string;
descriptionRu: string;
colors: ThemeColors;
preview: {
background: string
accent: string
text: string
}
background: string;
accent: string;
text: string;
};
}
export const COLOR_PRESETS: ColorPreset[] = [
@@ -275,8 +275,8 @@ export const COLOR_PRESETS: ColorPreset[] = [
text: '#f1f5f9',
},
},
]
];
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 { useQuery } from '@tanstack/react-query'
import { brandingApi } from '../api/branding'
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { brandingApi } from '../api/branding';
const YM_SCRIPT_ID = 'ym-counter-script'
const GTAG_LOADER_ID = 'gtag-loader-script'
const GTAG_INIT_ID = 'gtag-init-script'
const YM_SCRIPT_ID = 'ym-counter-script';
const GTAG_LOADER_ID = 'gtag-loader-script';
const GTAG_INIT_ID = 'gtag-init-script';
function removeElement(id: string) {
document.getElementById(id)?.remove()
document.getElementById(id)?.remove();
}
function injectYandexMetrika(counterId: string) {
if (document.getElementById(YM_SCRIPT_ID)) return
if (document.getElementById(YM_SCRIPT_ID)) return;
const script = document.createElement('script')
script.id = YM_SCRIPT_ID
script.type = 'text/javascript'
const script = document.createElement('script');
script.id = YM_SCRIPT_ID;
script.type = 'text/javascript';
script.textContent = `
(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();
@@ -26,30 +26,30 @@ function injectYandexMetrika(counterId: string) {
trackLinks:true,
accurateTrackBounce:true
});
`
document.head.appendChild(script)
`;
document.head.appendChild(script);
}
function injectGoogleAds(conversionId: string) {
if (document.getElementById(GTAG_LOADER_ID)) return
if (document.getElementById(GTAG_LOADER_ID)) return;
// External gtag.js loader
const loader = document.createElement('script')
loader.id = GTAG_LOADER_ID
loader.async = true
loader.src = `https://www.googletagmanager.com/gtag/js?id=${conversionId}`
document.head.appendChild(loader)
const loader = document.createElement('script');
loader.id = GTAG_LOADER_ID;
loader.async = true;
loader.src = `https://www.googletagmanager.com/gtag/js?id=${conversionId}`;
document.head.appendChild(loader);
// Init script
const init = document.createElement('script')
init.id = GTAG_INIT_ID
const init = document.createElement('script');
init.id = GTAG_INIT_ID;
init.textContent = `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${conversionId}');
`
document.head.appendChild(init)
`;
document.head.appendChild(init);
}
/**
@@ -61,24 +61,24 @@ export function useAnalyticsCounters() {
queryKey: ['analytics-counters'],
queryFn: brandingApi.getAnalyticsCounters,
staleTime: 5 * 60 * 1000, // 5 min
})
});
useEffect(() => {
if (!data) return
if (!data) return;
// Yandex Metrika
if (data.yandex_metrika_id) {
injectYandexMetrika(data.yandex_metrika_id)
injectYandexMetrika(data.yandex_metrika_id);
} else {
removeElement(YM_SCRIPT_ID)
removeElement(YM_SCRIPT_ID);
}
// Google Ads
if (data.google_ads_id) {
injectGoogleAds(data.google_ads_id)
injectGoogleAds(data.google_ads_id);
} else {
removeElement(GTAG_LOADER_ID)
removeElement(GTAG_INIT_ID)
removeElement(GTAG_LOADER_ID);
removeElement(GTAG_INIT_ID);
}
}, [data])
}, [data]);
}

View File

@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { currencyApi, type ExchangeRates } from '../api/currency'
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { currencyApi, type ExchangeRates } from '../api/currency';
// Map language to currency
const LANGUAGE_CURRENCY_MAP: Record<string, keyof ExchangeRates | 'RUB'> = {
@@ -8,17 +8,17 @@ const LANGUAGE_CURRENCY_MAP: Record<string, keyof ExchangeRates | 'RUB'> = {
en: 'USD',
zh: 'CNY',
fa: 'IRR',
}
};
// Default rates for fallback
const DEFAULT_RATES: ExchangeRates = {
USD: 100,
CNY: 14,
IRR: 0.0024,
}
};
export function useCurrency() {
const { i18n, t } = useTranslation()
const { i18n, t } = useTranslation();
// Fetch exchange rates
const { data: exchangeRates = DEFAULT_RATES } = useQuery({
@@ -27,72 +27,68 @@ export function useCurrency() {
staleTime: 60 * 60 * 1000, // 1 hour
refetchOnWindowFocus: false,
retry: 2,
})
});
// Get current language and currency
const currentLanguage = i18n.language
const targetCurrency = LANGUAGE_CURRENCY_MAP[currentLanguage] || 'USD'
const currentLanguage = i18n.language;
const targetCurrency = LANGUAGE_CURRENCY_MAP[currentLanguage] || 'USD';
// Check if current language is Russian (no conversion needed)
const isRussian = currentLanguage === 'ru'
const isRussian = currentLanguage === 'ru';
// Get currency symbol from translations
const currencySymbol = t('common.currency')
const currencySymbol = t('common.currency');
// Format amount with currency conversion
const formatAmount = (rubAmount: number, decimals: number = 2): string => {
if (isRussian) {
return rubAmount.toFixed(decimals)
return rubAmount.toFixed(decimals);
}
// Convert to target currency
const convertedAmount = currencyApi.convertFromRub(
rubAmount,
targetCurrency as keyof ExchangeRates,
exchangeRates
)
exchangeRates,
);
// For IRR (Iranian Toman), use no decimals as amounts are large
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
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)
const formatPositive = (rubAmount: number, decimals: number = 2): string => {
return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`
}
return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
};
// Get raw converted amount (for calculations)
const convertAmount = (rubAmount: number): number => {
if (isRussian) {
return rubAmount
return rubAmount;
}
return currencyApi.convertFromRub(
rubAmount,
targetCurrency as keyof ExchangeRates,
exchangeRates
)
}
exchangeRates,
);
};
// Convert from user's currency back to rubles
const convertToRub = (amount: number): number => {
if (isRussian) {
return amount
}
return currencyApi.convertToRub(
amount,
targetCurrency as keyof ExchangeRates,
exchangeRates
)
return amount;
}
return currencyApi.convertToRub(amount, targetCurrency as keyof ExchangeRates, exchangeRates);
};
return {
exchangeRates,
@@ -104,5 +100,5 @@ export function useCurrency() {
formatPositive,
convertAmount,
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() {
const [favorites, setFavorites] = useState<string[]>(() => {
try {
const stored = localStorage.getItem(STORAGE_KEY)
return stored ? JSON.parse(stored) : []
const stored = localStorage.getItem(STORAGE_KEY);
return stored ? JSON.parse(stored) : [];
} catch {
return []
return [];
}
})
});
// Sync to localStorage
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites))
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites));
} catch {
// Ignore storage errors
}
}, [favorites])
}, [favorites]);
const toggleFavorite = useCallback((settingKey: string) => {
setFavorites(prev => {
setFavorites((prev) => {
if (prev.includes(settingKey)) {
return prev.filter(key => key !== settingKey)
return prev.filter((key) => key !== settingKey);
} else {
return [...prev, settingKey]
return [...prev, settingKey];
}
})
}, [])
});
}, []);
const isFavorite = useCallback((settingKey: string) => {
return favorites.includes(settingKey)
}, [favorites])
const isFavorite = useCallback(
(settingKey: string) => {
return favorites.includes(settingKey);
},
[favorites],
);
const clearFavorites = useCallback(() => {
setFavorites([])
}, [])
setFavorites([]);
}, []);
return {
favorites,
toggleFavorite,
isFavorite,
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 {
onRefresh?: () => void | Promise<void>
threshold?: number // How far to pull before triggering refresh (px)
disabled?: boolean
onRefresh?: () => void | Promise<void>;
threshold?: number; // How far to pull before triggering refresh (px)
disabled?: boolean;
}
export function usePullToRefresh({
@@ -11,150 +11,152 @@ export function usePullToRefresh({
threshold = 80,
disabled = false,
}: UsePullToRefreshOptions = {}) {
const [isPulling, setIsPulling] = useState(false)
const [pullDistance, setPullDistance] = useState(0)
const [isRefreshing, setIsRefreshing] = useState(false)
const [isPulling, setIsPulling] = useState(false);
const [pullDistance, setPullDistance] = useState(0);
const [isRefreshing, setIsRefreshing] = useState(false);
const startY = useRef(0)
const currentY = useRef(0)
const startY = useRef(0);
const currentY = useRef(0);
const handleRefresh = useCallback(async () => {
if (onRefresh) {
setIsRefreshing(true)
setIsRefreshing(true);
try {
await onRefresh()
await onRefresh();
} finally {
setIsRefreshing(false)
setIsRefreshing(false);
}
} else {
// Default: reload the page
window.location.reload()
window.location.reload();
}
}, [onRefresh])
}, [onRefresh]);
useEffect(() => {
if (disabled) return
if (disabled) return;
// Check if a modal/overlay is currently open
const isModalOpen = (): boolean => {
// 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)
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) {
const style = window.getComputedStyle(el)
const zIndex = parseInt(style.zIndex, 10)
const style = window.getComputedStyle(el);
const zIndex = parseInt(style.zIndex, 10);
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
const isInsideScrollableContainer = (element: HTMLElement | null): boolean => {
while (element && element !== document.body) {
const style = window.getComputedStyle(element)
const overflowY = style.overflowY
const isScrollable = overflowY === 'auto' || overflowY === 'scroll'
const style = window.getComputedStyle(element);
const overflowY = style.overflowY;
const isScrollable = overflowY === 'auto' || overflowY === 'scroll';
if (isScrollable && element.scrollHeight > element.clientHeight) {
// Element is scrollable - check if it's at the top
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
}
return false
element = element.parentElement;
}
return false;
};
const handleTouchStart = (e: TouchEvent) => {
// Don't trigger if a modal is open
if (isModalOpen()) return
if (isModalOpen()) return;
// 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
const target = e.target as HTMLElement
if (isInsideScrollableContainer(target)) return
const target = e.target as HTMLElement;
if (isInsideScrollableContainer(target)) return;
startY.current = e.touches[0].clientY
currentY.current = e.touches[0].clientY
}
startY.current = e.touches[0].clientY;
currentY.current = e.touches[0].clientY;
};
const handleTouchMove = (e: TouchEvent) => {
if (startY.current === 0) return
if (startY.current === 0) return;
// Cancel if modal opened during gesture
if (isModalOpen()) {
startY.current = 0
setPullDistance(0)
setIsPulling(false)
return
startY.current = 0;
setPullDistance(0);
setIsPulling(false);
return;
}
if (window.scrollY > 5) {
// User scrolled down, reset
startY.current = 0
setPullDistance(0)
setIsPulling(false)
return
startY.current = 0;
setPullDistance(0);
setIsPulling(false);
return;
}
// 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)) {
startY.current = 0
setPullDistance(0)
setIsPulling(false)
return
startY.current = 0;
setPullDistance(0);
setIsPulling(false);
return;
}
currentY.current = e.touches[0].clientY
const diff = currentY.current - startY.current
currentY.current = e.touches[0].clientY;
const diff = currentY.current - startY.current;
// Only track downward pulls
if (diff > 0) {
// Apply resistance - pull distance is less than actual finger movement
const resistance = 0.4
const distance = Math.min(diff * resistance, threshold * 1.5)
setPullDistance(distance)
setIsPulling(true)
const resistance = 0.4;
const distance = Math.min(diff * resistance, threshold * 1.5);
setPullDistance(distance);
setIsPulling(true);
// Prevent default scroll if we're pulling
if (distance > 10) {
e.preventDefault()
}
e.preventDefault();
}
}
};
const handleTouchEnd = () => {
if (pullDistance >= threshold && !isRefreshing) {
handleRefresh()
handleRefresh();
}
startY.current = 0
setPullDistance(0)
setIsPulling(false)
}
startY.current = 0;
setPullDistance(0);
setIsPulling(false);
};
document.addEventListener('touchstart', handleTouchStart, { passive: true })
document.addEventListener('touchmove', handleTouchMove, { passive: false })
document.addEventListener('touchend', handleTouchEnd, { passive: true })
document.addEventListener('touchstart', handleTouchStart, { passive: true });
document.addEventListener('touchmove', handleTouchMove, { passive: false });
document.addEventListener('touchend', handleTouchEnd, { passive: true });
return () => {
document.removeEventListener('touchstart', handleTouchStart)
document.removeEventListener('touchmove', handleTouchMove)
document.removeEventListener('touchend', handleTouchEnd)
}
}, [disabled, threshold, pullDistance, isRefreshing, handleRefresh])
document.removeEventListener('touchstart', handleTouchStart);
document.removeEventListener('touchmove', handleTouchMove);
document.removeEventListener('touchend', handleTouchEnd);
};
}, [disabled, threshold, pullDistance, isRefreshing, handleRefresh]);
return {
isPulling,
pullDistance,
isRefreshing,
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
export const getCachedFullscreenEnabled = (): boolean => {
try {
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true'
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true';
} catch {
return false
}
return false;
}
};
// Set cached fullscreen setting
export const setCachedFullscreenEnabled = (enabled: boolean) => {
try {
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled))
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled));
} catch {
// localStorage not available
}
}
};
/**
* Hook for Telegram WebApp API integration
@@ -26,102 +26,112 @@ export const setCachedFullscreenEnabled = (enabled: boolean) => {
*/
export function useTelegramWebApp() {
// 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 [isTelegramWebApp, setIsTelegramWebApp] = useState(() => !!webApp)
const [safeAreaInset, setSafeAreaInset] = useState(() => webApp?.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
const [contentSafeAreaInset, setContentSafeAreaInset] = useState(() => webApp?.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
const [isFullscreen, setIsFullscreen] = useState(() => webApp?.isFullscreen || false);
const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => !!webApp);
const [safeAreaInset, setSafeAreaInset] = useState(
() => 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(() => {
if (!webApp) {
setIsTelegramWebApp(false)
return
setIsTelegramWebApp(false);
return;
}
setIsTelegramWebApp(true)
setIsFullscreen(webApp.isFullscreen || false)
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
setIsTelegramWebApp(true);
setIsFullscreen(webApp.isFullscreen || false);
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
setContentSafeAreaInset(
webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
);
// Expand WebApp to full height
webApp.expand()
webApp.ready()
webApp.expand();
webApp.ready();
// Listen for fullscreen changes
const handleFullscreenChanged = () => {
setIsFullscreen(webApp.isFullscreen || false)
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
}
setIsFullscreen(webApp.isFullscreen || false);
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
setContentSafeAreaInset(
webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
);
};
// Listen for safe area changes
const handleSafeAreaChanged = () => {
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
setContentSafeAreaInset(webApp.contentSafeAreaInset || { 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 },
);
};
webApp.onEvent('fullscreenChanged', handleFullscreenChanged)
webApp.onEvent('safeAreaChanged', handleSafeAreaChanged)
webApp.onEvent('contentSafeAreaChanged', handleSafeAreaChanged)
webApp.onEvent('fullscreenChanged', handleFullscreenChanged);
webApp.onEvent('safeAreaChanged', handleSafeAreaChanged);
webApp.onEvent('contentSafeAreaChanged', handleSafeAreaChanged);
return () => {
webApp.offEvent('fullscreenChanged', handleFullscreenChanged)
webApp.offEvent('safeAreaChanged', handleSafeAreaChanged)
webApp.offEvent('contentSafeAreaChanged', handleSafeAreaChanged)
}
}, [webApp])
webApp.offEvent('fullscreenChanged', handleFullscreenChanged);
webApp.offEvent('safeAreaChanged', handleSafeAreaChanged);
webApp.offEvent('contentSafeAreaChanged', handleSafeAreaChanged);
};
}, [webApp]);
const requestFullscreen = useCallback(() => {
if (webApp?.requestFullscreen) {
try {
webApp.requestFullscreen()
webApp.requestFullscreen();
} catch (e) {
console.warn('Fullscreen not supported:', e)
console.warn('Fullscreen not supported:', e);
}
}
}, [webApp])
}, [webApp]);
const exitFullscreen = useCallback(() => {
if (webApp?.exitFullscreen) {
try {
webApp.exitFullscreen()
webApp.exitFullscreen();
} catch (e) {
console.warn('Exit fullscreen failed:', e)
console.warn('Exit fullscreen failed:', e);
}
}
}, [webApp])
}, [webApp]);
const toggleFullscreen = useCallback(() => {
if (isFullscreen) {
exitFullscreen()
exitFullscreen();
} else {
requestFullscreen()
requestFullscreen();
}
}, [isFullscreen, requestFullscreen, exitFullscreen])
}, [isFullscreen, requestFullscreen, exitFullscreen]);
const disableVerticalSwipes = useCallback(() => {
try {
if (webApp?.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
webApp.disableVerticalSwipes()
webApp.disableVerticalSwipes();
}
} catch {
// Not supported in this version
}
}, [webApp])
}, [webApp]);
const enableVerticalSwipes = useCallback(() => {
try {
if (webApp?.enableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
webApp.enableVerticalSwipes()
webApp.enableVerticalSwipes();
}
} catch {
// Not supported in this version
}
}, [webApp])
}, [webApp]);
// Check if fullscreen is supported (Bot API 8.0+)
const isFullscreenSupported = Boolean(webApp?.requestFullscreen)
const isFullscreenSupported = Boolean(webApp?.requestFullscreen);
return {
isTelegramWebApp,
@@ -135,7 +145,7 @@ export function useTelegramWebApp() {
disableVerticalSwipes,
enableVerticalSwipes,
webApp,
}
};
}
/**
@@ -143,27 +153,27 @@ export function useTelegramWebApp() {
* Call this in main.tsx or App.tsx
*/
export function initTelegramWebApp() {
const webApp = window.Telegram?.WebApp
const webApp = window.Telegram?.WebApp;
if (webApp) {
webApp.ready()
webApp.expand()
webApp.ready();
webApp.expand();
// Disable vertical swipes to prevent accidental closing (requires Bot API 7.7+)
try {
if (webApp.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
webApp.disableVerticalSwipes()
webApp.disableVerticalSwipes();
}
} catch {
// Swipe control not supported in this version
}
// 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) {
try {
webApp.requestFullscreen()
webApp.requestFullscreen();
} 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 { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme'
import { themeColorsApi } from '../api/themeColors'
import { useState, useEffect, useCallback } from 'react';
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme';
import { themeColorsApi } from '../api/themeColors';
type Theme = 'dark' | 'light'
type Theme = 'dark' | 'light';
const THEME_KEY = 'cabinet-theme'
const ENABLED_THEMES_KEY = 'cabinet-enabled-themes'
const THEME_KEY = 'cabinet-theme';
const ENABLED_THEMES_KEY = 'cabinet-enabled-themes';
// Fetch enabled themes from API
async function fetchEnabledThemes(): Promise<EnabledThemes> {
try {
const data = await themeColorsApi.getEnabledThemes()
const data = await themeColorsApi.getEnabledThemes();
// Cache in localStorage for faster subsequent loads
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(data))
return data
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(data));
return data;
} catch {
// Ignore errors, use cached or default
}
// Try to get from cache
const cached = localStorage.getItem(ENABLED_THEMES_KEY)
const cached = localStorage.getItem(ENABLED_THEMES_KEY);
if (cached) {
try {
return JSON.parse(cached)
return JSON.parse(cached);
} catch {
// Ignore parse errors
}
}
return DEFAULT_ENABLED_THEMES
return DEFAULT_ENABLED_THEMES;
}
// Get cached enabled themes synchronously
function getCachedEnabledThemes(): EnabledThemes {
const cached = localStorage.getItem(ENABLED_THEMES_KEY)
const cached = localStorage.getItem(ENABLED_THEMES_KEY);
if (cached) {
try {
return JSON.parse(cached)
return JSON.parse(cached);
} catch {
// Ignore parse errors
}
}
return DEFAULT_ENABLED_THEMES
return DEFAULT_ENABLED_THEMES;
}
// Custom event for same-tab updates
const ENABLED_THEMES_CHANGED_EVENT = 'enabledThemesChanged'
const ENABLED_THEMES_CHANGED_EVENT = 'enabledThemesChanged';
// Update cache (called from admin settings)
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
window.dispatchEvent(new CustomEvent(ENABLED_THEMES_CHANGED_EVENT, { detail: themes }))
window.dispatchEvent(new CustomEvent(ENABLED_THEMES_CHANGED_EVENT, { detail: themes }));
}
export function useTheme() {
const [enabledThemes, setEnabledThemes] = useState<EnabledThemes>(getCachedEnabledThemes)
const [isLoading, setIsLoading] = useState(true)
const [enabledThemes, setEnabledThemes] = useState<EnabledThemes>(getCachedEnabledThemes);
const [isLoading, setIsLoading] = useState(true);
const [theme, setThemeState] = useState<Theme>(() => {
const enabled = getCachedEnabledThemes()
const enabled = getCachedEnabledThemes();
// Check localStorage first
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) {
return 'light'
return 'light';
}
if (stored === 'dark' && enabled.dark) {
return 'dark'
return 'dark';
}
// If stored theme is disabled, use the enabled one
if (stored && !enabled[stored]) {
return enabled.dark ? 'dark' : 'light'
return enabled.dark ? 'dark' : 'light';
}
// Check system preference
if (window.matchMedia('(prefers-color-scheme: light)').matches && enabled.light) {
return 'light'
return 'light';
}
}
// Default to dark if enabled, otherwise light
return enabled.dark ? 'dark' : 'light'
})
return enabled.dark ? 'dark' : 'light';
});
// Fetch enabled themes on mount
useEffect(() => {
fetchEnabledThemes().then((data) => {
setEnabledThemes(data)
setIsLoading(false)
setEnabledThemes(data);
setIsLoading(false);
// If current theme is disabled, switch to enabled one
if (!data[theme]) {
const newTheme = data.dark ? 'dark' : 'light'
setThemeState(newTheme)
const newTheme = data.dark ? 'dark' : 'light';
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)
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === ENABLED_THEMES_KEY && e.newValue) {
try {
const data = JSON.parse(e.newValue) as EnabledThemes
setEnabledThemes(data)
const data = JSON.parse(e.newValue) as EnabledThemes;
setEnabledThemes(data);
// If current theme is now disabled, switch to enabled one
if (!data[theme]) {
const newTheme = data.dark ? 'dark' : 'light'
setThemeState(newTheme)
const newTheme = data.dark ? 'dark' : 'light';
setThemeState(newTheme);
}
} catch {
// Ignore parse errors
}
}
}
};
window.addEventListener('storage', handleStorageChange)
return () => window.removeEventListener('storage', handleStorageChange)
}, [theme])
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [theme]);
// Listen for same-tab enabled themes changes (from admin settings)
useEffect(() => {
const handleEnabledThemesChange = (e: CustomEvent<EnabledThemes>) => {
const data = e.detail
setEnabledThemes(data)
const data = e.detail;
setEnabledThemes(data);
// If current theme is now disabled, switch to enabled one
if (!data[theme]) {
const newTheme = data.dark ? 'dark' : 'light'
setThemeState(newTheme)
}
const newTheme = data.dark ? 'dark' : 'light';
setThemeState(newTheme);
}
};
window.addEventListener(ENABLED_THEMES_CHANGED_EVENT, handleEnabledThemesChange as EventListener)
return () => window.removeEventListener(ENABLED_THEMES_CHANGED_EVENT, handleEnabledThemesChange as EventListener)
}, [theme])
window.addEventListener(
ENABLED_THEMES_CHANGED_EVENT,
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
useEffect(() => {
const root = document.documentElement
const root = document.documentElement;
// If current theme is disabled, switch to the enabled one
if (!enabledThemes[theme]) {
const newTheme = enabledThemes.dark ? 'dark' : 'light'
const newTheme = enabledThemes.dark ? 'dark' : 'light';
if (newTheme !== theme) {
setThemeState(newTheme)
return // Will re-run with correct theme
setThemeState(newTheme);
return; // Will re-run with correct theme
}
}
if (theme === 'light') {
root.classList.remove('dark')
root.classList.add('light')
root.classList.remove('dark');
root.classList.add('light');
} else {
root.classList.remove('light')
root.classList.add('dark')
root.classList.remove('light');
root.classList.add('dark');
}
localStorage.setItem(THEME_KEY, theme)
}, [theme, enabledThemes])
localStorage.setItem(THEME_KEY, theme);
}, [theme, enabledThemes]);
// Listen for system theme changes
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)')
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)');
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
if (!stored) {
const newTheme = e.matches ? 'light' : 'dark'
const newTheme = e.matches ? 'light' : 'dark';
if (enabledThemes[newTheme]) {
setThemeState(newTheme)
}
setThemeState(newTheme);
}
}
};
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [enabledThemes])
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [enabledThemes]);
const setTheme = useCallback((newTheme: Theme) => {
const setTheme = useCallback(
(newTheme: Theme) => {
// Only allow setting if theme is enabled
if (enabledThemes[newTheme]) {
setThemeState(newTheme)
setThemeState(newTheme);
}
}, [enabledThemes])
},
[enabledThemes],
);
const toggleTheme = useCallback(() => {
setThemeState((prev) => {
const newTheme = prev === 'dark' ? 'light' : 'dark'
const newTheme = prev === 'dark' ? 'light' : 'dark';
// Only toggle if the new theme is enabled
if (enabledThemes[newTheme]) {
return newTheme
return newTheme;
}
return prev
})
}, [enabledThemes])
return prev;
});
}, [enabledThemes]);
const isDark = theme === 'dark'
const isLight = theme === 'light'
const isDark = theme === 'dark';
const isLight = theme === 'light';
// 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
const refreshEnabledThemes = useCallback(() => {
fetchEnabledThemes().then((data) => {
setEnabledThemes(data)
setEnabledThemes(data);
if (!data[theme]) {
const newTheme = data.dark ? 'dark' : 'light'
setThemeState(newTheme)
const newTheme = data.dark ? 'dark' : 'light';
setThemeState(newTheme);
}
})
}, [theme])
});
}, [theme]);
return {
theme,
@@ -220,5 +230,5 @@ export function useTheme() {
canToggle,
isLoading,
refreshEnabledThemes,
}
};
}

View File

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

View File

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

View File

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

View File

@@ -1,20 +1,20 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import { ThemeColorsProvider } from './providers/ThemeColorsProvider'
import { ToastProvider } from './components/Toast'
import { initLogoPreload } from './api/branding'
import { initTelegramWebApp } from './hooks/useTelegramWebApp'
import './i18n'
import './styles/globals.css'
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import { ThemeColorsProvider } from './providers/ThemeColorsProvider';
import { ToastProvider } from './components/Toast';
import { initLogoPreload } from './api/branding';
import { initTelegramWebApp } from './hooks/useTelegramWebApp';
import './i18n';
import './styles/globals.css';
// Initialize Telegram WebApp (expand, disable swipes)
initTelegramWebApp()
initTelegramWebApp();
// Preload logo from cache immediately on page load
initLogoPreload()
initLogoPreload();
const queryClient = new QueryClient({
defaultOptions: {
@@ -23,7 +23,7 @@ const queryClient = new QueryClient({
refetchOnWindowFocus: false,
},
},
})
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
@@ -37,4 +37,4 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</BrowserRouter>
</QueryClientProvider>
</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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { useState, useRef, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import {
adminBroadcastsApi,
Broadcast,
BroadcastFilter,
TariffFilter,
BroadcastCreateRequest,
} from '../api/adminBroadcasts'
} from '../api/adminBroadcasts';
// Icons
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" />
</svg>
)
);
const BroadcastIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
/>
</svg>
)
);
const PlusIcon = () => (
<svg className="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" />
</svg>
)
);
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" />
</svg>
)
);
const RefreshIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
)
);
const StopIcon = () => (
<svg className="w-4 h-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" />
<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"
/>
</svg>
)
);
const PhotoIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</svg>
)
);
const VideoIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z"
/>
</svg>
)
);
const DocumentIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
)
);
const UsersIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
)
);
const ChevronDownIcon = () => (
<svg className="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" />
</svg>
)
);
// Status badge component
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: 'Ошибка' },
cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-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 (
<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}
</span>
)
);
}
// Filter labels
@@ -105,201 +139,203 @@ const FILTER_GROUP_LABELS: Record<string, string> = {
activity: 'По активности',
source: 'По источнику',
tariff: 'По тарифу',
}
};
// Create broadcast modal
interface CreateModalProps {
onClose: () => void
onSuccess: () => void
onClose: () => void;
onSuccess: () => void;
}
function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const fileInputRef = useRef<HTMLInputElement>(null)
const { t } = useTranslation();
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [target, setTarget] = useState('')
const [messageText, setMessageText] = useState('')
const [selectedButtons, setSelectedButtons] = useState<string[]>(['home'])
const [mediaFile, setMediaFile] = useState<File | null>(null)
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo')
const [mediaPreview, setMediaPreview] = useState<string | null>(null)
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null)
const [isUploading, setIsUploading] = useState(false)
const [showFilters, setShowFilters] = useState(false)
const [target, setTarget] = useState('');
const [messageText, setMessageText] = useState('');
const [selectedButtons, setSelectedButtons] = useState<string[]>(['home']);
const [mediaFile, setMediaFile] = useState<File | null>(null);
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo');
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [showFilters, setShowFilters] = useState(false);
// Fetch filters
const { data: filtersData, isLoading: filtersLoading } = useQuery({
queryKey: ['admin', 'broadcasts', 'filters'],
queryFn: adminBroadcastsApi.getFilters,
})
});
// Fetch buttons
const { data: buttonsData } = useQuery({
queryKey: ['admin', 'broadcasts', 'buttons'],
queryFn: adminBroadcastsApi.getButtons,
})
});
// Preview mutation
const previewMutation = useMutation({
mutationFn: adminBroadcastsApi.preview,
})
});
// Create mutation
const createMutation = useMutation({
mutationFn: adminBroadcastsApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] })
onSuccess()
onClose()
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
onSuccess();
onClose();
},
})
});
// Group filters
const groupedFilters = useMemo(() => {
if (!filtersData) return {}
const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {}
if (!filtersData) return {};
const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {};
// Basic filters
filtersData.filters.forEach(f => {
const group = f.group || 'basic'
if (!groups[group]) groups[group] = []
groups[group].push(f)
})
filtersData.filters.forEach((f) => {
const group = f.group || 'basic';
if (!groups[group]) groups[group] = [];
groups[group].push(f);
});
// Tariff filters
if (filtersData.tariff_filters.length > 0) {
groups['tariff'] = filtersData.tariff_filters
groups['tariff'] = filtersData.tariff_filters;
}
// Custom filters
filtersData.custom_filters.forEach(f => {
const group = f.group || 'custom'
if (!groups[group]) groups[group] = []
groups[group].push(f)
})
filtersData.custom_filters.forEach((f) => {
const group = f.group || 'custom';
if (!groups[group]) groups[group] = [];
groups[group].push(f);
});
return groups
}, [filtersData])
return groups;
}, [filtersData]);
// Selected filter info
const selectedFilter = useMemo(() => {
if (!target || !filtersData) return null
if (!target || !filtersData) return null;
const all = [
...filtersData.filters,
...filtersData.tariff_filters,
...filtersData.custom_filters,
]
return all.find(f => f.key === target)
}, [target, filtersData])
];
return all.find((f) => f.key === target);
}, [target, filtersData]);
// Handle file selection
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
const file = e.target.files?.[0];
if (!file) return;
setMediaFile(file)
setMediaFile(file);
// Determine media type
if (file.type.startsWith('image/')) {
setMediaType('photo')
setMediaPreview(URL.createObjectURL(file))
setMediaType('photo');
setMediaPreview(URL.createObjectURL(file));
} else if (file.type.startsWith('video/')) {
setMediaType('video')
setMediaPreview(null)
setMediaType('video');
setMediaPreview(null);
} else {
setMediaType('document')
setMediaPreview(null)
setMediaType('document');
setMediaPreview(null);
}
// Upload file
setIsUploading(true)
setIsUploading(true);
try {
const result = await adminBroadcastsApi.uploadMedia(file, mediaType)
setUploadedFileId(result.file_id)
const result = await adminBroadcastsApi.uploadMedia(file, mediaType);
setUploadedFileId(result.file_id);
} catch (err) {
console.error('Upload failed:', err)
setMediaFile(null)
setMediaPreview(null)
console.error('Upload failed:', err);
setMediaFile(null);
setMediaPreview(null);
} finally {
setIsUploading(false)
}
setIsUploading(false);
}
};
// Remove media
const handleRemoveMedia = () => {
setMediaFile(null)
setMediaPreview(null)
setUploadedFileId(null)
setMediaFile(null);
setMediaPreview(null);
setUploadedFileId(null);
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
fileInputRef.current.value = '';
}
};
// Toggle button
const toggleButton = (key: string) => {
setSelectedButtons(prev =>
prev.includes(key) ? prev.filter(b => b !== key) : [...prev, key]
)
}
setSelectedButtons((prev) =>
prev.includes(key) ? prev.filter((b) => b !== key) : [...prev, key],
);
};
// Submit
const handleSubmit = () => {
if (!target || !messageText.trim()) return
if (!target || !messageText.trim()) return;
const data: BroadcastCreateRequest = {
target,
message_text: messageText,
selected_buttons: selectedButtons,
}
};
if (uploadedFileId) {
data.media = {
type: mediaType,
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 (
<div className="fixed inset-0 flex items-center justify-center z-50 p-4 overflow-y-auto">
<div className="bg-dark-800 rounded-xl w-full max-w-2xl my-4">
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4">
<div className="my-4 w-full max-w-2xl rounded-xl bg-dark-800">
{/* 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="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 />
</div>
<h2 className="text-lg font-semibold text-dark-100">{t('admin.broadcasts.create')}</h2>
</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 />
</button>
</div>
{/* 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 */}
<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')}
</label>
<div className="relative">
<button
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">
<UsersIcon />
<span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}>
{selectedFilter ? selectedFilter.label : t('admin.broadcasts.selectFilterPlaceholder')}
{selectedFilter
? selectedFilter.label
: t('admin.broadcasts.selectFilterPlaceholder')}
</span>
{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')}
</span>
)}
@@ -308,24 +344,24 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
</button>
{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 ? (
<div className="p-4 text-center text-dark-400">Loading...</div>
) : (
Object.entries(groupedFilters).map(([group, filters]) => (
<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}
</div>
{filters.map(filter => (
{filters.map((filter) => (
<button
key={filter.key}
onClick={() => {
setTarget(filter.key)
setShowFilters(false)
previewMutation.mutate(filter.key)
setTarget(filter.key);
setShowFilters(false);
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' : ''
}`}
>
@@ -345,29 +381,27 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
{/* Message text */}
<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')}
</label>
<textarea
value={messageText}
onChange={e => setMessageText(e.target.value)}
onChange={(e) => setMessageText(e.target.value)}
placeholder={t('admin.broadcasts.messageTextPlaceholder')}
rows={5}
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">
{messageText.length}/4000
</div>
<div className="mt-1 text-right text-xs text-dark-400">{messageText.length}/4000</div>
</div>
{/* Media upload */}
<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')}
</label>
{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 gap-3">
{mediaType === 'photo' && <PhotoIcon />}
@@ -382,7 +416,7 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
</div>
<button
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}
>
<XIcon />
@@ -396,7 +430,9 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
/>
)}
{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>
) : (
@@ -410,7 +446,7 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
/>
<button
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 />
<span>{t('admin.broadcasts.addMedia')}</span>
@@ -421,15 +457,15 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
{/* Buttons selection */}
<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')}
</label>
<div className="flex flex-wrap gap-2">
{buttonsData?.buttons.map(button => (
{buttonsData?.buttons.map((button) => (
<button
key={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)
? 'bg-accent-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
@@ -443,76 +479,74 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
</div>
{/* 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">
{recipientsCount !== null && (
<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>
)}
</div>
<div className="flex gap-2">
<button
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')}
</button>
<button
onClick={handleSubmit}
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 ? (
<RefreshIcon />
) : (
<BroadcastIcon />
)}
{createMutation.isPending ? <RefreshIcon /> : <BroadcastIcon />}
{t('admin.broadcasts.send')}
</button>
</div>
</div>
</div>
</div>
)
);
}
// Broadcast detail modal
interface DetailModalProps {
broadcast: Broadcast
onClose: () => void
onStop: () => void
isStopping: boolean
broadcast: Broadcast;
onClose: () => void;
onStop: () => void;
isStopping: boolean;
}
function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: DetailModalProps) {
const { t } = useTranslation()
const isRunning = ['queued', 'in_progress', 'cancelling'].includes(broadcast.status)
const { t } = useTranslation();
const isRunning = ['queued', 'in_progress', 'cancelling'].includes(broadcast.status);
return (
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-lg">
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="w-full max-w-lg rounded-xl bg-dark-800">
{/* 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">
<StatusBadge status={broadcast.status} />
<span className="text-dark-400">#{broadcast.id}</span>
</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 />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-4">
<div className="space-y-4 p-4">
{/* Progress */}
{isRunning && (
<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-100">{broadcast.progress_percent.toFixed(1)}%</span>
</div>
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
<div className="h-2 overflow-hidden rounded-full bg-dark-700">
<div
className="h-full bg-accent-500 transition-all duration-300"
style={{ width: `${broadcast.progress_percent}%` }}
@@ -523,15 +557,15 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
{/* Stats */}
<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-xs text-dark-400">{t('admin.broadcasts.total')}</p>
</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-xs text-dark-400">{t('admin.broadcasts.sent')}</p>
</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-xs text-dark-400">{t('admin.broadcasts.failed')}</p>
</div>
@@ -539,14 +573,14 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
{/* Target */}
<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>
</div>
{/* Message */}
<div>
<p className="text-sm text-dark-400 mb-1">{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">
<p className="mb-1 text-sm text-dark-400">{t('admin.broadcasts.message')}</p>
<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}
</div>
</div>
@@ -554,7 +588,7 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
{/* Media */}
{broadcast.has_media && (
<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">
{broadcast.media_type === 'photo' && <PhotoIcon />}
{broadcast.media_type === 'video' && <VideoIcon />}
@@ -573,11 +607,11 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
{/* Footer */}
{isRunning && broadcast.status !== 'cancelling' && (
<div className="p-4 border-t border-dark-700">
<div className="border-t border-dark-700 p-4">
<button
onClick={onStop}
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 />
{t('admin.broadcasts.stop')}
@@ -586,48 +620,48 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
)}
</div>
</div>
)
);
}
// Main component
export default function AdminBroadcasts() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { t } = useTranslation();
const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false)
const [selectedBroadcast, setSelectedBroadcast] = useState<Broadcast | null>(null)
const [page, setPage] = useState(0)
const limit = 20
const [showCreateModal, setShowCreateModal] = useState(false);
const [selectedBroadcast, setSelectedBroadcast] = useState<Broadcast | null>(null);
const [page, setPage] = useState(0);
const limit = 20;
// Fetch broadcasts
const { data, isLoading, refetch } = useQuery({
queryKey: ['admin', 'broadcasts', 'list', page],
queryFn: () => adminBroadcastsApi.list(limit, page * limit),
refetchInterval: 5000, // Auto refresh every 5s
})
});
// Stop mutation
const stopMutation = useMutation({
mutationFn: adminBroadcastsApi.stop,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] })
setSelectedBroadcast(null)
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
setSelectedBroadcast(null);
},
})
});
const broadcasts = data?.items || []
const total = data?.total || 0
const totalPages = Math.ceil(total / limit)
const broadcasts = data?.items || [];
const total = data?.total || 0;
const totalPages = Math.ceil(total / limit);
return (
<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 */}
<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">
<Link
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 />
</Link>
@@ -639,13 +673,13 @@ export default function AdminBroadcasts() {
<div className="flex gap-2">
<button
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 />
</button>
<button
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 />
<span className="hidden sm:inline">{t('admin.broadcasts.create')}</span>
@@ -654,7 +688,7 @@ export default function AdminBroadcasts() {
</div>
{/* Broadcasts list */}
<div className="bg-dark-800 rounded-xl overflow-hidden">
<div className="overflow-hidden rounded-xl bg-dark-800">
{isLoading ? (
<div className="p-8 text-center text-dark-400">
<RefreshIcon />
@@ -667,15 +701,15 @@ export default function AdminBroadcasts() {
</div>
) : (
<div className="divide-y divide-dark-700">
{broadcasts.map(broadcast => (
{broadcasts.map((broadcast) => (
<button
key={broadcast.id}
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-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<div className="min-w-0 flex-1">
<div className="mb-1 flex items-center gap-2">
<StatusBadge status={broadcast.status} />
<span className="text-xs text-dark-400">#{broadcast.id}</span>
{broadcast.has_media && (
@@ -686,10 +720,8 @@ export default function AdminBroadcasts() {
</span>
)}
</div>
<p className="text-dark-100 text-sm truncate">
{broadcast.message_text}
</p>
<div className="flex items-center gap-4 mt-2 text-xs text-dark-400">
<p className="truncate text-sm text-dark-100">{broadcast.message_text}</p>
<div className="mt-2 flex items-center gap-4 text-xs text-dark-400">
<span>{broadcast.target_type}</span>
<span>
{broadcast.sent_count}/{broadcast.total_count}
@@ -699,13 +731,13 @@ export default function AdminBroadcasts() {
</div>
{['queued', 'in_progress'].includes(broadcast.status) && (
<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
className="h-full bg-accent-500"
style={{ width: `${broadcast.progress_percent}%` }}
/>
</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)}%
</p>
</div>
@@ -718,11 +750,11 @@ export default function AdminBroadcasts() {
{/* Pagination */}
{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
onClick={() => setPage(p => Math.max(0, p - 1))}
onClick={() => setPage((p) => Math.max(0, p - 1))}
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')}
</button>
@@ -730,9 +762,9 @@ export default function AdminBroadcasts() {
{page + 1} / {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 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')}
</button>
@@ -746,7 +778,7 @@ export default function AdminBroadcasts() {
<CreateBroadcastModal
onClose={() => setShowCreateModal(false)}
onSuccess={() => {
refetch()
refetch();
}}
/>
)}
@@ -760,5 +792,5 @@ export default function AdminBroadcasts() {
/>
)}
</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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom'
import { useState, useCallback, useRef, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import {
adminEmailTemplatesApi,
EmailTemplateType,
EmailTemplateDetail,
EmailTemplateLanguageData,
} from '../api/adminEmailTemplates'
} from '../api/adminEmailTemplates';
// Icons
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" />
</svg>
)
);
const MailIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
/>
</svg>
)
);
const SaveIcon = () => (
<svg className="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" />
</svg>
)
);
const EyeIcon = () => (
<svg className="w-4 h-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" />
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
);
const SendIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
/>
</svg>
)
);
const ResetIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
)
);
const 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" />
</svg>
)
);
const LANG_LABELS: Record<string, string> = {
ru: 'RU',
en: 'EN',
zh: 'ZH',
ua: 'UA',
}
};
const LANG_FULL_LABELS: Record<string, string> = {
ru: 'Русский',
en: 'English',
zh: '中文',
ua: 'Українська',
}
};
// ============ Template List View ============
@@ -74,31 +96,31 @@ function TemplateCard({
currentLang,
onClick,
}: {
template: EmailTemplateType
currentLang: string
onClick: () => void
template: EmailTemplateType;
currentLang: string;
onClick: () => void;
}) {
const label = template.label[currentLang] || template.label['en'] || template.type
const description = template.description[currentLang] || template.description['en'] || ''
const customCount = Object.values(template.languages).filter(l => l.has_custom).length
const label = template.label[currentLang] || template.label['en'] || template.type;
const description = template.description[currentLang] || template.description['en'] || '';
const customCount = Object.values(template.languages).filter((l) => l.has_custom).length;
return (
<button
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-1 min-w-0">
<h3 className="text-sm font-medium text-dark-100 group-hover:text-accent-400 transition-colors truncate">
<div className="min-w-0 flex-1">
<h3 className="truncate text-sm font-medium text-dark-100 transition-colors group-hover:text-accent-400">
{label}
</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 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]) => (
<span
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
? 'bg-accent-500/20 text-accent-400 ring-1 ring-accent-500/30'
: 'bg-dark-700 text-dark-400'
@@ -112,14 +134,14 @@ function TemplateCard({
</div>
{customCount > 0 && (
<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="w-1.5 h-1.5 rounded-full bg-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="h-1.5 w-1.5 rounded-full bg-accent-400" />
{customCount} custom
</span>
</div>
)}
</button>
)
);
}
// ============ Template Editor ============
@@ -129,225 +151,249 @@ function TemplateEditor({
onClose,
currentLang: interfaceLang,
}: {
detail: EmailTemplateDetail
onClose: () => void
currentLang: string
detail: EmailTemplateDetail;
onClose: () => void;
currentLang: string;
}) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [activeLang, setActiveLang] = useState('ru')
const [editSubject, setEditSubject] = useState('')
const [editBody, setEditBody] = useState('')
const [isDirty, setIsDirty] = useState(false)
const [showPreview, setShowPreview] = useState(false)
const [previewHtml, setPreviewHtml] = useState('')
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const iframeRef = useRef<HTMLIFrameElement>(null)
const { t } = useTranslation();
const queryClient = useQueryClient();
const [activeLang, setActiveLang] = useState('ru');
const [editSubject, setEditSubject] = useState('');
const [editBody, setEditBody] = useState('');
const [isDirty, setIsDirty] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [previewHtml, setPreviewHtml] = useState('');
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(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
useEffect(() => {
if (langData) {
setEditSubject(langData.subject)
setEditBody(langData.is_default ? langData.body_html : langData.body_html)
setIsDirty(false)
setEditSubject(langData.subject);
setEditBody(langData.is_default ? langData.body_html : langData.body_html);
setIsDirty(false);
}
}, [activeLang, langData])
}, [activeLang, langData]);
const showToast = useCallback((type: 'success' | 'error', message: string) => {
setToast({ type, message })
setTimeout(() => setToast(null), 3000)
}, [])
setToast({ type, message });
setTimeout(() => setToast(null), 3000);
}, []);
// Extract body content from full HTML (strip base template wrapper)
const extractBodyContent = useCallback((html: string): string => {
// 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) {
return contentMatch[1].trim()
return contentMatch[1].trim();
}
return html
}, [])
return html;
}, []);
// When langData changes (e.g., after refetch), update the body content
useEffect(() => {
if (langData) {
if (langData.is_default) {
// For default templates, extract just the content portion
setEditBody(extractBodyContent(langData.body_html))
setEditBody(extractBodyContent(langData.body_html));
} else {
setEditBody(langData.body_html)
setEditBody(langData.body_html);
}
setEditSubject(langData.subject)
setIsDirty(false)
setEditSubject(langData.subject);
setIsDirty(false);
}
}, [activeLang, langData, extractBodyContent])
}, [activeLang, langData, extractBodyContent]);
// Save mutation
const saveMutation = useMutation({
mutationFn: () => adminEmailTemplatesApi.updateTemplate(detail.notification_type, activeLang, {
mutationFn: () =>
adminEmailTemplatesApi.updateTemplate(detail.notification_type, activeLang, {
subject: editSubject,
body_html: editBody,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] })
queryClient.invalidateQueries({ queryKey: ['admin', 'email-template', detail.notification_type] })
setIsDirty(false)
showToast('success', t('admin.emailTemplates.saved', 'Template saved'))
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] });
queryClient.invalidateQueries({
queryKey: ['admin', 'email-template', detail.notification_type],
});
setIsDirty(false);
showToast('success', t('admin.emailTemplates.saved', 'Template saved'));
},
onError: () => {
showToast('error', t('common.error', 'Error'))
showToast('error', t('common.error', 'Error'));
},
})
});
// Reset mutation
const resetMutation = useMutation({
mutationFn: () => adminEmailTemplatesApi.deleteTemplate(detail.notification_type, activeLang),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] })
queryClient.invalidateQueries({ queryKey: ['admin', 'email-template', detail.notification_type] })
setIsDirty(false)
showToast('success', t('admin.emailTemplates.resetted', 'Template reset to default'))
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] });
queryClient.invalidateQueries({
queryKey: ['admin', 'email-template', detail.notification_type],
});
setIsDirty(false);
showToast('success', t('admin.emailTemplates.resetted', 'Template reset to default'));
},
onError: () => {
showToast('error', t('common.error', 'Error'))
showToast('error', t('common.error', 'Error'));
},
})
});
// Preview mutation
const previewMutation = useMutation({
mutationFn: () => adminEmailTemplatesApi.previewTemplate(detail.notification_type, {
mutationFn: () =>
adminEmailTemplatesApi.previewTemplate(detail.notification_type, {
language: activeLang,
subject: editSubject,
body_html: editBody,
}),
onSuccess: (data) => {
setPreviewHtml(data.body_html)
setShowPreview(true)
setPreviewHtml(data.body_html);
setShowPreview(true);
},
onError: () => {
showToast('error', t('common.error', 'Error'))
showToast('error', t('common.error', 'Error'));
},
})
});
// Send test mutation
const testMutation = useMutation({
mutationFn: () => adminEmailTemplatesApi.sendTestEmail(detail.notification_type, {
mutationFn: () =>
adminEmailTemplatesApi.sendTestEmail(detail.notification_type, {
language: activeLang,
}),
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: () => {
showToast('error', t('common.error', 'Error'))
showToast('error', t('common.error', 'Error'));
},
})
});
// Write preview HTML into iframe
useEffect(() => {
if (showPreview && iframeRef.current && previewHtml) {
const doc = iframeRef.current.contentDocument
const doc = iframeRef.current.contentDocument;
if (doc) {
doc.open()
doc.write(previewHtml)
doc.close()
doc.open();
doc.write(previewHtml);
doc.close();
}
}
}, [showPreview, previewHtml])
}, [showPreview, previewHtml]);
const handleSubjectChange = (value: string) => {
setEditSubject(value)
setIsDirty(true)
}
setEditSubject(value);
setIsDirty(true);
};
const handleBodyChange = (value: string) => {
setEditBody(value)
setIsDirty(true)
}
setEditBody(value);
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 (
<div className="space-y-3 sm:space-y-4">
{/* Header */}
<div className="flex items-start sm:items-center justify-between gap-2">
<div className="flex items-start sm:items-center gap-2 sm:gap-3 min-w-0">
<button onClick={onClose} className="p-1 rounded-lg hover:bg-dark-700 transition-colors flex-shrink-0 mt-0.5 sm:mt-0">
<div className="flex items-start justify-between gap-2 sm:items-center">
<div className="flex min-w-0 items-start gap-2 sm:items-center sm:gap-3">
<button
onClick={onClose}
className="mt-0.5 flex-shrink-0 rounded-lg p-1 transition-colors hover:bg-dark-700 sm:mt-0"
>
<BackIcon />
</button>
<div className="min-w-0">
<h2 className="text-base sm:text-lg font-semibold text-dark-100 truncate">{label}</h2>
<p className="text-xs text-dark-400 line-clamp-2">
<h2 className="truncate text-base font-semibold text-dark-100 sm:text-lg">{label}</h2>
<p className="line-clamp-2 text-xs text-dark-400">
{detail.description[interfaceLang] || detail.description['en'] || ''}
</p>
</div>
</div>
{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
</span>
)}
</div>
{/* Language tabs */}
<div className="flex items-center gap-1 p-1 bg-dark-900 rounded-lg overflow-x-auto">
{Object.keys(detail.languages).map(lang => {
const isActive = lang === activeLang
const langInfo = detail.languages[lang]
<div className="flex items-center gap-1 overflow-x-auto rounded-lg bg-dark-900 p-1">
{Object.keys(detail.languages).map((lang) => {
const isActive = lang === activeLang;
const langInfo = detail.languages[lang];
return (
<button
key={lang}
onClick={() => {
if (isDirty && !window.confirm(t('admin.emailTemplates.unsavedWarning', 'Unsaved changes will be lost. Continue?'))) return
setActiveLang(lang)
if (
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
? '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="hidden sm:inline">{LANG_FULL_LABELS[lang] || lang}</span>
{!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>
)
);
})}
</div>
{/* Subject */}
<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')}
</label>
<input
type="text"
value={editSubject}
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"
onChange={(e) => handleSubjectChange(e.target.value)}
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...')}
/>
</div>
{/* Context variables hint */}
{detail.context_vars.length > 0 && (
<div className="p-2.5 sm:p-3 bg-dark-900/60 border border-dark-700 rounded-lg">
<p className="text-xs font-medium text-dark-300 mb-1.5">
<div className="rounded-lg border border-dark-700 bg-dark-900/60 p-2.5 sm:p-3">
<p className="mb-1.5 text-xs font-medium text-dark-300">
{t('admin.emailTemplates.variables', 'Available Variables')}
</p>
<div className="flex flex-wrap gap-1 sm:gap-1.5">
{detail.context_vars.map(v => (
{detail.context_vars.map((v) => (
<code
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')}
onClick={() => {
navigator.clipboard.writeText(`{${v}}`)
showToast('success', `Copied {${v}}`)
navigator.clipboard.writeText(`{${v}}`);
showToast('success', `Copied {${v}}`);
}}
>
{`{${v}}`}
@@ -359,30 +405,33 @@ function TemplateEditor({
{/* Body HTML editor */}
<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)')}
</label>
<textarea
ref={textareaRef}
value={editBody}
onChange={e => handleBodyChange(e.target.value)}
onChange={(e) => handleBodyChange(e.target.value)}
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>"
spellCheck={false}
/>
<p className="text-2xs text-dark-500 mt-1">
{t('admin.emailTemplates.bodyHint', 'HTML content that will be wrapped in the base email template with header and footer.')}
<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.',
)}
</p>
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2">
<div className="grid grid-cols-2 sm:flex gap-2">
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
<div className="grid grid-cols-2 gap-2 sm:flex">
<button
onClick={() => saveMutation.mutate()}
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 />
{saveMutation.isPending ? t('common.loading', 'Loading...') : t('common.save', 'Save')}
@@ -391,35 +440,46 @@ function TemplateEditor({
<button
onClick={() => previewMutation.mutate()}
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 />
{t('admin.emailTemplates.preview', 'Preview')}
</button>
</div>
<div className="grid grid-cols-2 sm:flex gap-2">
<div className="grid grid-cols-2 gap-2 sm:flex">
<button
onClick={() => testMutation.mutate()}
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 />
{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>
{langData && !langData.is_default && (
<button
onClick={() => {
if (window.confirm(t('admin.emailTemplates.resetConfirm', 'Reset this template to the default version?'))) {
resetMutation.mutate()
if (
window.confirm(
t(
'admin.emailTemplates.resetConfirm',
'Reset this template to the default version?',
),
)
) {
resetMutation.mutate();
}
}}
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 />
<span className="truncate">{t('admin.emailTemplates.resetDefault', 'Reset to Default')}</span>
<span className="truncate">
{t('admin.emailTemplates.resetDefault', 'Reset to Default')}
</span>
</button>
)}
</div>
@@ -427,26 +487,26 @@ function TemplateEditor({
{/* 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 ${
toast.type === 'success'
? 'bg-emerald-500/90 text-white'
: 'bg-red-500/90 text-white'
}`}>
<div
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 ${
toast.type === 'success' ? 'bg-emerald-500/90 text-white' : 'bg-red-500/90 text-white'
}`}
>
{toast.message}
</div>
)}
{/* Preview Modal */}
{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="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 items-center justify-between px-4 sm:px-5 py-3 sm:py-4 border-b border-dark-700">
<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="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 border-b border-dark-700 px-4 py-3 sm:px-5 sm:py-4">
<h3 className="text-base font-semibold text-dark-100">
{t('admin.emailTemplates.preview', 'Preview')}
</h3>
<button
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 />
</button>
@@ -454,7 +514,7 @@ function TemplateEditor({
<div className="flex-1 overflow-hidden p-1">
<iframe
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"
title="Email Preview"
/>
@@ -463,48 +523,48 @@ function TemplateEditor({
</div>
)}
</div>
)
);
}
// ============ Main Page ============
export default function AdminEmailTemplates() {
const { t, i18n } = useTranslation()
const currentLang = i18n.language || 'ru'
const [selectedType, setSelectedType] = useState<string | null>(null)
const { t, i18n } = useTranslation();
const currentLang = i18n.language || 'ru';
const [selectedType, setSelectedType] = useState<string | null>(null);
// Fetch template types list
const { data: typesData, isLoading: typesLoading } = useQuery({
queryKey: ['admin', 'email-templates'],
queryFn: adminEmailTemplatesApi.getTemplateTypes,
})
});
// Fetch detail for selected type
const { data: detailData, isLoading: detailLoading } = useQuery({
queryKey: ['admin', 'email-template', selectedType],
queryFn: () => adminEmailTemplatesApi.getTemplate(selectedType!),
enabled: !!selectedType,
})
});
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 */}
<div className="flex items-center gap-2 sm:gap-3">
<Link
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 />
</Link>
<div className="flex items-center gap-2 sm:gap-2.5 min-w-0">
<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 min-w-0 items-center gap-2 sm:gap-2.5">
<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 />
</div>
<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')}
</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')}
</p>
</div>
@@ -524,12 +584,12 @@ export default function AdminEmailTemplates() {
{typesLoading ? (
<div className="space-y-3">
{[...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 className="grid gap-2 sm:gap-3 grid-cols-1 sm:grid-cols-2">
{typesData?.items.map(template => (
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3">
{typesData?.items.map((template) => (
<TemplateCard
key={template.type}
template={template}
@@ -545,9 +605,9 @@ export default function AdminEmailTemplates() {
{/* Detail loading overlay */}
{selectedType && detailLoading && (
<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>
)
);
}

View File

@@ -1,210 +1,298 @@
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
// Group header icons
const AnalyticsGroupIcon = () => (
<svg className="w-4 h-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" />
<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"
/>
</svg>
)
);
const UsersGroupIcon = () => (
<svg className="w-4 h-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" />
<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"
/>
</svg>
)
);
const TariffsGroupIcon = () => (
<svg className="w-4 h-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" />
<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"
/>
</svg>
)
);
const MarketingGroupIcon = () => (
<svg className="w-4 h-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" />
<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"
/>
</svg>
)
);
const SystemGroupIcon = () => (
<svg className="w-4 h-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" />
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
);
// Modern icons with consistent styling
const ChartBarIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
/>
</svg>
)
);
const BanknotesIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
/>
</svg>
)
);
const UsersIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg>
)
);
const ChatBubbleIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
const NoSymbolIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
/>
</svg>
)
);
const CreditCardIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
const TicketIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
const GiftIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
const MegaphoneIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
/>
</svg>
)
);
const PaperAirplaneIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
const SparklesIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
/>
</svg>
)
);
const CogIcon = () => (
<svg className="w-5 h-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" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
);
const DevicePhoneMobileIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
const ServerStackIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
/>
</svg>
)
);
const EnvelopeIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
const CubeTransparentIcon = () => (
<svg className="w-5 h-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" />
<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"
/>
</svg>
)
);
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" />
</svg>
)
);
interface AdminItem {
to: string
icon: React.ReactNode
title: string
description: string
to: string;
icon: React.ReactNode;
title: string;
description: string;
}
interface AdminGroup {
id: string
title: string
icon: React.ReactNode
gradient: string
borderColor: string
iconBg: string
iconColor: string
items: AdminItem[]
id: string;
title: string;
icon: React.ReactNode;
gradient: string;
borderColor: string;
iconBg: string;
iconColor: string;
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 (
<Link
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}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-dark-100 group-hover:text-white transition-colors">{title}</h3>
<p className="text-xs text-dark-500 truncate">{description}</p>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-medium text-dark-100 transition-colors group-hover:text-white">
{title}
</h3>
<p className="truncate text-xs text-dark-500">{description}</p>
</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 />
</div>
</Link>
)
);
}
function GroupSection({ group }: { group: AdminGroup }) {
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 */}
<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={`p-1.5 rounded-lg ${group.iconBg} ${group.iconColor}`}>
{group.icon}
</div>
<div className={`rounded-lg p-1.5 ${group.iconBg} ${group.iconColor}`}>{group.icon}</div>
<h2 className="text-sm font-semibold text-dark-100">{group.title}</h2>
</div>
</div>
{/* Group Items */}
<div className="p-2 space-y-1.5">
<div className="space-y-1.5 p-2">
{group.items.map((item) => (
<AdminCard
key={item.to}
{...item}
iconBg={group.iconBg}
iconColor={group.iconColor}
/>
<AdminCard key={item.to} {...item} iconBg={group.iconBg} iconColor={group.iconColor} />
))}
</div>
</div>
)
);
}
export default function AdminPanel() {
const { t } = useTranslation()
const { t } = useTranslation();
const groups: AdminGroup[] = [
{
@@ -364,22 +452,22 @@ export default function AdminPanel() {
},
],
},
]
];
return (
<div className="animate-fade-in space-y-4">
{/* Header */}
<div className="mb-6">
<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>
{/* 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) => (
<GroupSection key={group.id} group={group} />
))}
</div>
</div>
)
);
}

View File

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

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