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

View File

@@ -13,7 +13,11 @@ env:
IMAGE_NAME: ${{ github.repository }} IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
lint:
uses: ./.github/workflows/lint.yml
build-and-push: build-and-push:
needs: lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: read 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 contents: write
jobs: jobs:
lint:
uses: ./.github/workflows/lint.yml
create-release: create-release:
needs: lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: 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,
)

1963
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,13 +2,19 @@
"name": "cabinet-frontend", "name": "cabinet-frontend",
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.0",
"description": "Remnawave Bedolaga Web App",
"license": "AGPL-3.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "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", "type-check": "tsc --noEmit",
"preview": "vite preview" "preview": "vite preview",
"prepare": "husky"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -23,23 +29,38 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^13.5.0", "react-i18next": "^13.5.0",
"react-router-dom": "^6.20.0", "react-router-dom": "^7.13.0",
"zustand": "^4.4.0" "zustand": "^4.4.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.2",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"@types/react": "^18.2.37", "@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15", "@types/react-dom": "^18.2.15",
"@typescript-eslint/eslint-plugin": "^6.10.0", "@vitejs/plugin-react": "^5.1.2",
"@typescript-eslint/parser": "^6.10.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"eslint": "^8.53.0", "eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-refresh": "^0.4.4", "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", "postcss": "^8.4.31",
"prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^3.3.5", "tailwindcss": "^3.3.5",
"typescript": "^5.2.2", "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,364 +1,416 @@
import { lazy, Suspense } from 'react' import { lazy, Suspense } from 'react';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom' import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
import { useAuthStore } from './store/auth' import { useAuthStore } from './store/auth';
import { useBlockingStore } from './store/blocking' import { useBlockingStore } from './store/blocking';
import Layout from './components/layout/Layout' import Layout from './components/layout/Layout';
import PageLoader from './components/common/PageLoader' import PageLoader from './components/common/PageLoader';
import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking' import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking';
import { saveReturnUrl } from './utils/token' import { saveReturnUrl } from './utils/token';
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters' import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
// Auth pages - load immediately (small) // Auth pages - load immediately (small)
import Login from './pages/Login' import Login from './pages/Login';
import TelegramCallback from './pages/TelegramCallback' import TelegramCallback from './pages/TelegramCallback';
import TelegramRedirect from './pages/TelegramRedirect' import TelegramRedirect from './pages/TelegramRedirect';
import DeepLinkRedirect from './pages/DeepLinkRedirect' import DeepLinkRedirect from './pages/DeepLinkRedirect';
import VerifyEmail from './pages/VerifyEmail' import VerifyEmail from './pages/VerifyEmail';
import ResetPassword from './pages/ResetPassword' import ResetPassword from './pages/ResetPassword';
// User pages - lazy load // User pages - lazy load
const Dashboard = lazy(() => import('./pages/Dashboard')) const Dashboard = lazy(() => import('./pages/Dashboard'));
const Subscription = lazy(() => import('./pages/Subscription')) const Subscription = lazy(() => import('./pages/Subscription'));
const Balance = lazy(() => import('./pages/Balance')) const Balance = lazy(() => import('./pages/Balance'));
const Referral = lazy(() => import('./pages/Referral')) const Referral = lazy(() => import('./pages/Referral'));
const Support = lazy(() => import('./pages/Support')) const Support = lazy(() => import('./pages/Support'));
const Profile = lazy(() => import('./pages/Profile')) const Profile = lazy(() => import('./pages/Profile'));
const Contests = lazy(() => import('./pages/Contests')) const Contests = lazy(() => import('./pages/Contests'));
const Polls = lazy(() => import('./pages/Polls')) const Polls = lazy(() => import('./pages/Polls'));
const Info = lazy(() => import('./pages/Info')) const Info = lazy(() => import('./pages/Info'));
const Wheel = lazy(() => import('./pages/Wheel')) const Wheel = lazy(() => import('./pages/Wheel'));
// Admin pages - lazy load (only for admins) // Admin pages - lazy load (only for admins)
const AdminPanel = lazy(() => import('./pages/AdminPanel')) const AdminPanel = lazy(() => import('./pages/AdminPanel'));
const AdminTickets = lazy(() => import('./pages/AdminTickets')) const AdminTickets = lazy(() => import('./pages/AdminTickets'));
const AdminSettings = lazy(() => import('./pages/AdminSettings')) const AdminSettings = lazy(() => import('./pages/AdminSettings'));
const AdminApps = lazy(() => import('./pages/AdminApps')) const AdminApps = lazy(() => import('./pages/AdminApps'));
const AdminWheel = lazy(() => import('./pages/AdminWheel')) const AdminWheel = lazy(() => import('./pages/AdminWheel'));
const AdminTariffs = lazy(() => import('./pages/AdminTariffs')) const AdminTariffs = lazy(() => import('./pages/AdminTariffs'));
const AdminServers = lazy(() => import('./pages/AdminServers')) const AdminServers = lazy(() => import('./pages/AdminServers'));
const AdminDashboard = lazy(() => import('./pages/AdminDashboard')) const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
const AdminBanSystem = lazy(() => import('./pages/AdminBanSystem')) const AdminBanSystem = lazy(() => import('./pages/AdminBanSystem'));
const AdminBroadcasts = lazy(() => import('./pages/AdminBroadcasts')) const AdminBroadcasts = lazy(() => import('./pages/AdminBroadcasts'));
const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes')) const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes'));
const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns')) const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns'));
const AdminUsers = lazy(() => import('./pages/AdminUsers')) const AdminUsers = lazy(() => import('./pages/AdminUsers'));
const AdminPayments = lazy(() => import('./pages/AdminPayments')) const AdminPayments = lazy(() => import('./pages/AdminPayments'));
const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods')) const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'));
const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers')) const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers'));
const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave')) const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave'));
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates')) const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'));
function ProtectedRoute({ children }: { children: React.ReactNode }) { function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuthStore() const { isAuthenticated, isLoading } = useAuthStore();
const location = useLocation() const location = useLocation();
if (isLoading) { if (isLoading) {
return <PageLoader variant="dark" /> return <PageLoader variant="dark" />;
} }
if (!isAuthenticated) { if (!isAuthenticated) {
// Сохраняем текущий URL для возврата после авторизации // Сохраняем текущий URL для возврата после авторизации
saveReturnUrl() saveReturnUrl();
return <Navigate to="/login" replace state={{ from: location.pathname }} /> return <Navigate to="/login" replace state={{ from: location.pathname }} />;
} }
return <Layout>{children}</Layout> return <Layout>{children}</Layout>;
} }
function AdminRoute({ children }: { children: React.ReactNode }) { function AdminRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading, isAdmin } = useAuthStore() const { isAuthenticated, isLoading, isAdmin } = useAuthStore();
const location = useLocation() const location = useLocation();
if (isLoading) { if (isLoading) {
return <PageLoader variant="light" /> return <PageLoader variant="light" />;
} }
if (!isAuthenticated) { if (!isAuthenticated) {
// Сохраняем текущий URL для возврата после авторизации // Сохраняем текущий URL для возврата после авторизации
saveReturnUrl() saveReturnUrl();
return <Navigate to="/login" replace state={{ from: location.pathname }} /> return <Navigate to="/login" replace state={{ from: location.pathname }} />;
} }
if (!isAdmin) { if (!isAdmin) {
return <Navigate to="/" replace /> return <Navigate to="/" replace />;
} }
return <Layout>{children}</Layout> return <Layout>{children}</Layout>;
} }
// Suspense wrapper for lazy components // Suspense wrapper for lazy components
function LazyPage({ children }: { children: React.ReactNode }) { function LazyPage({ children }: { children: React.ReactNode }) {
return ( return <Suspense fallback={<PageLoader variant="dark" />}>{children}</Suspense>;
<Suspense fallback={<PageLoader variant="dark" />}>
{children}
</Suspense>
)
} }
function BlockingOverlay() { function BlockingOverlay() {
const { blockingType } = useBlockingStore() const { blockingType } = useBlockingStore();
if (blockingType === 'maintenance') { if (blockingType === 'maintenance') {
return <MaintenanceScreen /> return <MaintenanceScreen />;
} }
if (blockingType === 'channel_subscription') { if (blockingType === 'channel_subscription') {
return <ChannelSubscriptionScreen /> return <ChannelSubscriptionScreen />;
} }
return null return null;
} }
function App() { function App() {
useAnalyticsCounters() useAnalyticsCounters();
return ( return (
<> <>
<BlockingOverlay /> <BlockingOverlay />
<Routes> <Routes>
{/* Public routes */} {/* Public routes */}
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/auth/telegram/callback" element={<TelegramCallback />} /> <Route path="/auth/telegram/callback" element={<TelegramCallback />} />
<Route path="/auth/telegram" element={<TelegramRedirect />} /> <Route path="/auth/telegram" element={<TelegramRedirect />} />
<Route path="/tg" element={<TelegramRedirect />} /> <Route path="/tg" element={<TelegramRedirect />} />
<Route path="/connect" element={<DeepLinkRedirect />} /> <Route path="/connect" element={<DeepLinkRedirect />} />
<Route path="/add" element={<DeepLinkRedirect />} /> <Route path="/add" element={<DeepLinkRedirect />} />
<Route path="/verify-email" element={<VerifyEmail />} /> <Route path="/verify-email" element={<VerifyEmail />} />
<Route path="/reset-password" element={<ResetPassword />} /> <Route path="/reset-password" element={<ResetPassword />} />
{/* Protected routes */} {/* Protected routes */}
<Route <Route
path="/" path="/"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<LazyPage><Dashboard /></LazyPage> <LazyPage>
</ProtectedRoute> <Dashboard />
} </LazyPage>
/> </ProtectedRoute>
<Route }
path="/subscription" />
element={ <Route
<ProtectedRoute> path="/subscription"
<LazyPage><Subscription /></LazyPage> element={
</ProtectedRoute> <ProtectedRoute>
} <LazyPage>
/> <Subscription />
<Route </LazyPage>
path="/balance" </ProtectedRoute>
element={ }
<ProtectedRoute> />
<LazyPage><Balance /></LazyPage> <Route
</ProtectedRoute> path="/balance"
} element={
/> <ProtectedRoute>
<Route <LazyPage>
path="/referral" <Balance />
element={ </LazyPage>
<ProtectedRoute> </ProtectedRoute>
<LazyPage><Referral /></LazyPage> }
</ProtectedRoute> />
} <Route
/> path="/referral"
<Route element={
path="/support" <ProtectedRoute>
element={ <LazyPage>
<ProtectedRoute> <Referral />
<LazyPage><Support /></LazyPage> </LazyPage>
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route <Route
path="/profile" path="/support"
element={ element={
<ProtectedRoute> <ProtectedRoute>
<LazyPage><Profile /></LazyPage> <LazyPage>
</ProtectedRoute> <Support />
} </LazyPage>
/> </ProtectedRoute>
<Route }
path="/contests" />
element={ <Route
<ProtectedRoute> path="/profile"
<LazyPage><Contests /></LazyPage> element={
</ProtectedRoute> <ProtectedRoute>
} <LazyPage>
/> <Profile />
<Route </LazyPage>
path="/polls" </ProtectedRoute>
element={ }
<ProtectedRoute> />
<LazyPage><Polls /></LazyPage> <Route
</ProtectedRoute> path="/contests"
} element={
/> <ProtectedRoute>
<Route <LazyPage>
path="/info" <Contests />
element={ </LazyPage>
<ProtectedRoute> </ProtectedRoute>
<LazyPage><Info /></LazyPage> }
</ProtectedRoute> />
} <Route
/> path="/polls"
<Route element={
path="/wheel" <ProtectedRoute>
element={ <LazyPage>
<ProtectedRoute> <Polls />
<LazyPage><Wheel /></LazyPage> </LazyPage>
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/info"
element={
<ProtectedRoute>
<LazyPage>
<Info />
</LazyPage>
</ProtectedRoute>
}
/>
<Route
path="/wheel"
element={
<ProtectedRoute>
<LazyPage>
<Wheel />
</LazyPage>
</ProtectedRoute>
}
/>
{/* Admin routes */} {/* Admin routes */}
<Route <Route
path="/admin" path="/admin"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminPanel /></LazyPage> <LazyPage>
</AdminRoute> <AdminPanel />
} </LazyPage>
/> </AdminRoute>
<Route }
path="/admin/tickets" />
element={ <Route
<AdminRoute> path="/admin/tickets"
<LazyPage><AdminTickets /></LazyPage> element={
</AdminRoute> <AdminRoute>
} <LazyPage>
/> <AdminTickets />
<Route </LazyPage>
path="/admin/settings" </AdminRoute>
element={ }
<AdminRoute> />
<LazyPage><AdminSettings /></LazyPage> <Route
</AdminRoute> path="/admin/settings"
} element={
/> <AdminRoute>
<Route <LazyPage>
path="/admin/apps" <AdminSettings />
element={ </LazyPage>
<AdminRoute> </AdminRoute>
<LazyPage><AdminApps /></LazyPage> }
</AdminRoute> />
} <Route
/> path="/admin/apps"
<Route element={
path="/admin/wheel" <AdminRoute>
element={ <LazyPage>
<AdminRoute> <AdminApps />
<LazyPage><AdminWheel /></LazyPage> </LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
<Route <Route
path="/admin/tariffs" path="/admin/wheel"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminTariffs /></LazyPage> <LazyPage>
</AdminRoute> <AdminWheel />
} </LazyPage>
/> </AdminRoute>
<Route }
path="/admin/servers" />
element={ <Route
<AdminRoute> path="/admin/tariffs"
<LazyPage><AdminServers /></LazyPage> element={
</AdminRoute> <AdminRoute>
} <LazyPage>
/> <AdminTariffs />
<Route </LazyPage>
path="/admin/dashboard" </AdminRoute>
element={ }
<AdminRoute> />
<LazyPage><AdminDashboard /></LazyPage> <Route
</AdminRoute> path="/admin/servers"
} element={
/> <AdminRoute>
<Route <LazyPage>
path="/admin/ban-system" <AdminServers />
element={ </LazyPage>
<AdminRoute> </AdminRoute>
<LazyPage><AdminBanSystem /></LazyPage> }
</AdminRoute> />
} <Route
/> path="/admin/dashboard"
<Route element={
path="/admin/broadcasts" <AdminRoute>
element={ <LazyPage>
<AdminRoute> <AdminDashboard />
<LazyPage><AdminBroadcasts /></LazyPage> </LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
<Route <Route
path="/admin/promocodes" path="/admin/ban-system"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminPromocodes /></LazyPage> <LazyPage>
</AdminRoute> <AdminBanSystem />
} </LazyPage>
/> </AdminRoute>
<Route }
path="/admin/campaigns" />
element={ <Route
<AdminRoute> path="/admin/broadcasts"
<LazyPage><AdminCampaigns /></LazyPage> element={
</AdminRoute> <AdminRoute>
} <LazyPage>
/> <AdminBroadcasts />
<Route </LazyPage>
path="/admin/users" </AdminRoute>
element={ }
<AdminRoute> />
<LazyPage><AdminUsers /></LazyPage> <Route
</AdminRoute> path="/admin/promocodes"
} element={
/> <AdminRoute>
<Route <LazyPage>
path="/admin/payments" <AdminPromocodes />
element={ </LazyPage>
<AdminRoute> </AdminRoute>
<LazyPage><AdminPayments /></LazyPage> }
</AdminRoute> />
} <Route
/> path="/admin/campaigns"
<Route element={
path="/admin/payment-methods" <AdminRoute>
element={ <LazyPage>
<AdminRoute> <AdminCampaigns />
<LazyPage><AdminPaymentMethods /></LazyPage> </LazyPage>
</AdminRoute> </AdminRoute>
} }
/> />
<Route <Route
path="/admin/promo-offers" path="/admin/users"
element={ element={
<AdminRoute> <AdminRoute>
<LazyPage><AdminPromoOffers /></LazyPage> <LazyPage>
</AdminRoute> <AdminUsers />
} </LazyPage>
/> </AdminRoute>
<Route }
path="/admin/remnawave" />
element={ <Route
<AdminRoute> path="/admin/payments"
<LazyPage><AdminRemnawave /></LazyPage> element={
</AdminRoute> <AdminRoute>
} <LazyPage>
/> <AdminPayments />
<Route </LazyPage>
path="/admin/email-templates" </AdminRoute>
element={ }
<AdminRoute> />
<LazyPage><AdminEmailTemplates /></LazyPage> <Route
</AdminRoute> path="/admin/payment-methods"
} element={
/> <AdminRoute>
<LazyPage>
<AdminPaymentMethods />
</LazyPage>
</AdminRoute>
}
/>
<Route
path="/admin/promo-offers"
element={
<AdminRoute>
<LazyPage>
<AdminPromoOffers />
</LazyPage>
</AdminRoute>
}
/>
<Route
path="/admin/remnawave"
element={
<AdminRoute>
<LazyPage>
<AdminRemnawave />
</LazyPage>
</AdminRoute>
}
/>
<Route
path="/admin/email-templates"
element={
<AdminRoute>
<LazyPage>
<AdminEmailTemplates />
</LazyPage>
</AdminRoute>
}
/>
{/* Catch all */} {/* Catch all */}
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
</> </>
) );
} }
export default App export default App;

View File

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

View File

@@ -1,220 +1,240 @@
import apiClient from './client' import apiClient from './client';
export interface LocalizedText { export interface LocalizedText {
en: string en: string;
ru: string ru: string;
zh?: string zh?: string;
fa?: string fa?: string;
fr?: string fr?: string;
} }
export interface AppButton { export interface AppButton {
id?: string // Unique identifier for React key (client-side only) id?: string; // Unique identifier for React key (client-side only)
buttonLink: string buttonLink: string;
buttonText: LocalizedText buttonText: LocalizedText;
} }
export interface AppStep { export interface AppStep {
description: LocalizedText description: LocalizedText;
buttons?: AppButton[] buttons?: AppButton[];
title?: LocalizedText title?: LocalizedText;
} }
export interface AppDefinition { export interface AppDefinition {
id: string id: string;
name: string name: string;
isFeatured: boolean isFeatured: boolean;
urlScheme: string urlScheme: string;
isNeedBase64Encoding?: boolean isNeedBase64Encoding?: boolean;
installationStep: AppStep installationStep: AppStep;
addSubscriptionStep: AppStep addSubscriptionStep: AppStep;
connectAndUseStep: AppStep connectAndUseStep: AppStep;
additionalBeforeAddSubscriptionStep?: AppStep additionalBeforeAddSubscriptionStep?: AppStep;
additionalAfterAddSubscriptionStep?: AppStep additionalAfterAddSubscriptionStep?: AppStep;
} }
export interface AppConfigBranding { export interface AppConfigBranding {
name: string name: string;
logoUrl: string logoUrl: string;
supportUrl: string supportUrl: string;
} }
export interface AppConfigConfig { export interface AppConfigConfig {
additionalLocales: string[] additionalLocales: string[];
branding: AppConfigBranding branding: AppConfigBranding;
} }
export interface AppConfigResponse { export interface AppConfigResponse {
config: AppConfigConfig config: AppConfigConfig;
platforms: Record<string, AppDefinition[]> platforms: Record<string, AppDefinition[]>;
} }
export const adminAppsApi = { export const adminAppsApi = {
// Get full app config // Get full app config
getConfig: async (): Promise<AppConfigResponse> => { getConfig: async (): Promise<AppConfigResponse> => {
const response = await apiClient.get<AppConfigResponse>('/cabinet/admin/apps') const response = await apiClient.get<AppConfigResponse>('/cabinet/admin/apps');
return response.data return response.data;
}, },
// Get available platforms // Get available platforms
getPlatforms: async (): Promise<string[]> => { getPlatforms: async (): Promise<string[]> => {
const response = await apiClient.get<string[]>('/cabinet/admin/apps/platforms') const response = await apiClient.get<string[]>('/cabinet/admin/apps/platforms');
return response.data return response.data;
}, },
// Get apps for a platform // Get apps for a platform
getPlatformApps: async (platform: string): Promise<AppDefinition[]> => { getPlatformApps: async (platform: string): Promise<AppDefinition[]> => {
const response = await apiClient.get<AppDefinition[]>(`/cabinet/admin/apps/platforms/${platform}`) const response = await apiClient.get<AppDefinition[]>(
return response.data `/cabinet/admin/apps/platforms/${platform}`,
);
return response.data;
}, },
// Create a new app // Create a new app
createApp: async (platform: string, app: AppDefinition): Promise<AppDefinition> => { createApp: async (platform: string, app: AppDefinition): Promise<AppDefinition> => {
const response = await apiClient.post<AppDefinition>(`/cabinet/admin/apps/platforms/${platform}`, { const response = await apiClient.post<AppDefinition>(
platform, `/cabinet/admin/apps/platforms/${platform}`,
app, {
}) platform,
return response.data app,
},
);
return response.data;
}, },
// Update an app // Update an app
updateApp: async (platform: string, appId: string, app: AppDefinition): Promise<AppDefinition> => { updateApp: async (
const response = await apiClient.put<AppDefinition>(`/cabinet/admin/apps/platforms/${platform}/${appId}`, { platform: string,
app, appId: string,
}) app: AppDefinition,
return response.data ): Promise<AppDefinition> => {
const response = await apiClient.put<AppDefinition>(
`/cabinet/admin/apps/platforms/${platform}/${appId}`,
{
app,
},
);
return response.data;
}, },
// Delete an app // Delete an app
deleteApp: async (platform: string, appId: string): Promise<void> => { deleteApp: async (platform: string, appId: string): Promise<void> => {
await apiClient.delete(`/cabinet/admin/apps/platforms/${platform}/${appId}`) await apiClient.delete(`/cabinet/admin/apps/platforms/${platform}/${appId}`);
}, },
// Reorder apps // Reorder apps
reorderApps: async (platform: string, appIds: string[]): Promise<void> => { reorderApps: async (platform: string, appIds: string[]): Promise<void> => {
await apiClient.post(`/cabinet/admin/apps/platforms/${platform}/reorder`, { await apiClient.post(`/cabinet/admin/apps/platforms/${platform}/reorder`, {
app_ids: appIds, app_ids: appIds,
}) });
}, },
// Copy app to another platform // Copy app to another platform
copyApp: async (platform: string, appId: string, targetPlatform: string): Promise<{ new_id: string }> => { copyApp: async (
platform: string,
appId: string,
targetPlatform: string,
): Promise<{ new_id: string }> => {
const response = await apiClient.post<{ new_id: string; target_platform: string }>( const response = await apiClient.post<{ new_id: string; target_platform: string }>(
`/cabinet/admin/apps/platforms/${platform}/copy/${appId}?target_platform=${targetPlatform}` `/cabinet/admin/apps/platforms/${platform}/copy/${appId}?target_platform=${targetPlatform}`,
) );
return response.data return response.data;
}, },
// Get branding // Get branding
getBranding: async (): Promise<AppConfigBranding> => { getBranding: async (): Promise<AppConfigBranding> => {
const response = await apiClient.get<AppConfigBranding>('/cabinet/admin/apps/branding') const response = await apiClient.get<AppConfigBranding>('/cabinet/admin/apps/branding');
return response.data return response.data;
}, },
// Update branding // Update branding
updateBranding: async (branding: AppConfigBranding): Promise<AppConfigBranding> => { updateBranding: async (branding: AppConfigBranding): Promise<AppConfigBranding> => {
const response = await apiClient.put<AppConfigBranding>('/cabinet/admin/apps/branding', { const response = await apiClient.put<AppConfigBranding>('/cabinet/admin/apps/branding', {
branding, branding,
}) });
return response.data return response.data;
}, },
// Get RemnaWave config status // Get RemnaWave config status
getRemnaWaveStatus: async (): Promise<{ enabled: boolean; config_uuid: string | null }> => { getRemnaWaveStatus: async (): Promise<{ enabled: boolean; config_uuid: string | null }> => {
const response = await apiClient.get<{ enabled: boolean; config_uuid: string | null }>( const response = await apiClient.get<{ enabled: boolean; config_uuid: string | null }>(
'/cabinet/admin/apps/remnawave/status' '/cabinet/admin/apps/remnawave/status',
) );
return response.data return response.data;
}, },
// Set RemnaWave config UUID // Set RemnaWave config UUID
setRemnaWaveUuid: async (uuid: string | null): Promise<{ enabled: boolean; config_uuid: string | null }> => { setRemnaWaveUuid: async (
uuid: string | null,
): Promise<{ enabled: boolean; config_uuid: string | null }> => {
const response = await apiClient.put<{ enabled: boolean; config_uuid: string | null }>( const response = await apiClient.put<{ enabled: boolean; config_uuid: string | null }>(
'/cabinet/admin/apps/remnawave/uuid', '/cabinet/admin/apps/remnawave/uuid',
{ uuid } { uuid },
) );
return response.data return response.data;
}, },
// List available RemnaWave configs // List available RemnaWave configs
listRemnaWaveConfigs: async (): Promise<{ uuid: string; name: string; view_position: number }[]> => { listRemnaWaveConfigs: async (): Promise<
{ uuid: string; name: string; view_position: number }[]
> => {
const response = await apiClient.get<{ uuid: string; name: string; view_position: number }[]>( const response = await apiClient.get<{ uuid: string; name: string; view_position: number }[]>(
'/cabinet/admin/apps/remnawave/configs' '/cabinet/admin/apps/remnawave/configs',
) );
return response.data return response.data;
}, },
// Get RemnaWave subscription config // Get RemnaWave subscription config
getRemnaWaveConfig: async (): Promise<RemnawaveConfig> => { getRemnaWaveConfig: async (): Promise<RemnawaveConfig> => {
const response = await apiClient.get<{ const response = await apiClient.get<{
uuid: string uuid: string;
name: string name: string;
view_position: number view_position: number;
config: RemnawaveConfig config: RemnawaveConfig;
}>('/cabinet/admin/apps/remnawave/config') }>('/cabinet/admin/apps/remnawave/config');
return response.data.config return response.data.config;
}, },
} };
// ============== RemnaWave Format Types ============== // ============== RemnaWave Format Types ==============
export interface RemnawaveButton { export interface RemnawaveButton {
url: string url: string;
text: LocalizedText text: LocalizedText;
} }
export interface RemnawaveBlock { export interface RemnawaveBlock {
title: LocalizedText title: LocalizedText;
description: LocalizedText description: LocalizedText;
buttons?: RemnawaveButton[] buttons?: RemnawaveButton[];
svgIconKey?: string svgIconKey?: string;
svgIconColor?: string svgIconColor?: string;
} }
export interface RemnawaveApp { export interface RemnawaveApp {
name: string name: string;
featured?: boolean featured?: boolean;
urlScheme?: string urlScheme?: string;
isNeedBase64Encoding?: boolean isNeedBase64Encoding?: boolean;
blocks: RemnawaveBlock[] blocks: RemnawaveBlock[];
} }
export interface RemnawavePlatform { export interface RemnawavePlatform {
apps: RemnawaveApp[] apps: RemnawaveApp[];
} }
export interface RemnawaveSvgItem { export interface RemnawaveSvgItem {
svgString: string svgString: string;
tags?: string[] tags?: string[];
} }
export interface RemnawaveBaseSettings { export interface RemnawaveBaseSettings {
isShowTutorialButton: boolean isShowTutorialButton: boolean;
tutorialUrl: string tutorialUrl: string;
} }
export interface RemnawaveBaseTranslations { export interface RemnawaveBaseTranslations {
installApp: LocalizedText installApp: LocalizedText;
addSubscription: LocalizedText addSubscription: LocalizedText;
connectAndUse: LocalizedText connectAndUse: LocalizedText;
copyLink: LocalizedText copyLink: LocalizedText;
openApp: LocalizedText openApp: LocalizedText;
tutorial: LocalizedText tutorial: LocalizedText;
close: LocalizedText close: LocalizedText;
} }
export interface RemnawaveBrandingSettings { export interface RemnawaveBrandingSettings {
name: string name: string;
logoUrl: string logoUrl: string;
supportUrl: string supportUrl: string;
} }
export interface RemnawaveConfig { export interface RemnawaveConfig {
platforms: Record<string, RemnawavePlatform> platforms: Record<string, RemnawavePlatform>;
svgLibrary?: Record<string, RemnawaveSvgItem> svgLibrary?: Record<string, RemnawaveSvgItem>;
baseSettings?: RemnawaveBaseSettings baseSettings?: RemnawaveBaseSettings;
baseTranslations?: RemnawaveBaseTranslations baseTranslations?: RemnawaveBaseTranslations;
brandingSettings?: RemnawaveBrandingSettings brandingSettings?: RemnawaveBrandingSettings;
} }
// ============== Converter Functions ============== // ============== Converter Functions ==============
@@ -225,23 +245,29 @@ const emptyLocalizedText = (): LocalizedText => ({
zh: '', zh: '',
fa: '', fa: '',
fr: '', fr: '',
}) });
// Convert Cabinet button to RemnaWave button // Convert Cabinet button to RemnaWave button
const cabinetButtonToRemnawave = (button: AppButton): RemnawaveButton => ({ const cabinetButtonToRemnawave = (button: AppButton): RemnawaveButton => ({
url: button.buttonLink, url: button.buttonLink,
text: { ...emptyLocalizedText(), ...button.buttonText }, text: { ...emptyLocalizedText(), ...button.buttonText },
}) });
// Convert RemnaWave button to Cabinet button // Convert RemnaWave button to Cabinet button
const remnawaveButtonToCabinet = (button: RemnawaveButton): AppButton => ({ const remnawaveButtonToCabinet = (button: RemnawaveButton): AppButton => ({
buttonLink: button.url, buttonLink: button.url,
buttonText: { en: button.text.en || '', ru: button.text.ru || '', zh: button.text.zh, fa: button.text.fa, fr: button.text.fr }, buttonText: {
}) en: button.text.en || '',
ru: button.text.ru || '',
zh: button.text.zh,
fa: button.text.fa,
fr: button.text.fr,
},
});
// Convert Cabinet app to RemnaWave app format // Convert Cabinet app to RemnaWave app format
export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => { export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
const blocks: RemnawaveBlock[] = [] const blocks: RemnawaveBlock[] = [];
// Block 1: Installation // Block 1: Installation
blocks.push({ blocks.push({
@@ -250,17 +276,27 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
buttons: app.installationStep.buttons?.map(cabinetButtonToRemnawave), buttons: app.installationStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'download', svgIconKey: 'download',
svgIconColor: '#3B82F6', svgIconColor: '#3B82F6',
}) });
// Block 2 (optional): Additional before subscription // Block 2 (optional): Additional before subscription
if (app.additionalBeforeAddSubscriptionStep?.description?.en || app.additionalBeforeAddSubscriptionStep?.description?.ru) { if (
app.additionalBeforeAddSubscriptionStep?.description?.en ||
app.additionalBeforeAddSubscriptionStep?.description?.ru
) {
blocks.push({ blocks.push({
title: app.additionalBeforeAddSubscriptionStep.title || { ...emptyLocalizedText(), en: 'Preparation', ru: 'Подготовка' }, title: app.additionalBeforeAddSubscriptionStep.title || {
description: { ...emptyLocalizedText(), ...app.additionalBeforeAddSubscriptionStep.description }, ...emptyLocalizedText(),
en: 'Preparation',
ru: 'Подготовка',
},
description: {
...emptyLocalizedText(),
...app.additionalBeforeAddSubscriptionStep.description,
},
buttons: app.additionalBeforeAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave), buttons: app.additionalBeforeAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'settings', svgIconKey: 'settings',
svgIconColor: '#8B5CF6', svgIconColor: '#8B5CF6',
}) });
} }
// Block 3: Add subscription // Block 3: Add subscription
@@ -270,17 +306,27 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
buttons: app.addSubscriptionStep.buttons?.map(cabinetButtonToRemnawave), buttons: app.addSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'plus', svgIconKey: 'plus',
svgIconColor: '#10B981', svgIconColor: '#10B981',
}) });
// Block 4 (optional): Additional after subscription // Block 4 (optional): Additional after subscription
if (app.additionalAfterAddSubscriptionStep?.description?.en || app.additionalAfterAddSubscriptionStep?.description?.ru) { if (
app.additionalAfterAddSubscriptionStep?.description?.en ||
app.additionalAfterAddSubscriptionStep?.description?.ru
) {
blocks.push({ blocks.push({
title: app.additionalAfterAddSubscriptionStep.title || { ...emptyLocalizedText(), en: 'Configuration', ru: 'Настройка' }, title: app.additionalAfterAddSubscriptionStep.title || {
description: { ...emptyLocalizedText(), ...app.additionalAfterAddSubscriptionStep.description }, ...emptyLocalizedText(),
en: 'Configuration',
ru: 'Настройка',
},
description: {
...emptyLocalizedText(),
...app.additionalAfterAddSubscriptionStep.description,
},
buttons: app.additionalAfterAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave), buttons: app.additionalAfterAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'settings', svgIconKey: 'settings',
svgIconColor: '#F59E0B', svgIconColor: '#F59E0B',
}) });
} }
// Block 5: Connect and use // Block 5: Connect and use
@@ -290,7 +336,7 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
buttons: app.connectAndUseStep.buttons?.map(cabinetButtonToRemnawave), buttons: app.connectAndUseStep.buttons?.map(cabinetButtonToRemnawave),
svgIconKey: 'check', svgIconKey: 'check',
svgIconColor: '#22C55E', svgIconColor: '#22C55E',
}) });
return { return {
name: app.name, name: app.name,
@@ -298,78 +344,104 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
urlScheme: app.urlScheme, urlScheme: app.urlScheme,
isNeedBase64Encoding: app.isNeedBase64Encoding, isNeedBase64Encoding: app.isNeedBase64Encoding,
blocks, blocks,
} };
} };
// Convert RemnaWave app to Cabinet app format // Convert RemnaWave app to Cabinet app format
export const remnawaveAppToCabinet = (app: RemnawaveApp, platform: string): AppDefinition => { export const remnawaveAppToCabinet = (app: RemnawaveApp, platform: string): AppDefinition => {
const blocks = app.blocks || [] const blocks = app.blocks || [];
// Default empty step // Default empty step
const emptyStep = (): AppStep => ({ const emptyStep = (): AppStep => ({
description: emptyLocalizedText(), description: emptyLocalizedText(),
buttons: [], buttons: [],
}) });
// Try to identify blocks by their titles or position // Try to identify blocks by their titles or position
let installationBlock: RemnawaveBlock | undefined let installationBlock: RemnawaveBlock | undefined;
let subscriptionBlock: RemnawaveBlock | undefined let subscriptionBlock: RemnawaveBlock | undefined;
let connectBlock: RemnawaveBlock | undefined let connectBlock: RemnawaveBlock | undefined;
let beforeSubBlock: RemnawaveBlock | undefined let beforeSubBlock: RemnawaveBlock | undefined;
let afterSubBlock: RemnawaveBlock | undefined let afterSubBlock: RemnawaveBlock | undefined;
// First pass: try to identify by title keywords // First pass: try to identify by title keywords
for (const block of blocks) { for (const block of blocks) {
const enTitle = (block.title?.en || '').toLowerCase() const enTitle = (block.title?.en || '').toLowerCase();
const ruTitle = (block.title?.ru || '').toLowerCase() const ruTitle = (block.title?.ru || '').toLowerCase();
if (enTitle.includes('install') || ruTitle.includes('установ') || ruTitle.includes('скачай')) { if (enTitle.includes('install') || ruTitle.includes('установ') || ruTitle.includes('скачай')) {
if (!installationBlock) installationBlock = block if (!installationBlock) installationBlock = block;
} else if (enTitle.includes('subscription') || enTitle.includes('add') || ruTitle.includes('подписк') || ruTitle.includes('добав')) { } else if (
if (!subscriptionBlock) subscriptionBlock = block enTitle.includes('subscription') ||
} else if (enTitle.includes('connect') || enTitle.includes('use') || ruTitle.includes('подключ') || ruTitle.includes('использ')) { enTitle.includes('add') ||
if (!connectBlock) connectBlock = block ruTitle.includes('подписк') ||
ruTitle.includes('добав')
) {
if (!subscriptionBlock) subscriptionBlock = block;
} else if (
enTitle.includes('connect') ||
enTitle.includes('use') ||
ruTitle.includes('подключ') ||
ruTitle.includes('использ')
) {
if (!connectBlock) connectBlock = block;
} }
} }
// Second pass: assign remaining blocks by position if not found by title // Second pass: assign remaining blocks by position if not found by title
if (!installationBlock && blocks.length > 0) { if (!installationBlock && blocks.length > 0) {
installationBlock = blocks[0] installationBlock = blocks[0];
} }
if (!subscriptionBlock && blocks.length > 1) { if (!subscriptionBlock && blocks.length > 1) {
subscriptionBlock = blocks.find(b => b !== installationBlock) || blocks[1] subscriptionBlock = blocks.find((b) => b !== installationBlock) || blocks[1];
} }
if (!connectBlock && blocks.length > 2) { if (!connectBlock && blocks.length > 2) {
connectBlock = blocks.find(b => b !== installationBlock && b !== subscriptionBlock) || blocks[blocks.length - 1] connectBlock =
blocks.find((b) => b !== installationBlock && b !== subscriptionBlock) ||
blocks[blocks.length - 1];
} }
// Assign additional blocks // Assign additional blocks
const additionalBlocks = blocks.filter(b => const additionalBlocks = blocks.filter(
b !== installationBlock && b !== subscriptionBlock && b !== connectBlock (b) => b !== installationBlock && b !== subscriptionBlock && b !== connectBlock,
) );
if (additionalBlocks.length >= 1) { if (additionalBlocks.length >= 1) {
// Check if block appears before subscription // Check if block appears before subscription
const subIndex = blocks.indexOf(subscriptionBlock!) const subIndex = blocks.indexOf(subscriptionBlock!);
const firstAdditionalIndex = blocks.indexOf(additionalBlocks[0]) const firstAdditionalIndex = blocks.indexOf(additionalBlocks[0]);
if (firstAdditionalIndex < subIndex) { if (firstAdditionalIndex < subIndex) {
beforeSubBlock = additionalBlocks[0] beforeSubBlock = additionalBlocks[0];
if (additionalBlocks.length >= 2) { if (additionalBlocks.length >= 2) {
afterSubBlock = additionalBlocks[1] afterSubBlock = additionalBlocks[1];
} }
} else { } else {
afterSubBlock = additionalBlocks[0] afterSubBlock = additionalBlocks[0];
} }
} }
// Convert block to cabinet step // Convert block to cabinet step
const blockToStep = (block: RemnawaveBlock | undefined): AppStep => { const blockToStep = (block: RemnawaveBlock | undefined): AppStep => {
if (!block) return emptyStep() if (!block) return emptyStep();
return { return {
description: { en: block.description?.en || '', ru: block.description?.ru || '', zh: block.description?.zh, fa: block.description?.fa, fr: block.description?.fr }, description: {
title: block.title ? { en: block.title.en || '', ru: block.title.ru || '', zh: block.title.zh, fa: block.title.fa, fr: block.title.fr } : undefined, en: block.description?.en || '',
ru: block.description?.ru || '',
zh: block.description?.zh,
fa: block.description?.fa,
fr: block.description?.fr,
},
title: block.title
? {
en: block.title.en || '',
ru: block.title.ru || '',
zh: block.title.zh,
fa: block.title.fa,
fr: block.title.fr,
}
: undefined,
buttons: block.buttons?.map(remnawaveButtonToCabinet), buttons: block.buttons?.map(remnawaveButtonToCabinet),
} };
} };
return { return {
id: `${app.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${platform}-${Date.now()}`, id: `${app.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${platform}-${Date.now()}`,
@@ -382,66 +454,114 @@ export const remnawaveAppToCabinet = (app: RemnawaveApp, platform: string): AppD
connectAndUseStep: blockToStep(connectBlock), connectAndUseStep: blockToStep(connectBlock),
additionalBeforeAddSubscriptionStep: beforeSubBlock ? blockToStep(beforeSubBlock) : undefined, additionalBeforeAddSubscriptionStep: beforeSubBlock ? blockToStep(beforeSubBlock) : undefined,
additionalAfterAddSubscriptionStep: afterSubBlock ? blockToStep(afterSubBlock) : undefined, additionalAfterAddSubscriptionStep: afterSubBlock ? blockToStep(afterSubBlock) : undefined,
} };
} };
// Export all apps from cabinet to RemnaWave format // Export all apps from cabinet to RemnaWave format
export const exportToRemnawaveFormat = ( export const exportToRemnawaveFormat = (
platformApps: Record<string, AppDefinition[]>, platformApps: Record<string, AppDefinition[]>,
branding?: AppConfigBranding branding?: AppConfigBranding,
): RemnawaveConfig => { ): RemnawaveConfig => {
const platforms: Record<string, RemnawavePlatform> = {} const platforms: Record<string, RemnawavePlatform> = {};
for (const [platform, apps] of Object.entries(platformApps)) { for (const [platform, apps] of Object.entries(platformApps)) {
platforms[platform] = { platforms[platform] = {
apps: apps.map(cabinetAppToRemnawave), apps: apps.map(cabinetAppToRemnawave),
} };
} }
return { return {
platforms, platforms,
svgLibrary: { svgLibrary: {
download: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>' }, download: {
plus: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' }, svgString:
check: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>' }, '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>',
settings: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>' }, },
plus: {
svgString:
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>',
},
check: {
svgString:
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>',
},
settings: {
svgString:
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>',
},
}, },
baseSettings: { baseSettings: {
isShowTutorialButton: false, isShowTutorialButton: false,
tutorialUrl: '', tutorialUrl: '',
}, },
baseTranslations: { baseTranslations: {
installApp: { en: 'Install App', ru: 'Установить приложение', zh: '安装应用', fa: 'نصب برنامه', fr: 'Installer l\'application' }, installApp: {
addSubscription: { en: 'Add Subscription', ru: 'Добавить подписку', zh: '添加订阅', fa: 'اضافه کردن اشتراک', fr: 'Ajouter un abonnement' }, en: 'Install App',
connectAndUse: { en: 'Connect and Use', ru: 'Подключиться и использовать', zh: '连接并使用', fa: 'اتصال و استفاده', fr: 'Connecter et utiliser' }, ru: 'Установить приложение',
copyLink: { en: 'Copy Link', ru: 'Скопировать ссылку', zh: '复制链接', fa: 'کپی لینک', fr: 'Copier le lien' }, zh: '安装应用',
openApp: { en: 'Open App', ru: 'Открыть приложение', zh: '打开应用', fa: 'باز کردن برنامه', fr: 'Ouvrir l\'application' }, fa: صب برنامه',
fr: "Installer l'application",
},
addSubscription: {
en: 'Add Subscription',
ru: 'Добавить подписку',
zh: '添加订阅',
fa: 'اضافه کردن اشتراک',
fr: 'Ajouter un abonnement',
},
connectAndUse: {
en: 'Connect and Use',
ru: 'Подключиться и использовать',
zh: '连接并使用',
fa: 'اتصال و استفاده',
fr: 'Connecter et utiliser',
},
copyLink: {
en: 'Copy Link',
ru: 'Скопировать ссылку',
zh: '复制链接',
fa: 'کپی لینک',
fr: 'Copier le lien',
},
openApp: {
en: 'Open App',
ru: 'Открыть приложение',
zh: '打开应用',
fa: 'باز کردن برنامه',
fr: "Ouvrir l'application",
},
tutorial: { en: 'Tutorial', ru: 'Инструкция', zh: '教程', fa: 'آموزش', fr: 'Tutoriel' }, tutorial: { en: 'Tutorial', ru: 'Инструкция', zh: '教程', fa: 'آموزش', fr: 'Tutoriel' },
close: { en: 'Close', ru: 'Закрыть', zh: '关闭', fa: 'بستن', fr: 'Fermer' }, close: { en: 'Close', ru: 'Закрыть', zh: '关闭', fa: 'بستن', fr: 'Fermer' },
}, },
brandingSettings: branding ? { brandingSettings: branding
name: branding.name, ? {
logoUrl: branding.logoUrl, name: branding.name,
supportUrl: branding.supportUrl, logoUrl: branding.logoUrl,
} : undefined, supportUrl: branding.supportUrl,
} }
} : undefined,
};
};
// Import apps from RemnaWave format to cabinet // Import apps from RemnaWave format to cabinet
export const importFromRemnawaveFormat = ( export const importFromRemnawaveFormat = (
config: RemnawaveConfig config: RemnawaveConfig,
): { platformApps: Record<string, AppDefinition[]>; branding?: AppConfigBranding } => { ): { platformApps: Record<string, AppDefinition[]>; branding?: AppConfigBranding } => {
const platformApps: Record<string, AppDefinition[]> = {} const platformApps: Record<string, AppDefinition[]> = {};
for (const [platform, platformData] of Object.entries(config.platforms || {})) { for (const [platform, platformData] of Object.entries(config.platforms || {})) {
platformApps[platform] = (platformData.apps || []).map(app => remnawaveAppToCabinet(app, platform)) platformApps[platform] = (platformData.apps || []).map((app) =>
remnawaveAppToCabinet(app, platform),
);
} }
const branding = config.brandingSettings ? { const branding = config.brandingSettings
name: config.brandingSettings.name, ? {
logoUrl: config.brandingSettings.logoUrl, name: config.brandingSettings.name,
supportUrl: config.brandingSettings.supportUrl, logoUrl: config.brandingSettings.logoUrl,
} : undefined supportUrl: config.brandingSettings.supportUrl,
}
: undefined;
return { platformApps, branding } return { platformApps, branding };
} };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,20 +1,20 @@
import { useState } from 'react' import { useState } from 'react';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import { balanceApi } from '../api/balance' import { balanceApi } from '../api/balance';
import { useCurrency } from '../hooks/useCurrency' import { useCurrency } from '../hooks/useCurrency';
import TopUpModal from './TopUpModal' import TopUpModal from './TopUpModal';
import type { PaymentMethod } from '../types' import type { PaymentMethod } from '../types';
interface InsufficientBalancePromptProps { interface InsufficientBalancePromptProps {
/** Amount missing in kopeks */ /** Amount missing in kopeks */
missingAmountKopeks: number missingAmountKopeks: number;
/** Optional custom message */ /** Optional custom message */
message?: string message?: string;
/** Compact mode for inline use */ /** Compact mode for inline use */
compact?: boolean compact?: boolean;
/** Additional className */ /** Additional className */
className?: string className?: string;
} }
export default function InsufficientBalancePrompt({ export default function InsufficientBalancePrompt({
@@ -23,40 +23,55 @@ export default function InsufficientBalancePrompt({
compact = false, compact = false,
className = '', className = '',
}: InsufficientBalancePromptProps) { }: InsufficientBalancePromptProps) {
const { t } = useTranslation() const { t } = useTranslation();
const { formatAmount, currencySymbol } = useCurrency() const { formatAmount, currencySymbol } = useCurrency();
const [showMethodSelect, setShowMethodSelect] = useState(false) const [showMethodSelect, setShowMethodSelect] = useState(false);
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null) const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null);
const { data: paymentMethods } = useQuery({ const { data: paymentMethods } = useQuery({
queryKey: ['payment-methods'], queryKey: ['payment-methods'],
queryFn: balanceApi.getPaymentMethods, queryFn: balanceApi.getPaymentMethods,
enabled: showMethodSelect, enabled: showMethodSelect,
}) });
const missingRubles = missingAmountKopeks / 100 const missingRubles = missingAmountKopeks / 100;
const displayAmount = formatAmount(missingRubles) const displayAmount = formatAmount(missingRubles);
const handleMethodSelect = (method: PaymentMethod) => { const handleMethodSelect = (method: PaymentMethod) => {
setSelectedMethod(method) setSelectedMethod(method);
setShowMethodSelect(false) setShowMethodSelect(false);
} };
if (compact) { if (compact) {
return ( return (
<> <>
<div className={`flex items-center justify-between gap-3 p-3 bg-error-500/10 border border-error-500/30 rounded-xl ${className}`}> <div
className={`flex items-center justify-between gap-3 rounded-xl border border-error-500/30 bg-error-500/10 p-3 ${className}`}
>
<div className="flex items-center gap-2 text-sm text-error-400"> <div className="flex items-center gap-2 text-sm text-error-400">
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" /> className="h-4 w-4 flex-shrink-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg> </svg>
<span> <span>
{message || t('balance.insufficientFunds')}: <span className="font-semibold">{displayAmount} {currencySymbol}</span> {message || t('balance.insufficientFunds')}:{' '}
<span className="font-semibold">
{displayAmount} {currencySymbol}
</span>
</span> </span>
</div> </div>
<button <button
onClick={() => setShowMethodSelect(true)} onClick={() => setShowMethodSelect(true)}
className="btn-primary text-xs py-1.5 px-3 whitespace-nowrap" className="btn-primary whitespace-nowrap px-3 py-1.5 text-xs"
> >
{t('balance.topUp')} {t('balance.topUp')}
</button> </button>
@@ -78,37 +93,54 @@ export default function InsufficientBalancePrompt({
/> />
)} )}
</> </>
) );
} }
return ( return (
<> <>
<div className={`p-4 bg-gradient-to-br from-error-500/10 to-warning-500/5 border border-error-500/30 rounded-xl ${className}`}> <div
className={`rounded-xl border border-error-500/30 bg-gradient-to-br from-error-500/10 to-warning-500/5 p-4 ${className}`}
>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-error-500/20 flex items-center justify-center flex-shrink-0"> <div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-error-500/20">
<svg className="w-5 h-5 text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" /> className="h-5 w-5 text-error-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
/>
</svg> </svg>
</div> </div>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="text-error-400 font-medium mb-1"> <div className="mb-1 font-medium text-error-400">{t('balance.insufficientFunds')}</div>
{t('balance.insufficientFunds')} <div className="text-sm text-dark-300">{message || t('balance.topUpToComplete')}</div>
</div>
<div className="text-dark-300 text-sm">
{message || t('balance.topUpToComplete')}
</div>
<div className="mt-3 flex items-center gap-3"> <div className="mt-3 flex items-center gap-3">
<div className="text-lg font-bold text-dark-100"> <div className="text-lg font-bold text-dark-100">
{t('balance.missing')}: <span className="text-error-400">{displayAmount} {currencySymbol}</span> {t('balance.missing')}:{' '}
<span className="text-error-400">
{displayAmount} {currencySymbol}
</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<button <button
onClick={() => setShowMethodSelect(true)} onClick={() => setShowMethodSelect(true)}
className="btn-primary w-full mt-4 py-2.5 flex items-center justify-center gap-2" className="btn-primary mt-4 flex w-full items-center justify-center gap-2 py-2.5"
> >
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg> </svg>
{t('balance.topUpBalance')} {t('balance.topUpBalance')}
@@ -131,80 +163,115 @@ export default function InsufficientBalancePrompt({
/> />
)} )}
</> </>
) );
} }
interface PaymentMethodModalProps { interface PaymentMethodModalProps {
paymentMethods: PaymentMethod[] | undefined paymentMethods: PaymentMethod[] | undefined;
onSelect: (method: PaymentMethod) => void onSelect: (method: PaymentMethod) => void;
onClose: () => void onClose: () => void;
} }
function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethodModalProps) { function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethodModalProps) {
const { t } = useTranslation() const { t } = useTranslation();
const { formatAmount, currencySymbol } = useCurrency() const { formatAmount, currencySymbol } = useCurrency();
return ( return (
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[60] flex items-center justify-center px-4 pt-14 pb-28 sm:pt-0 sm:pb-0"> <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 px-4 pb-28 pt-14 backdrop-blur-sm sm:pb-0 sm:pt-0">
<div className="absolute inset-0" onClick={onClose} /> <div className="absolute inset-0" onClick={onClose} />
<div className="relative w-full max-w-sm bg-dark-900/95 backdrop-blur-xl rounded-3xl border border-dark-700/50 shadow-2xl overflow-hidden max-h-full flex flex-col"> <div className="relative flex max-h-full w-full max-w-sm flex-col overflow-hidden rounded-3xl border border-dark-700/50 bg-dark-900/95 shadow-2xl backdrop-blur-xl">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50"> <div className="flex items-center justify-between bg-dark-800/50 px-4 py-3">
<span className="font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</span> <span className="font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</span>
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400" aria-label="Close"> <button
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> onClick={onClose}
className="rounded-lg p-1.5 text-dark-400 hover:bg-dark-700"
aria-label="Close"
>
<svg
className="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="flex-1 overflow-y-auto p-3 space-y-2"> <div className="flex-1 space-y-2 overflow-y-auto p-3">
{!paymentMethods ? ( {!paymentMethods ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<div className="w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" /> <div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
</div> </div>
) : paymentMethods.length === 0 ? ( ) : paymentMethods.length === 0 ? (
<div className="text-center py-6 text-dark-400 text-sm"> <div className="py-6 text-center text-sm text-dark-400">
{t('balance.noPaymentMethods')} {t('balance.noPaymentMethods')}
</div> </div>
) : ( ) : (
paymentMethods.map((method) => { paymentMethods.map((method) => {
const methodKey = method.id.toLowerCase().replace(/-/g, '_') const methodKey = method.id.toLowerCase().replace(/-/g, '_');
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) const translatedName = t(`balance.paymentMethods.${methodKey}.name`, {
defaultValue: '',
});
return ( return (
<button <button
key={method.id} key={method.id}
disabled={!method.is_available} disabled={!method.is_available}
onClick={() => method.is_available && onSelect(method)} onClick={() => method.is_available && onSelect(method)}
className={`w-full p-3 rounded-xl text-left flex items-center gap-3 ${ className={`flex w-full items-center gap-3 rounded-xl p-3 text-left ${
method.is_available method.is_available
? 'bg-dark-800 hover:bg-dark-700 active:bg-dark-600' ? 'bg-dark-800 hover:bg-dark-700 active:bg-dark-600'
: 'bg-dark-800/50 opacity-50' : 'bg-dark-800/50 opacity-50'
}`} }`}
> >
<div className="w-9 h-9 rounded-lg bg-accent-500/20 flex items-center justify-center flex-shrink-0"> <div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-accent-500/20">
<svg className="w-4 h-4 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" /> className="h-4 w-4 text-accent-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
/>
</svg> </svg>
</div> </div>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="font-medium text-dark-100 text-sm">{translatedName || method.name}</div> <div className="text-sm font-medium text-dark-100">
{translatedName || method.name}
</div>
<div className="text-xs text-dark-500"> <div className="text-xs text-dark-500">
{formatAmount(method.min_amount_kopeks / 100, 0)} {formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol} {formatAmount(method.min_amount_kopeks / 100, 0)} {' '}
{formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
</div> </div>
</div> </div>
<svg className="w-4 h-4 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" /> className="h-4 w-4 text-dark-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg> </svg>
</button> </button>
) );
}) })
)} )}
</div> </div>
</div> </div>
</div> </div>
) );
} }

View File

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

View File

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

View File

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

View File

@@ -1,144 +1,165 @@
import { useState } from 'react' import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { promoApi, PromoOffer } from '../api/promo' import { promoApi, PromoOffer } from '../api/promo';
// Icons // Icons
const GiftIcon = () => ( const GiftIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
/>
</svg> </svg>
) );
const ClockIcon = () => ( const ClockIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
) );
const SparklesIcon = () => ( const SparklesIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
/>
</svg> </svg>
) );
const CheckIcon = () => ( const CheckIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg> </svg>
) );
const ServerIcon = () => ( const ServerIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
/>
</svg> </svg>
) );
// Helper functions // Helper functions
const formatTimeLeft = (expiresAt: string): string => { const formatTimeLeft = (expiresAt: string): string => {
const now = new Date() const now = new Date();
// Ensure UTC parsing - if no timezone specified, assume UTC // Ensure UTC parsing - if no timezone specified, assume UTC
let expires: Date let expires: Date;
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) { if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
expires = new Date(expiresAt) expires = new Date(expiresAt);
} else { } else {
// No timezone - treat as UTC // No timezone - treat as UTC
expires = new Date(expiresAt + 'Z') expires = new Date(expiresAt + 'Z');
} }
const diffMs = expires.getTime() - now.getTime() const diffMs = expires.getTime() - now.getTime();
if (diffMs <= 0) return 'Истекло' if (diffMs <= 0) return 'Истекло';
const hours = Math.floor(diffMs / (1000 * 60 * 60)) const hours = Math.floor(diffMs / (1000 * 60 * 60));
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) { if (hours > 24) {
const days = Math.floor(hours / 24) const days = Math.floor(hours / 24);
return `${days} дн.` return `${days} дн.`;
} }
if (hours > 0) { if (hours > 0) {
return `${hours}ч ${minutes}м` return `${hours}ч ${minutes}м`;
} }
return `${minutes}м` return `${minutes}м`;
} };
const getOfferIcon = (effectType: string) => { const getOfferIcon = (effectType: string) => {
if (effectType === 'test_access') return <ServerIcon /> if (effectType === 'test_access') return <ServerIcon />;
return <SparklesIcon /> return <SparklesIcon />;
} };
const getOfferTitle = (offer: PromoOffer): string => { const getOfferTitle = (offer: PromoOffer): string => {
if (offer.effect_type === 'test_access') { if (offer.effect_type === 'test_access') {
return 'Тестовый доступ' return 'Тестовый доступ';
} }
if (offer.discount_percent) { if (offer.discount_percent) {
return `Скидка ${offer.discount_percent}%` return `Скидка ${offer.discount_percent}%`;
} }
return 'Специальное предложение' return 'Специальное предложение';
} };
const getOfferDescription = (offer: PromoOffer): string => { const getOfferDescription = (offer: PromoOffer): string => {
if (offer.effect_type === 'test_access') { if (offer.effect_type === 'test_access') {
const squads = offer.extra_data?.test_squad_uuids?.length || 0 const squads = offer.extra_data?.test_squad_uuids?.length || 0;
return squads > 0 ? `Доступ к ${squads} серверам` : 'Доступ к дополнительным серверам' return squads > 0 ? `Доступ к ${squads} серверам` : 'Доступ к дополнительным серверам';
} }
return 'Активируйте скидку на покупку подписки' return 'Активируйте скидку на покупку подписки';
} };
interface PromoOffersSectionProps { interface PromoOffersSectionProps {
className?: string className?: string;
} }
export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) { export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) {
const queryClient = useQueryClient() const queryClient = useQueryClient();
const [claimingId, setClaimingId] = useState<number | null>(null) const [claimingId, setClaimingId] = useState<number | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null) const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Fetch available offers // Fetch available offers
const { data: offers = [], isLoading: offersLoading } = useQuery({ const { data: offers = [], isLoading: offersLoading } = useQuery({
queryKey: ['promo-offers'], queryKey: ['promo-offers'],
queryFn: promoApi.getOffers, queryFn: promoApi.getOffers,
staleTime: 30000, staleTime: 30000,
}) });
// Fetch active discount // Fetch active discount
const { data: activeDiscount } = useQuery({ const { data: activeDiscount } = useQuery({
queryKey: ['active-discount'], queryKey: ['active-discount'],
queryFn: promoApi.getActiveDiscount, queryFn: promoApi.getActiveDiscount,
staleTime: 30000, staleTime: 30000,
}) });
// Claim offer mutation // Claim offer mutation
const claimMutation = useMutation({ const claimMutation = useMutation({
mutationFn: promoApi.claimOffer, mutationFn: promoApi.claimOffer,
onSuccess: (result) => { onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['promo-offers'] }) queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
queryClient.invalidateQueries({ queryKey: ['active-discount'] }) queryClient.invalidateQueries({ queryKey: ['active-discount'] });
queryClient.invalidateQueries({ queryKey: ['subscription'] }) queryClient.invalidateQueries({ queryKey: ['subscription'] });
setSuccessMessage(result.message) setSuccessMessage(result.message);
setClaimingId(null) setClaimingId(null);
setTimeout(() => setSuccessMessage(null), 5000) setTimeout(() => setSuccessMessage(null), 5000);
}, },
onError: (error: any) => { onError: (error: unknown) => {
setErrorMessage(error.response?.data?.detail || 'Не удалось активировать предложение') const axiosErr = error as { response?: { data?: { detail?: string } } };
setClaimingId(null) setErrorMessage(axiosErr.response?.data?.detail || 'Не удалось активировать предложение');
setTimeout(() => setErrorMessage(null), 5000) setClaimingId(null);
setTimeout(() => setErrorMessage(null), 5000);
}, },
}) });
const handleClaim = (offerId: number) => { const handleClaim = (offerId: number) => {
setClaimingId(offerId) setClaimingId(offerId);
setErrorMessage(null) setErrorMessage(null);
setSuccessMessage(null) setSuccessMessage(null);
claimMutation.mutate(offerId) claimMutation.mutate(offerId);
} };
// Filter unclaimed and active offers // Filter unclaimed and active offers
const availableOffers = offers.filter(o => o.is_active && !o.is_claimed) const availableOffers = offers.filter((o) => o.is_active && !o.is_claimed);
// Don't render if no offers and no active discount // Don't render if no offers and no active discount
if (!offersLoading && availableOffers.length === 0 && (!activeDiscount || !activeDiscount.is_active)) { if (
return null !offersLoading &&
availableOffers.length === 0 &&
(!activeDiscount || !activeDiscount.is_active)
) {
return null;
} }
return ( return (
@@ -147,15 +168,15 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
{activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && ( {activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && (
<div className="card border-accent-500/30 bg-gradient-to-br from-accent-500/10 to-transparent"> <div className="card border-accent-500/30 bg-gradient-to-br from-accent-500/10 to-transparent">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-accent-500/20 flex items-center justify-center flex-shrink-0 text-accent-400"> <div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-accent-500/20 text-accent-400">
<CheckIcon /> <CheckIcon />
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="mb-1 flex items-center gap-2">
<h3 className="font-semibold text-dark-100"> <h3 className="font-semibold text-dark-100">
Скидка {activeDiscount.discount_percent}% активна Скидка {activeDiscount.discount_percent}% активна
</h3> </h3>
<span className="px-2 py-0.5 text-xs bg-accent-500/20 text-accent-400 rounded"> <span className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
Действует Действует
</span> </span>
</div> </div>
@@ -174,14 +195,14 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
{/* Success/Error Messages */} {/* Success/Error Messages */}
{successMessage && ( {successMessage && (
<div className="p-4 bg-success-500/10 border border-success-500/30 text-success-400 rounded-xl flex items-center gap-3"> <div className="flex items-center gap-3 rounded-xl border border-success-500/30 bg-success-500/10 p-4 text-success-400">
<CheckIcon /> <CheckIcon />
<span>{successMessage}</span> <span>{successMessage}</span>
</div> </div>
)} )}
{errorMessage && ( {errorMessage && (
<div className="p-4 bg-error-500/10 border border-error-500/30 text-error-400 rounded-xl"> <div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-error-400">
{errorMessage} {errorMessage}
</div> </div>
)} )}
@@ -192,26 +213,22 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
{availableOffers.map((offer) => ( {availableOffers.map((offer) => (
<div <div
key={offer.id} key={offer.id}
className="card border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-transparent hover:border-orange-500/50 transition-colors" className="card border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-transparent transition-colors hover:border-orange-500/50"
> >
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-xl bg-orange-500/20 flex items-center justify-center flex-shrink-0 text-orange-400"> <div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-orange-500/20 text-orange-400">
{getOfferIcon(offer.effect_type)} {getOfferIcon(offer.effect_type)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="mb-1 flex items-center gap-2">
<h3 className="font-semibold text-dark-100"> <h3 className="font-semibold text-dark-100">{getOfferTitle(offer)}</h3>
{getOfferTitle(offer)}
</h3>
{offer.effect_type === 'test_access' && ( {offer.effect_type === 'test_access' && (
<span className="px-2 py-0.5 text-xs bg-purple-500/20 text-purple-400 rounded"> <span className="rounded bg-purple-500/20 px-2 py-0.5 text-xs text-purple-400">
Тест Тест
</span> </span>
)} )}
</div> </div>
<p className="text-sm text-dark-400 mb-3"> <p className="mb-3 text-sm text-dark-400">{getOfferDescription(offer)}</p>
{getOfferDescription(offer)}
</p>
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-1 text-xs text-dark-500"> <div className="flex items-center gap-1 text-xs text-dark-500">
<ClockIcon /> <ClockIcon />
@@ -220,11 +237,11 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
<button <button
onClick={() => handleClaim(offer.id)} onClick={() => handleClaim(offer.id)}
disabled={claimingId === offer.id} disabled={claimingId === offer.id}
className="px-4 py-2 bg-orange-500 text-white text-sm font-medium rounded-lg hover:bg-orange-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" className="flex items-center gap-2 rounded-lg bg-orange-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-50"
> >
{claimingId === offer.id ? ( {claimingId === offer.id ? (
<> <>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
<span>Активация...</span> <span>Активация...</span>
</> </>
) : ( ) : (
@@ -246,14 +263,14 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
{offersLoading && ( {offersLoading && (
<div className="card"> <div className="card">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-dark-700 animate-pulse" /> <div className="h-12 w-12 animate-pulse rounded-xl bg-dark-700" />
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<div className="h-5 w-32 bg-dark-700 rounded animate-pulse" /> <div className="h-5 w-32 animate-pulse rounded bg-dark-700" />
<div className="h-4 w-48 bg-dark-700 rounded animate-pulse" /> <div className="h-4 w-48 animate-pulse rounded bg-dark-700" />
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
) );
} }

View File

@@ -1,47 +1,45 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
interface TelegramLoginButtonProps { interface TelegramLoginButtonProps {
botUsername: string botUsername: string;
} }
export default function TelegramLoginButton({ export default function TelegramLoginButton({ botUsername }: TelegramLoginButtonProps) {
botUsername, const { t } = useTranslation();
}: TelegramLoginButtonProps) { const containerRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation()
const containerRef = useRef<HTMLDivElement>(null)
// Load widget script // Load widget script
useEffect(() => { useEffect(() => {
if (!containerRef.current || !botUsername) return if (!containerRef.current || !botUsername) return;
// Clear previous widget using safe DOM API // Clear previous widget using safe DOM API
while (containerRef.current.firstChild) { while (containerRef.current.firstChild) {
containerRef.current.removeChild(containerRef.current.firstChild) containerRef.current.removeChild(containerRef.current.firstChild);
} }
// Get current URL for redirect // Get current URL for redirect
const redirectUrl = `${window.location.origin}/auth/telegram/callback` const redirectUrl = `${window.location.origin}/auth/telegram/callback`;
// Create script element for Telegram Login Widget // Create script element for Telegram Login Widget
const script = document.createElement('script') const script = document.createElement('script');
script.src = 'https://telegram.org/js/telegram-widget.js?22' script.src = 'https://telegram.org/js/telegram-widget.js?22';
script.setAttribute('data-telegram-login', botUsername) script.setAttribute('data-telegram-login', botUsername);
script.setAttribute('data-size', 'large') script.setAttribute('data-size', 'large');
script.setAttribute('data-radius', '8') script.setAttribute('data-radius', '8');
script.setAttribute('data-auth-url', redirectUrl) script.setAttribute('data-auth-url', redirectUrl);
script.setAttribute('data-request-access', 'write') script.setAttribute('data-request-access', 'write');
script.async = true script.async = true;
containerRef.current.appendChild(script) containerRef.current.appendChild(script);
}, [botUsername]) }, [botUsername]);
if (!botUsername || botUsername === 'your_bot') { if (!botUsername || botUsername === 'your_bot') {
return ( return (
<div className="text-center text-gray-500 text-sm py-4"> <div className="py-4 text-center text-sm text-gray-500">
{t('auth.telegramNotConfigured')} {t('auth.telegramNotConfigured')}
</div> </div>
) );
} }
return ( return (
@@ -51,19 +49,19 @@ export default function TelegramLoginButton({
{/* Fallback link for mobile */} {/* Fallback link for mobile */}
<div className="text-center"> <div className="text-center">
<p className="text-xs text-gray-500 mb-2">{t('auth.orOpenInApp')}</p> <p className="mb-2 text-xs text-gray-500">{t('auth.orOpenInApp')}</p>
<a <a
href={`https://t.me/${botUsername}`} href={`https://t.me/${botUsername}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center text-sm text-telegram-blue hover:underline" className="text-telegram-blue inline-flex items-center text-sm hover:underline"
> >
<svg className="w-4 h-4 mr-1" viewBox="0 0 24 24" fill="currentColor"> <svg className="mr-1 h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/> <path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
</svg> </svg>
@{botUsername} @{botUsername}
</a> </a>
</div> </div>
</div> </div>
) );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,91 +1,141 @@
// Admin Settings Icons // Admin Settings Icons
export const BackIcon = () => ( export const BackIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg> </svg>
) );
export const SearchIcon = () => ( export const SearchIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
/>
</svg> </svg>
) );
export const StarIcon = ({ filled }: { filled?: boolean }) => ( export const StarIcon = ({ filled }: { filled?: boolean }) => (
<svg className="w-5 h-5" fill={filled ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" /> className="h-5 w-5"
fill={filled ? 'currentColor' : 'none'}
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
/>
</svg> </svg>
) );
export const ChevronDownIcon = () => ( export const ChevronDownIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg> </svg>
) );
export const UploadIcon = () => ( export const UploadIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
/>
</svg> </svg>
) );
export const TrashIcon = () => ( export const TrashIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg> </svg>
) );
export const PencilIcon = () => ( export const PencilIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
/>
</svg> </svg>
) );
export const RefreshIcon = () => ( export const RefreshIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg> </svg>
) );
export const LockIcon = () => ( export const LockIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
/>
</svg> </svg>
) );
export const CheckIcon = () => ( export const CheckIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg> </svg>
) );
export const CloseIcon = () => ( export const CloseIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
) );
export const SunIcon = () => ( export const SunIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg> </svg>
) );
export const MoonIcon = () => ( export const MoonIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</svg> </svg>
) );
export const MenuIcon = () => ( export const MenuIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg> </svg>
) );
export const EditIcon = () => ( export const EditIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125"
/>
</svg> </svg>
) );

View File

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

View File

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

View File

@@ -1,97 +1,99 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { useBlockingStore } from '../../store/blocking' import { useBlockingStore } from '../../store/blocking';
import { apiClient } from '../../api/client' import { apiClient } from '../../api/client';
const CHECK_COOLDOWN_SECONDS = 5 const CHECK_COOLDOWN_SECONDS = 5;
export default function ChannelSubscriptionScreen() { export default function ChannelSubscriptionScreen() {
const { t } = useTranslation() const { t } = useTranslation();
const { channelInfo, clearBlocking } = useBlockingStore() const { channelInfo, clearBlocking } = useBlockingStore();
const [isChecking, setIsChecking] = useState(false) const [isChecking, setIsChecking] = useState(false);
const [cooldown, setCooldown] = useState(0) const [cooldown, setCooldown] = useState(0);
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null);
// Cooldown timer // Cooldown timer
useEffect(() => { useEffect(() => {
if (cooldown <= 0) return if (cooldown <= 0) return;
const timer = setInterval(() => { const timer = setInterval(() => {
setCooldown((prev) => { setCooldown((prev) => {
if (prev <= 1) { if (prev <= 1) {
clearInterval(timer) clearInterval(timer);
return 0 return 0;
} }
return prev - 1 return prev - 1;
}) });
}, 1000) }, 1000);
return () => clearInterval(timer) return () => clearInterval(timer);
}, [cooldown]) }, [cooldown]);
const openChannel = useCallback(() => { const openChannel = useCallback(() => {
if (channelInfo?.channel_link) { if (channelInfo?.channel_link) {
window.open(channelInfo.channel_link, '_blank') window.open(channelInfo.channel_link, '_blank');
} }
}, [channelInfo?.channel_link]) }, [channelInfo?.channel_link]);
const checkSubscription = useCallback(async () => { const checkSubscription = useCallback(async () => {
if (isChecking || cooldown > 0) return if (isChecking || cooldown > 0) return;
setIsChecking(true) setIsChecking(true);
setError(null) setError(null);
try { try {
// Make any authenticated request - if channel check passes, it will succeed // Make any authenticated request - if channel check passes, it will succeed
await apiClient.get('/cabinet/auth/me') await apiClient.get('/cabinet/auth/me');
// If we get here, subscription is valid - reload page // If we get here, subscription is valid - reload page
clearBlocking() clearBlocking();
window.location.reload() window.location.reload();
} catch (err: unknown) { } catch (err: unknown) {
// Check if it's still a channel subscription error // Check if it's still a channel subscription error
const error = err as { response?: { status?: number; data?: { detail?: { code?: string } } } } const error = err as {
if (error.response?.status === 403 && error.response?.data?.detail?.code === 'channel_subscription_required') { response?: { status?: number; data?: { detail?: { code?: string } } };
setError(t('blocking.channel.notSubscribed', 'Вы ещё не подписались на канал')) };
if (
error.response?.status === 403 &&
error.response?.data?.detail?.code === 'channel_subscription_required'
) {
setError(t('blocking.channel.notSubscribed', 'Вы ещё не подписались на канал'));
} else { } else {
// Other error - might be network issue // Other error - might be network issue
setError(t('blocking.channel.checkError', 'Ошибка проверки. Попробуйте позже.')) setError(t('blocking.channel.checkError', 'Ошибка проверки. Попробуйте позже.'));
} }
} finally { } finally {
setIsChecking(false) setIsChecking(false);
setCooldown(CHECK_COOLDOWN_SECONDS) setCooldown(CHECK_COOLDOWN_SECONDS);
} }
}, [isChecking, cooldown, clearBlocking, t]) }, [isChecking, cooldown, clearBlocking, t]);
return ( return (
<div className="fixed inset-0 z-[100] bg-dark-950 flex flex-col items-center justify-center p-6"> <div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
<div className="w-full max-w-md text-center"> <div className="w-full max-w-md text-center">
{/* Icon */} {/* Icon */}
<div className="mb-8"> <div className="mb-8">
<div className="w-24 h-24 mx-auto rounded-full bg-gradient-to-br from-blue-500/20 to-cyan-500/20 flex items-center justify-center"> <div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br from-blue-500/20 to-cyan-500/20">
<svg <svg className="h-12 w-12 text-blue-400" fill="currentColor" viewBox="0 0 24 24">
className="w-12 h-12 text-blue-400" <path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg> </svg>
</div> </div>
</div> </div>
{/* Title */} {/* Title */}
<h1 className="text-2xl font-bold text-white mb-4"> <h1 className="mb-4 text-2xl font-bold text-white">
{t('blocking.channel.title', 'Подписка на канал')} {t('blocking.channel.title', 'Подписка на канал')}
</h1> </h1>
{/* Message */} {/* Message */}
<p className="text-gray-400 mb-8 text-lg"> <p className="mb-8 text-lg text-gray-400">
{channelInfo?.message || t('blocking.channel.defaultMessage', 'Для продолжения работы подпишитесь на наш канал')} {channelInfo?.message ||
t('blocking.channel.defaultMessage', 'Для продолжения работы подпишитесь на наш канал')}
</p> </p>
{/* Error message */} {/* Error message */}
{error && ( {error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4 mb-6"> <div className="mb-6 rounded-xl border border-red-500/30 bg-red-500/10 p-4">
<p className="text-red-400 text-sm">{error}</p> <p className="text-sm text-red-400">{error}</p>
</div> </div>
)} )}
@@ -101,14 +103,10 @@ export default function ChannelSubscriptionScreen() {
<button <button
onClick={openChannel} onClick={openChannel}
disabled={!channelInfo?.channel_link} disabled={!channelInfo?.channel_link}
className="w-full py-4 px-6 bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600 disabled:from-gray-600 disabled:to-gray-600 text-white font-semibold rounded-xl transition-all duration-200 flex items-center justify-center gap-3" className="flex w-full items-center justify-center gap-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 px-6 py-4 font-semibold text-white transition-all duration-200 hover:from-blue-600 hover:to-cyan-600 disabled:from-gray-600 disabled:to-gray-600"
> >
<svg <svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
className="w-5 h-5" <path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg> </svg>
{t('blocking.channel.openChannel', 'Открыть канал')} {t('blocking.channel.openChannel', 'Открыть канал')}
</button> </button>
@@ -117,27 +115,60 @@ export default function ChannelSubscriptionScreen() {
<button <button
onClick={checkSubscription} onClick={checkSubscription}
disabled={isChecking || cooldown > 0} disabled={isChecking || cooldown > 0}
className="w-full py-4 px-6 bg-dark-800 hover:bg-dark-700 disabled:bg-dark-800 disabled:opacity-60 text-white font-semibold rounded-xl transition-all duration-200 flex items-center justify-center gap-3" className="flex w-full items-center justify-center gap-3 rounded-xl bg-dark-800 px-6 py-4 font-semibold text-white transition-all duration-200 hover:bg-dark-700 disabled:bg-dark-800 disabled:opacity-60"
> >
{isChecking ? ( {isChecking ? (
<> <>
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> <svg
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> className="h-5 w-5 animate-spin"
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg> </svg>
{t('blocking.channel.checking', 'Проверяем...')} {t('blocking.channel.checking', 'Проверяем...')}
</> </>
) : cooldown > 0 ? ( ) : cooldown > 0 ? (
<> <>
<svg className="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /> className="h-5 w-5 text-gray-500"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg> </svg>
{t('blocking.channel.waitSeconds', 'Подождите {{seconds}} сек.', { seconds: cooldown })} {t('blocking.channel.waitSeconds', 'Подождите {{seconds}} сек.', {
seconds: cooldown,
})}
</> </>
) : ( ) : (
<> <>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg> </svg>
{t('blocking.channel.checkSubscription', 'Проверить подписку')} {t('blocking.channel.checkSubscription', 'Проверить подписку')}
</> </>
@@ -146,10 +177,10 @@ export default function ChannelSubscriptionScreen() {
</div> </div>
{/* Hint */} {/* Hint */}
<p className="text-gray-500 text-sm mt-6"> <p className="mt-6 text-sm text-gray-500">
{t('blocking.channel.hint', 'После подписки нажмите кнопку проверки')} {t('blocking.channel.hint', 'После подписки нажмите кнопку проверки')}
</p> </p>
</div> </div>
</div> </div>
) );
} }

View File

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

View File

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

View File

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

View File

@@ -1,274 +1,357 @@
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { useState, useEffect, useMemo } from 'react' import { useState, useEffect, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query';
import { useAuthStore } from '../../store/auth' import { useAuthStore } from '../../store/auth';
import LanguageSwitcher from '../LanguageSwitcher' import LanguageSwitcher from '../LanguageSwitcher';
import PromoDiscountBadge from '../PromoDiscountBadge' import PromoDiscountBadge from '../PromoDiscountBadge';
import TicketNotificationBell from '../TicketNotificationBell' import TicketNotificationBell from '../TicketNotificationBell';
import AnimatedBackground from '../AnimatedBackground' import AnimatedBackground from '../AnimatedBackground';
import { contestsApi } from '../../api/contests' import { contestsApi } from '../../api/contests';
import { pollsApi } from '../../api/polls' import { pollsApi } from '../../api/polls';
import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo, isLogoPreloaded } from '../../api/branding' import {
import { wheelApi } from '../../api/wheel' brandingApi,
import { themeColorsApi } from '../../api/themeColors' getCachedBranding,
import { promoApi } from '../../api/promo' setCachedBranding,
import { referralApi } from '../../api/referral' preloadLogo,
import { useTheme } from '../../hooks/useTheme' isLogoPreloaded,
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp' } from '../../api/branding';
import { usePullToRefresh } from '../../hooks/usePullToRefresh' import { wheelApi } from '../../api/wheel';
import { themeColorsApi } from '../../api/themeColors';
import { promoApi } from '../../api/promo';
import { referralApi } from '../../api/referral';
import { useTheme } from '../../hooks/useTheme';
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp';
import { usePullToRefresh } from '../../hooks/usePullToRefresh';
// Fallback branding from environment variables // Fallback branding from environment variables
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet' const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V' const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
interface LayoutProps { interface LayoutProps {
children: React.ReactNode children: React.ReactNode;
} }
// Icons as simple SVG components // Icons as simple SVG components
const HomeIcon = () => ( const HomeIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
/>
</svg> </svg>
) );
const SubscriptionIcon = () => ( const SubscriptionIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
/>
</svg> </svg>
) );
const WalletIcon = () => ( const WalletIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
/>
</svg> </svg>
) );
const UsersIcon = () => ( const UsersIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg> </svg>
) );
const ChatIcon = () => ( const ChatIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
/>
</svg> </svg>
) );
const UserIcon = () => ( const UserIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
/>
</svg> </svg>
) );
const LogoutIcon = () => ( const LogoutIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
/>
</svg> </svg>
) );
// Theme toggle icons // Theme toggle icons
const SunIcon = () => ( const SunIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg> </svg>
) );
const MoonIcon = () => ( const MoonIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
/>
</svg> </svg>
) );
const MenuIcon = () => ( const MenuIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg> </svg>
) );
const CloseIcon = () => ( const CloseIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
) );
const GamepadIcon = () => ( const GamepadIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.959.401v0a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.78.78 0 01.79-.869" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.959.401v0a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.78.78 0 01.79-.869"
/>
</svg> </svg>
) );
const ClipboardIcon = () => ( const ClipboardIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z"
/>
</svg> </svg>
) );
const InfoIcon = () => ( const InfoIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
/>
</svg> </svg>
) );
const CogIcon = () => ( const CogIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
/>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg> </svg>
) );
const WheelIcon = () => ( const WheelIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
/>
</svg> </svg>
) );
export default function Layout({ children }: LayoutProps) { export default function Layout({ children }: LayoutProps) {
const { t } = useTranslation() const { t } = useTranslation();
const location = useLocation() const location = useLocation();
const { user, logout, isAdmin, isAuthenticated } = useAuthStore() const { user, logout, isAdmin, isAuthenticated } = useAuthStore();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false) const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
const { toggleTheme, isDark } = useTheme() const { toggleTheme, isDark } = useTheme();
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null) const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null);
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp() const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
// Pull to refresh (disabled when mobile menu is open) // Pull to refresh (disabled when mobile menu is open)
const { isPulling, pullDistance, isRefreshing, progress } = usePullToRefresh({ const { isPulling, pullDistance, isRefreshing, progress } = usePullToRefresh({
disabled: mobileMenuOpen, disabled: mobileMenuOpen,
threshold: 80, threshold: 80,
}) });
// Fetch enabled themes from API - same source of truth as AdminSettings // Fetch enabled themes from API - same source of truth as AdminSettings
const { data: enabledThemes } = useQuery({ const { data: enabledThemes } = useQuery({
queryKey: ['enabled-themes'], queryKey: ['enabled-themes'],
queryFn: themeColorsApi.getEnabledThemes, queryFn: themeColorsApi.getEnabledThemes,
staleTime: 1000 * 60 * 5, // 5 minutes staleTime: 1000 * 60 * 5, // 5 minutes
}) });
// Only show theme toggle if both themes are enabled // Only show theme toggle if both themes are enabled
const canToggle = enabledThemes?.dark && enabledThemes?.light const canToggle = enabledThemes?.dark && enabledThemes?.light;
// Get user photo from Telegram WebApp // Get user photo from Telegram WebApp
useEffect(() => { useEffect(() => {
try { try {
const tg = (window as any).Telegram?.WebApp const tg = (
const photoUrl = tg?.initDataUnsafe?.user?.photo_url window as unknown as {
Telegram?: { WebApp?: { initDataUnsafe?: { user?: { photo_url?: string } } } };
}
).Telegram?.WebApp;
const photoUrl = tg?.initDataUnsafe?.user?.photo_url;
if (photoUrl) { if (photoUrl) {
setUserPhotoUrl(photoUrl) setUserPhotoUrl(photoUrl);
} }
} catch (e) { } catch (e) {
console.warn('Failed to get Telegram user photo:', e) console.warn('Failed to get Telegram user photo:', e);
} }
}, []) }, []);
// Lock body scroll when mobile menu is open (cross-platform) // Lock body scroll when mobile menu is open (cross-platform)
// Note: We avoid using body position:fixed with top:-scrollY as it causes issues // Note: We avoid using body position:fixed with top:-scrollY as it causes issues
// in Telegram Mini App where the menu disappears when opened from scrolled position // in Telegram Mini App where the menu disappears when opened from scrolled position
useEffect(() => { useEffect(() => {
if (!mobileMenuOpen) return if (!mobileMenuOpen) return;
const body = document.body const body = document.body;
const html = document.documentElement const html = document.documentElement;
// Save original styles // Save original styles
const originalStyles = { const originalStyles = {
bodyOverflow: body.style.overflow, bodyOverflow: body.style.overflow,
htmlOverflow: html.style.overflow, htmlOverflow: html.style.overflow,
} };
// Lock scroll - simple approach without body position manipulation // Lock scroll - simple approach without body position manipulation
body.style.overflow = 'hidden' body.style.overflow = 'hidden';
html.style.overflow = 'hidden' html.style.overflow = 'hidden';
// Prevent touchmove on body (critical for mobile, especially Telegram Mini App) // Prevent touchmove on body (critical for mobile, especially Telegram Mini App)
const preventScroll = (e: TouchEvent) => { const preventScroll = (e: TouchEvent) => {
const target = e.target as HTMLElement const target = e.target as HTMLElement;
// Allow scroll inside menu content // Allow scroll inside menu content
if (target.closest('.mobile-menu-content')) return if (target.closest('.mobile-menu-content')) return;
e.preventDefault() e.preventDefault();
} };
document.addEventListener('touchmove', preventScroll, { passive: false }) document.addEventListener('touchmove', preventScroll, { passive: false });
// Also prevent wheel scroll on desktop // Also prevent wheel scroll on desktop
const preventWheel = (e: WheelEvent) => { const preventWheel = (e: WheelEvent) => {
const target = e.target as HTMLElement const target = e.target as HTMLElement;
if (target.closest('.mobile-menu-content')) return if (target.closest('.mobile-menu-content')) return;
e.preventDefault() e.preventDefault();
} };
document.addEventListener('wheel', preventWheel, { passive: false }) document.addEventListener('wheel', preventWheel, { passive: false });
return () => { return () => {
// Restore original styles // Restore original styles
body.style.overflow = originalStyles.bodyOverflow body.style.overflow = originalStyles.bodyOverflow;
html.style.overflow = originalStyles.htmlOverflow html.style.overflow = originalStyles.htmlOverflow;
// Remove listeners // Remove listeners
document.removeEventListener('touchmove', preventScroll) document.removeEventListener('touchmove', preventScroll);
document.removeEventListener('wheel', preventWheel) document.removeEventListener('wheel', preventWheel);
} };
}, [mobileMenuOpen]) }, [mobileMenuOpen]);
// Detect virtual keyboard by tracking focus on input elements // Detect virtual keyboard by tracking focus on input elements
useEffect(() => { useEffect(() => {
const handleFocusIn = (e: FocusEvent) => { const handleFocusIn = (e: FocusEvent) => {
const target = e.target as HTMLElement const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
setIsKeyboardOpen(true) setIsKeyboardOpen(true);
} }
} };
const handleFocusOut = (e: FocusEvent) => { const handleFocusOut = (e: FocusEvent) => {
const relatedTarget = e.relatedTarget as HTMLElement | null const relatedTarget = e.relatedTarget as HTMLElement | null;
// Only close if not focusing another input // Only close if not focusing another input
if (!relatedTarget || if (
(relatedTarget.tagName !== 'INPUT' && !relatedTarget ||
relatedTarget.tagName !== 'TEXTAREA' && (relatedTarget.tagName !== 'INPUT' &&
!relatedTarget.isContentEditable)) { relatedTarget.tagName !== 'TEXTAREA' &&
setIsKeyboardOpen(false) !relatedTarget.isContentEditable)
) {
setIsKeyboardOpen(false);
} }
} };
document.addEventListener('focusin', handleFocusIn) document.addEventListener('focusin', handleFocusIn);
document.addEventListener('focusout', handleFocusOut) document.addEventListener('focusout', handleFocusOut);
return () => { return () => {
document.removeEventListener('focusin', handleFocusIn) document.removeEventListener('focusin', handleFocusIn);
document.removeEventListener('focusout', handleFocusOut) document.removeEventListener('focusout', handleFocusOut);
} };
}, []) }, []);
// State to track if logo image has loaded - start with true if already preloaded // State to track if logo image has loaded - start with true if already preloaded
const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded()) const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded());
// Fetch branding settings with localStorage cache for instant load // Fetch branding settings with localStorage cache for instant load
const { data: branding } = useQuery({ const { data: branding } = useQuery({
queryKey: ['branding'], queryKey: ['branding'],
queryFn: async () => { queryFn: async () => {
const data = await brandingApi.getBranding() const data = await brandingApi.getBranding();
setCachedBranding(data) // Update cache setCachedBranding(data); // Update cache
// Preload logo in background // Preload logo in background
preloadLogo(data) preloadLogo(data);
return data return data;
}, },
initialData: getCachedBranding() ?? undefined, // Use cached data immediately initialData: getCachedBranding() ?? undefined, // Use cached data immediately
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
retry: 1, retry: 1,
}) });
// Computed branding values - use fallback only if no branding and no cache // Computed branding values - use fallback only if no branding and no cache
const appName = branding ? branding.name : FALLBACK_NAME // Empty string is valid (logo-only mode) const appName = branding ? branding.name : FALLBACK_NAME; // Empty string is valid (logo-only mode)
const logoLetter = branding?.logo_letter || FALLBACK_LOGO const logoLetter = branding?.logo_letter || FALLBACK_LOGO;
const hasCustomLogo = branding?.has_custom_logo || false const hasCustomLogo = branding?.has_custom_logo || false;
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
// Set document title // Set document title
useEffect(() => { useEffect(() => {
document.title = appName || 'VPN' // Fallback title if name is empty document.title = appName || 'VPN'; // Fallback title if name is empty
}, [appName]) }, [appName]);
// 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 // Fetch contests and polls counts to determine if they should be shown
const { data: contestsCount } = useQuery({ const { data: contestsCount } = useQuery({
@@ -277,7 +360,7 @@ export default function Layout({ children }: LayoutProps) {
enabled: isAuthenticated, enabled: isAuthenticated,
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
retry: false, retry: false,
}) });
const { data: pollsCount } = useQuery({ const { data: pollsCount } = useQuery({
queryKey: ['polls-count'], queryKey: ['polls-count'],
@@ -285,7 +368,7 @@ export default function Layout({ children }: LayoutProps) {
enabled: isAuthenticated, enabled: isAuthenticated,
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
retry: false, retry: false,
}) });
// Fetch wheel config to check if enabled // Fetch wheel config to check if enabled
const { data: wheelConfig } = useQuery({ const { data: wheelConfig } = useQuery({
@@ -294,7 +377,7 @@ export default function Layout({ children }: LayoutProps) {
enabled: isAuthenticated, enabled: isAuthenticated,
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
retry: false, retry: false,
}) });
// Fetch referral terms to check if enabled // Fetch referral terms to check if enabled
const { data: referralTerms } = useQuery({ const { data: referralTerms } = useQuery({
@@ -303,7 +386,7 @@ export default function Layout({ children }: LayoutProps) {
enabled: isAuthenticated, enabled: isAuthenticated,
staleTime: 60000, // 1 minute staleTime: 60000, // 1 minute
retry: false, retry: false,
}) });
// Fetch active discount to determine mobile layout // Fetch active discount to determine mobile layout
const { data: activeDiscount } = useQuery({ const { data: activeDiscount } = useQuery({
@@ -311,86 +394,90 @@ export default function Layout({ children }: LayoutProps) {
queryFn: promoApi.getActiveDiscount, queryFn: promoApi.getActiveDiscount,
enabled: isAuthenticated, enabled: isAuthenticated,
staleTime: 30000, staleTime: 30000,
}) });
// Check if promo is active (to hide language switcher on mobile) // Check if promo is active (to hide language switcher on mobile)
const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent;
const navItems = useMemo(() => { const navItems = useMemo(() => {
const items = [ const items = [
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon }, { path: '/', label: t('nav.dashboard'), icon: HomeIcon },
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon }, { path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon }, { path: '/balance', label: t('nav.balance'), icon: WalletIcon },
] ];
// Only show referral if program is enabled // Only show referral if program is enabled
if (referralTerms?.is_enabled) { if (referralTerms?.is_enabled) {
items.push({ path: '/referral', label: t('nav.referral'), icon: UsersIcon }) items.push({ path: '/referral', label: t('nav.referral'), icon: UsersIcon });
} }
items.push({ path: '/support', label: t('nav.support'), icon: ChatIcon }) items.push({ path: '/support', label: t('nav.support'), icon: ChatIcon });
// Only show contests if there are available contests // Only show contests if there are available contests
if (contestsCount && contestsCount.count > 0) { if (contestsCount && contestsCount.count > 0) {
items.push({ path: '/contests', label: t('nav.contests'), icon: GamepadIcon }) items.push({ path: '/contests', label: t('nav.contests'), icon: GamepadIcon });
} }
// Only show polls if there are available polls // Only show polls if there are available polls
if (pollsCount && pollsCount.count > 0) { if (pollsCount && pollsCount.count > 0) {
items.push({ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon }) items.push({ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon });
} }
items.push({ path: '/info', label: t('nav.info'), icon: InfoIcon }) items.push({ path: '/info', label: t('nav.info'), icon: InfoIcon });
return items return items;
}, [t, contestsCount, pollsCount, referralTerms]) }, [t, contestsCount, pollsCount, referralTerms]);
// Separate navItems for desktop that includes wheel (if enabled) // Separate navItems for desktop that includes wheel (if enabled)
const desktopNavItems = useMemo(() => { const desktopNavItems = useMemo(() => {
const items = [...navItems] const items = [...navItems];
// Add wheel before info if enabled // Add wheel before info if enabled
if (wheelConfig?.is_enabled) { if (wheelConfig?.is_enabled) {
const infoIndex = items.findIndex(item => item.path === '/info') const infoIndex = items.findIndex((item) => item.path === '/info');
if (infoIndex !== -1) { if (infoIndex !== -1) {
items.splice(infoIndex, 0, { path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }) items.splice(infoIndex, 0, { path: '/wheel', label: t('nav.wheel'), icon: WheelIcon });
} else { } else {
items.push({ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon }) items.push({ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon });
} }
} }
return items return items;
}, [navItems, wheelConfig, t]) }, [navItems, wheelConfig, t]);
const adminNavItems = [ const adminNavItems = [{ path: '/admin', label: t('admin.nav.title'), icon: CogIcon }];
{ path: '/admin', label: t('admin.nav.title'), icon: CogIcon },
]
const isActive = (path: string) => location.pathname === path const isActive = (path: string) => location.pathname === path;
const isAdminActive = () => location.pathname.startsWith('/admin') const isAdminActive = () => location.pathname.startsWith('/admin');
return ( return (
<div className="min-h-screen flex flex-col"> <div className="flex min-h-screen flex-col">
{/* Animated Background */} {/* Animated Background */}
<AnimatedBackground /> <AnimatedBackground />
{/* Pull to refresh indicator */} {/* Pull to refresh indicator */}
{(isPulling || isRefreshing) && ( {(isPulling || isRefreshing) && (
<div <div
className="fixed left-1/2 -translate-x-1/2 z-[100] flex items-center justify-center transition-all duration-200" className="fixed left-1/2 z-[100] flex -translate-x-1/2 items-center justify-center transition-all duration-200"
style={{ style={{
top: `calc(${Math.max(pullDistance, isRefreshing ? 40 : 0)}px + env(safe-area-inset-top, 0px) + 0.5rem)`, top: `calc(${Math.max(pullDistance, isRefreshing ? 40 : 0)}px + env(safe-area-inset-top, 0px) + 0.5rem)`,
opacity: isRefreshing ? 1 : progress, opacity: isRefreshing ? 1 : progress,
}} }}
> >
<div className={`w-10 h-10 rounded-full bg-dark-800 border border-dark-700 shadow-lg flex items-center justify-center ${isRefreshing ? 'animate-pulse' : ''}`}> <div
className={`flex h-10 w-10 items-center justify-center rounded-full border border-dark-700 bg-dark-800 shadow-lg ${isRefreshing ? 'animate-pulse' : ''}`}
>
<svg <svg
className={`w-5 h-5 text-accent-400 transition-transform duration-200 ${isRefreshing ? 'animate-spin' : ''}`} className={`h-5 w-5 text-accent-400 transition-transform duration-200 ${isRefreshing ? 'animate-spin' : ''}`}
style={{ transform: isRefreshing ? undefined : `rotate(${progress * 360}deg)` }} style={{ transform: isRefreshing ? undefined : `rotate(${progress * 360}deg)` }}
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
strokeWidth={2} strokeWidth={2}
> >
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg> </svg>
</div> </div>
</div> </div>
@@ -398,19 +485,30 @@ export default function Layout({ children }: LayoutProps) {
{/* Header */} {/* Header */}
<header <header
className="fixed top-0 left-0 right-0 z-50 glass shadow-lg shadow-black/10" className="glass fixed left-0 right-0 top-0 z-50 shadow-lg shadow-black/10"
style={{ style={{
// In fullscreen mode, add padding for safe area + Telegram native controls (close/menu buttons in corners) // In fullscreen mode, add padding for safe area + Telegram native controls (close/menu buttons in corners)
paddingTop: isFullscreen ? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` : undefined, paddingTop: isFullscreen
? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px`
: undefined,
}} }}
> >
<div className="w-full mx-auto px-4 sm:px-6" onClick={() => mobileMenuOpen && setMobileMenuOpen(false)}> <div
<div className="flex justify-between items-center h-16 lg:h-20"> className="mx-auto w-full px-4 sm:px-6"
onClick={() => mobileMenuOpen && setMobileMenuOpen(false)}
>
<div className="flex h-16 items-center justify-between lg:h-20">
{/* Logo */} {/* Logo */}
<Link to="/" onClick={() => setMobileMenuOpen(false)} className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}> <Link
<div className="w-10 h-10 sm:w-11 sm:h-11 lg:w-12 lg:h-12 rounded-xl bg-dark-800/80 dark:bg-dark-800/80 border border-dark-700/50 flex items-center justify-center overflow-hidden shadow-md flex-shrink-0 relative"> to="/"
onClick={() => setMobileMenuOpen(false)}
className={`flex flex-shrink-0 items-center gap-2.5 ${!appName ? 'lg:mr-4' : ''}`}
>
<div className="relative flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-dark-700/50 bg-dark-800/80 shadow-md dark:bg-dark-800/80 sm:h-11 sm:w-11 lg:h-12 lg:w-12">
{/* Always show letter as fallback */} {/* Always show letter as fallback */}
<span className={`text-accent-400 font-bold text-lg sm:text-xl absolute transition-opacity duration-200 ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}> <span
className={`absolute text-lg font-bold text-accent-400 transition-opacity duration-200 sm:text-xl ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}
>
{logoLetter} {logoLetter}
</span> </span>
{/* Logo image with smooth fade-in */} {/* Logo image with smooth fade-in */}
@@ -418,28 +516,28 @@ export default function Layout({ children }: LayoutProps) {
<img <img
src={logoUrl} src={logoUrl}
alt={appName || 'Logo'} alt={appName || 'Logo'}
className={`w-full h-full object-contain absolute transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`} className={`absolute h-full w-full object-contain transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
onLoad={() => setLogoLoaded(true)} onLoad={() => setLogoLoaded(true)}
/> />
)} )}
</div> </div>
{appName && ( {appName && (
<span className="text-base lg:text-lg font-semibold text-dark-100 whitespace-nowrap"> <span className="whitespace-nowrap text-base font-semibold text-dark-100 lg:text-lg">
{appName} {appName}
</span> </span>
)} )}
</Link> </Link>
{/* Desktop Navigation */} {/* Desktop Navigation */}
<nav className="hidden lg:flex items-center gap-1"> <nav className="hidden items-center gap-1 lg:flex">
{desktopNavItems.map((item) => ( {desktopNavItems.map((item) => (
<Link <Link
key={item.path} key={item.path}
to={item.path} to={item.path}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${ className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 ${
isActive(item.path) isActive(item.path)
? 'text-accent-400 bg-accent-500/10' ? 'bg-accent-500/10 text-accent-400'
: 'text-dark-400 hover:text-dark-100 hover:bg-dark-800/50' : 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-100'
}`} }`}
> >
<item.icon /> <item.icon />
@@ -448,15 +546,15 @@ export default function Layout({ children }: LayoutProps) {
))} ))}
{isAdmin && ( {isAdmin && (
<> <>
<div className="w-px h-6 bg-dark-700 mx-2" /> <div className="mx-2 h-6 w-px bg-dark-700" />
{adminNavItems.map((item) => ( {adminNavItems.map((item) => (
<Link <Link
key={item.path} key={item.path}
to={item.path} to={item.path}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${ className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 ${
isAdminActive() isAdminActive()
? 'text-warning-400 bg-warning-500/10' ? 'bg-warning-500/10 text-warning-400'
: 'text-warning-500/70 hover:text-warning-400 hover:bg-warning-500/10' : 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400'
}`} }`}
> >
<item.icon /> <item.icon />
@@ -473,21 +571,26 @@ export default function Layout({ children }: LayoutProps) {
{canToggle && ( {canToggle && (
<button <button
onClick={() => { onClick={() => {
toggleTheme() toggleTheme();
setMobileMenuOpen(false) setMobileMenuOpen(false);
}} }}
className="relative p-2 rounded-xl transition-all duration-200 className="relative rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-champagne-500 transition-all duration-200 hover:bg-dark-700 hover:text-champagne-800 dark:text-dark-400 dark:hover:text-accent-400"
bg-dark-800/50 hover:bg-dark-700 border border-dark-700/50
dark:text-dark-400 dark:hover:text-accent-400
text-champagne-500 hover:text-champagne-800"
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'} title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
aria-label={isDark ? t('theme.light') || 'Switch to light mode' : t('theme.dark') || 'Switch to dark mode'} aria-label={
isDark
? t('theme.light') || 'Switch to light mode'
: t('theme.dark') || 'Switch to dark mode'
}
> >
<div className="relative w-5 h-5"> <div className="relative h-5 w-5">
<div className={`absolute inset-0 transition-all duration-300 ${isDark ? 'opacity-100 rotate-0' : 'opacity-0 rotate-90'}`}> <div
className={`absolute inset-0 transition-all duration-300 ${isDark ? 'rotate-0 opacity-100' : 'rotate-90 opacity-0'}`}
>
<MoonIcon /> <MoonIcon />
</div> </div>
<div className={`absolute inset-0 transition-all duration-300 ${isDark ? 'opacity-0 -rotate-90' : 'opacity-100 rotate-0'}`}> <div
className={`absolute inset-0 transition-all duration-300 ${isDark ? '-rotate-90 opacity-0' : 'rotate-0 opacity-100'}`}
>
<SunIcon /> <SunIcon />
</div> </div>
</div> </div>
@@ -501,17 +604,20 @@ export default function Layout({ children }: LayoutProps) {
<TicketNotificationBell isAdmin={isAdminActive()} /> <TicketNotificationBell isAdmin={isAdminActive()} />
</div> </div>
{/* Hide language switcher on mobile when promo is active */} {/* Hide language switcher on mobile when promo is active */}
<div className={isPromoActive ? 'hidden sm:block' : ''} onClick={() => setMobileMenuOpen(false)}> <div
className={isPromoActive ? 'hidden sm:block' : ''}
onClick={() => setMobileMenuOpen(false)}
>
<LanguageSwitcher /> <LanguageSwitcher />
</div> </div>
{/* Profile - Desktop */} {/* Profile - Desktop */}
<div className="hidden sm:flex items-center gap-3"> <div className="hidden items-center gap-3 sm:flex">
<Link <Link
to="/profile" to="/profile"
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-dark-800/50 transition-colors" className="flex items-center gap-2 rounded-lg px-3 py-1.5 transition-colors hover:bg-dark-800/50"
> >
<div className="w-8 h-8 rounded-full bg-dark-700 flex items-center justify-center"> <div className="flex h-8 w-8 items-center justify-center rounded-full bg-dark-700">
<UserIcon /> <UserIcon />
</div> </div>
<span className="text-sm text-dark-300"> <span className="text-sm text-dark-300">
@@ -531,11 +637,13 @@ export default function Layout({ children }: LayoutProps) {
{/* Mobile menu button */} {/* Mobile menu button */}
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
setMobileMenuOpen(!mobileMenuOpen) setMobileMenuOpen(!mobileMenuOpen);
}} }}
className="lg:hidden btn-icon" className="btn-icon lg:hidden"
aria-label={mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'} aria-label={
mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'
}
aria-expanded={mobileMenuOpen} aria-expanded={mobileMenuOpen}
> >
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />} {mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
@@ -543,27 +651,26 @@ export default function Layout({ children }: LayoutProps) {
</div> </div>
</div> </div>
</div> </div>
</header> </header>
{/* Spacer for fixed header - matches header height */} {/* Spacer for fixed header - matches header height */}
{isFullscreen ? ( {isFullscreen ? (
<div <div
className="flex-shrink-0" className="flex-shrink-0"
style={{ height: `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` }} style={{ height: `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` }}
/> />
) : ( ) : (
<div className="flex-shrink-0 h-16 lg:h-20" /> <div className="h-16 flex-shrink-0 lg:h-20" />
)} )}
{/* Mobile menu - fixed overlay below header */} {/* Mobile menu - fixed overlay below header */}
{mobileMenuOpen && ( {mobileMenuOpen && (
<div <div
className="lg:hidden fixed inset-x-0 bottom-0 z-40 animate-fade-in" className="fixed inset-x-0 bottom-0 z-40 animate-fade-in lg:hidden"
style={{ style={{
top: isFullscreen top: isFullscreen
? `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` ? `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px`
: '64px' : '64px',
}} }}
> >
{/* Backdrop */} {/* Backdrop */}
@@ -573,23 +680,28 @@ export default function Layout({ children }: LayoutProps) {
/> />
{/* Menu content */} {/* Menu content */}
<div className="mobile-menu-content absolute inset-x-0 top-0 bottom-0 bg-dark-900 border-t border-dark-800/50 overflow-y-auto overscroll-contain pb-[calc(5rem+env(safe-area-inset-bottom,0px))]" style={{ WebkitOverflowScrolling: 'touch' }}> <div
<div className="max-w-6xl mx-auto px-4 py-4"> className="mobile-menu-content absolute inset-x-0 bottom-0 top-0 overflow-y-auto overscroll-contain border-t border-dark-800/50 bg-dark-900 pb-[calc(5rem+env(safe-area-inset-bottom,0px))]"
style={{ WebkitOverflowScrolling: 'touch' }}
>
<div className="mx-auto max-w-6xl px-4 py-4">
{/* User info */} {/* User info */}
<div className="flex items-center justify-between pb-4 mb-4 border-b border-dark-800/50"> <div className="mb-4 flex items-center justify-between border-b border-dark-800/50 pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{userPhotoUrl ? ( {userPhotoUrl ? (
<img <img
src={userPhotoUrl} src={userPhotoUrl}
alt="Avatar" alt="Avatar"
className="w-10 h-10 rounded-full object-cover" className="h-10 w-10 rounded-full object-cover"
onError={(e) => { onError={(e) => {
e.currentTarget.style.display = 'none' e.currentTarget.style.display = 'none';
e.currentTarget.nextElementSibling?.classList.remove('hidden') e.currentTarget.nextElementSibling?.classList.remove('hidden');
}} }}
/> />
) : null} ) : null}
<div className={`w-10 h-10 rounded-full bg-dark-700 flex items-center justify-center ${userPhotoUrl ? 'hidden' : ''}`}> <div
className={`flex h-10 w-10 items-center justify-center rounded-full bg-dark-700 ${userPhotoUrl ? 'hidden' : ''}`}
>
<UserIcon /> <UserIcon />
</div> </div>
<div> <div>
@@ -622,7 +734,7 @@ export default function Layout({ children }: LayoutProps) {
{isAdmin && ( {isAdmin && (
<> <>
<div className="divider my-3" /> <div className="divider my-3" />
<div className="px-4 py-1 text-xs font-medium text-dark-500 uppercase tracking-wider"> <div className="px-4 py-1 text-xs font-medium uppercase tracking-wider text-dark-500">
{t('admin.nav.title')} {t('admin.nav.title')}
</div> </div>
{adminNavItems.map((item) => ( {adminNavItems.map((item) => (
@@ -630,7 +742,7 @@ export default function Layout({ children }: LayoutProps) {
key={item.path} key={item.path}
to={item.path} to={item.path}
onClick={() => setMobileMenuOpen(false)} onClick={() => setMobileMenuOpen(false)}
className={`nav-item ${isAdminActive() ? 'text-warning-400 bg-warning-500/10' : 'text-warning-500/70'}`} className={`nav-item ${isAdminActive() ? 'bg-warning-500/10 text-warning-400' : 'text-warning-500/70'}`}
> >
<item.icon /> <item.icon />
{item.label} {item.label}
@@ -652,8 +764,8 @@ export default function Layout({ children }: LayoutProps) {
<button <button
onClick={() => { onClick={() => {
setMobileMenuOpen(false) setMobileMenuOpen(false);
logout() logout();
}} }}
className="nav-item w-full text-error-400" className="nav-item w-full text-error-400"
> >
@@ -667,31 +779,32 @@ export default function Layout({ children }: LayoutProps) {
)} )}
{/* Main Content */} {/* Main Content */}
<main className="flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 py-6 pb-24 lg:pb-8"> <main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6 pb-24 sm:px-6 lg:pb-8">
<div className="animate-fade-in"> <div className="animate-fade-in">{children}</div>
{children}
</div>
</main> </main>
{/* Mobile Bottom Navigation - only core items, hidden when keyboard is open */} {/* Mobile Bottom Navigation - only core items, hidden when keyboard is open */}
<nav className={`bottom-nav lg:hidden transition-opacity duration-200 ${isKeyboardOpen ? 'opacity-0 pointer-events-none' : 'opacity-100'}`}> <nav
className={`bottom-nav transition-opacity duration-200 lg:hidden ${isKeyboardOpen ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
>
<div className="flex justify-around"> <div className="flex justify-around">
{navItems.filter(item => {navItems
['/', '/subscription', '/balance', '/referral', '/support'].includes(item.path) .filter((item) =>
).map((item) => ( ['/', '/subscription', '/balance', '/referral', '/support'].includes(item.path),
<Link )
key={item.path} .map((item) => (
to={item.path} <Link
onClick={() => setMobileMenuOpen(false)} key={item.path}
className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'} to={item.path}
> onClick={() => setMobileMenuOpen(false)}
<item.icon /> className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'}
<span className="text-2xs mt-1 whitespace-nowrap">{item.label}</span> >
</Link> <item.icon />
))} <span className="mt-1 whitespace-nowrap text-2xs">{item.label}</span>
</Link>
))}
</div> </div>
</nav> </nav>
</div> </div>
) );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -933,4 +933,4 @@
"checkError": "بررسی ناموفق بود. لطفاً بعداً دوباره امتحان کنید." "checkError": "بررسی ناموفق بود. لطفاً بعداً دوباره امتحان کنید."
} }
} }
} }

View File

@@ -934,4 +934,4 @@
"checkError": "检查失败。请稍后重试。" "checkError": "检查失败。请稍后重试。"
} }
} }
} }

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,81 +1,115 @@
import { useState, useRef, useMemo } from 'react' import { useState, useRef, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom';
import { import {
adminBroadcastsApi, adminBroadcastsApi,
Broadcast, Broadcast,
BroadcastFilter, BroadcastFilter,
TariffFilter, TariffFilter,
BroadcastCreateRequest, BroadcastCreateRequest,
} from '../api/adminBroadcasts' } from '../api/adminBroadcasts';
// Icons // Icons
const BackIcon = () => ( const BackIcon = () => (
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg
className="h-5 w-5 text-dark-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
</svg> </svg>
) );
const BroadcastIcon = () => ( const BroadcastIcon = () => (
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}> <svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
/>
</svg> </svg>
) );
const PlusIcon = () => ( const PlusIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg> </svg>
) );
const XIcon = () => ( const XIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg> </svg>
) );
const RefreshIcon = () => ( const RefreshIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg> </svg>
) );
const StopIcon = () => ( const StopIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z"
/>
</svg> </svg>
) );
const PhotoIcon = () => ( const PhotoIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
/>
</svg> </svg>
) );
const VideoIcon = () => ( const VideoIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z"
/>
</svg> </svg>
) );
const DocumentIcon = () => ( const DocumentIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg> </svg>
) );
const UsersIcon = () => ( const UsersIcon = () => (
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" /> <path
strokeLinecap="round"
strokeLinejoin="round"
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
/>
</svg> </svg>
) );
const ChevronDownIcon = () => ( const ChevronDownIcon = () => (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" /> <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg> </svg>
) );
// Status badge component // Status badge component
function StatusBadge({ status }: { status: string }) { function StatusBadge({ status }: { status: string }) {
@@ -87,13 +121,13 @@ function StatusBadge({ status }: { status: string }) {
failed: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Ошибка' }, failed: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Ошибка' },
cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-400', label: 'Отменено' }, cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-400', label: 'Отменено' },
cancelling: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'Отменяется' }, cancelling: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'Отменяется' },
} };
const config = statusConfig[status] || statusConfig.queued const config = statusConfig[status] || statusConfig.queued;
return ( return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${config.bg} ${config.text}`}> <span className={`rounded-full px-2 py-1 text-xs font-medium ${config.bg} ${config.text}`}>
{config.label} {config.label}
</span> </span>
) );
} }
// Filter labels // Filter labels
@@ -105,201 +139,203 @@ const FILTER_GROUP_LABELS: Record<string, string> = {
activity: 'По активности', activity: 'По активности',
source: 'По источнику', source: 'По источнику',
tariff: 'По тарифу', tariff: 'По тарифу',
} };
// Create broadcast modal // Create broadcast modal
interface CreateModalProps { interface CreateModalProps {
onClose: () => void onClose: () => void;
onSuccess: () => void onSuccess: () => void;
} }
function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) { function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
const { t } = useTranslation() const { t } = useTranslation();
const queryClient = useQueryClient() const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null);
const [target, setTarget] = useState('') const [target, setTarget] = useState('');
const [messageText, setMessageText] = useState('') const [messageText, setMessageText] = useState('');
const [selectedButtons, setSelectedButtons] = useState<string[]>(['home']) const [selectedButtons, setSelectedButtons] = useState<string[]>(['home']);
const [mediaFile, setMediaFile] = useState<File | null>(null) const [mediaFile, setMediaFile] = useState<File | null>(null);
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo') const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo');
const [mediaPreview, setMediaPreview] = useState<string | null>(null) const [mediaPreview, setMediaPreview] = useState<string | null>(null);
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null) const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false);
const [showFilters, setShowFilters] = useState(false) const [showFilters, setShowFilters] = useState(false);
// Fetch filters // Fetch filters
const { data: filtersData, isLoading: filtersLoading } = useQuery({ const { data: filtersData, isLoading: filtersLoading } = useQuery({
queryKey: ['admin', 'broadcasts', 'filters'], queryKey: ['admin', 'broadcasts', 'filters'],
queryFn: adminBroadcastsApi.getFilters, queryFn: adminBroadcastsApi.getFilters,
}) });
// Fetch buttons // Fetch buttons
const { data: buttonsData } = useQuery({ const { data: buttonsData } = useQuery({
queryKey: ['admin', 'broadcasts', 'buttons'], queryKey: ['admin', 'broadcasts', 'buttons'],
queryFn: adminBroadcastsApi.getButtons, queryFn: adminBroadcastsApi.getButtons,
}) });
// Preview mutation // Preview mutation
const previewMutation = useMutation({ const previewMutation = useMutation({
mutationFn: adminBroadcastsApi.preview, mutationFn: adminBroadcastsApi.preview,
}) });
// Create mutation // Create mutation
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: adminBroadcastsApi.create, mutationFn: adminBroadcastsApi.create,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] }) queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
onSuccess() onSuccess();
onClose() onClose();
}, },
}) });
// Group filters // Group filters
const groupedFilters = useMemo(() => { const groupedFilters = useMemo(() => {
if (!filtersData) return {} if (!filtersData) return {};
const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {} const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {};
// Basic filters // Basic filters
filtersData.filters.forEach(f => { filtersData.filters.forEach((f) => {
const group = f.group || 'basic' const group = f.group || 'basic';
if (!groups[group]) groups[group] = [] if (!groups[group]) groups[group] = [];
groups[group].push(f) groups[group].push(f);
}) });
// Tariff filters // Tariff filters
if (filtersData.tariff_filters.length > 0) { if (filtersData.tariff_filters.length > 0) {
groups['tariff'] = filtersData.tariff_filters groups['tariff'] = filtersData.tariff_filters;
} }
// Custom filters // Custom filters
filtersData.custom_filters.forEach(f => { filtersData.custom_filters.forEach((f) => {
const group = f.group || 'custom' const group = f.group || 'custom';
if (!groups[group]) groups[group] = [] if (!groups[group]) groups[group] = [];
groups[group].push(f) groups[group].push(f);
}) });
return groups return groups;
}, [filtersData]) }, [filtersData]);
// Selected filter info // Selected filter info
const selectedFilter = useMemo(() => { const selectedFilter = useMemo(() => {
if (!target || !filtersData) return null if (!target || !filtersData) return null;
const all = [ const all = [
...filtersData.filters, ...filtersData.filters,
...filtersData.tariff_filters, ...filtersData.tariff_filters,
...filtersData.custom_filters, ...filtersData.custom_filters,
] ];
return all.find(f => f.key === target) return all.find((f) => f.key === target);
}, [target, filtersData]) }, [target, filtersData]);
// Handle file selection // Handle file selection
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0];
if (!file) return if (!file) return;
setMediaFile(file) setMediaFile(file);
// Determine media type // Determine media type
if (file.type.startsWith('image/')) { if (file.type.startsWith('image/')) {
setMediaType('photo') setMediaType('photo');
setMediaPreview(URL.createObjectURL(file)) setMediaPreview(URL.createObjectURL(file));
} else if (file.type.startsWith('video/')) { } else if (file.type.startsWith('video/')) {
setMediaType('video') setMediaType('video');
setMediaPreview(null) setMediaPreview(null);
} else { } else {
setMediaType('document') setMediaType('document');
setMediaPreview(null) setMediaPreview(null);
} }
// Upload file // Upload file
setIsUploading(true) setIsUploading(true);
try { try {
const result = await adminBroadcastsApi.uploadMedia(file, mediaType) const result = await adminBroadcastsApi.uploadMedia(file, mediaType);
setUploadedFileId(result.file_id) setUploadedFileId(result.file_id);
} catch (err) { } catch (err) {
console.error('Upload failed:', err) console.error('Upload failed:', err);
setMediaFile(null) setMediaFile(null);
setMediaPreview(null) setMediaPreview(null);
} finally { } finally {
setIsUploading(false) setIsUploading(false);
} }
} };
// Remove media // Remove media
const handleRemoveMedia = () => { const handleRemoveMedia = () => {
setMediaFile(null) setMediaFile(null);
setMediaPreview(null) setMediaPreview(null);
setUploadedFileId(null) setUploadedFileId(null);
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = '' fileInputRef.current.value = '';
} }
} };
// Toggle button // Toggle button
const toggleButton = (key: string) => { const toggleButton = (key: string) => {
setSelectedButtons(prev => setSelectedButtons((prev) =>
prev.includes(key) ? prev.filter(b => b !== key) : [...prev, key] prev.includes(key) ? prev.filter((b) => b !== key) : [...prev, key],
) );
} };
// Submit // Submit
const handleSubmit = () => { const handleSubmit = () => {
if (!target || !messageText.trim()) return if (!target || !messageText.trim()) return;
const data: BroadcastCreateRequest = { const data: BroadcastCreateRequest = {
target, target,
message_text: messageText, message_text: messageText,
selected_buttons: selectedButtons, selected_buttons: selectedButtons,
} };
if (uploadedFileId) { if (uploadedFileId) {
data.media = { data.media = {
type: mediaType, type: mediaType,
file_id: uploadedFileId, file_id: uploadedFileId,
} };
} }
createMutation.mutate(data) createMutation.mutate(data);
} };
const recipientsCount = previewMutation.data?.count ?? selectedFilter?.count ?? null const recipientsCount = previewMutation.data?.count ?? selectedFilter?.count ?? null;
return ( return (
<div className="fixed inset-0 flex items-center justify-center z-50 p-4 overflow-y-auto"> <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-2xl my-4"> <div className="my-4 w-full max-w-2xl rounded-xl bg-dark-800">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700"> <div className="flex items-center justify-between border-b border-dark-700 p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-accent-500/20 rounded-lg text-accent-400"> <div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
<BroadcastIcon /> <BroadcastIcon />
</div> </div>
<h2 className="text-lg font-semibold text-dark-100">{t('admin.broadcasts.create')}</h2> <h2 className="text-lg font-semibold text-dark-100">{t('admin.broadcasts.create')}</h2>
</div> </div>
<button onClick={onClose} className="p-2 hover:bg-dark-700 rounded-lg transition-colors"> <button onClick={onClose} className="rounded-lg p-2 transition-colors hover:bg-dark-700">
<XIcon /> <XIcon />
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="p-4 space-y-4 max-h-[70vh] overflow-y-auto"> <div className="max-h-[70vh] space-y-4 overflow-y-auto p-4">
{/* Filter selection */} {/* Filter selection */}
<div> <div>
<label className="block text-sm font-medium text-dark-300 mb-2"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.selectFilter')} {t('admin.broadcasts.selectFilter')}
</label> </label>
<div className="relative"> <div className="relative">
<button <button
onClick={() => setShowFilters(!showFilters)} onClick={() => setShowFilters(!showFilters)}
className="w-full p-3 bg-dark-700 rounded-lg text-left flex items-center justify-between hover:bg-dark-600 transition-colors" className="flex w-full items-center justify-between rounded-lg bg-dark-700 p-3 text-left transition-colors hover:bg-dark-600"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<UsersIcon /> <UsersIcon />
<span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}> <span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}>
{selectedFilter ? selectedFilter.label : t('admin.broadcasts.selectFilterPlaceholder')} {selectedFilter
? selectedFilter.label
: t('admin.broadcasts.selectFilterPlaceholder')}
</span> </span>
{recipientsCount !== null && ( {recipientsCount !== null && (
<span className="px-2 py-0.5 bg-accent-500/20 text-accent-400 rounded-full text-xs"> <span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
{recipientsCount} {t('admin.broadcasts.recipients')} {recipientsCount} {t('admin.broadcasts.recipients')}
</span> </span>
)} )}
@@ -308,24 +344,24 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
</button> </button>
{showFilters && ( {showFilters && (
<div className="absolute top-full left-0 right-0 mt-1 bg-dark-700 rounded-lg shadow-xl z-10 max-h-64 overflow-y-auto"> <div className="absolute left-0 right-0 top-full z-10 mt-1 max-h-64 overflow-y-auto rounded-lg bg-dark-700 shadow-xl">
{filtersLoading ? ( {filtersLoading ? (
<div className="p-4 text-center text-dark-400">Loading...</div> <div className="p-4 text-center text-dark-400">Loading...</div>
) : ( ) : (
Object.entries(groupedFilters).map(([group, filters]) => ( Object.entries(groupedFilters).map(([group, filters]) => (
<div key={group}> <div key={group}>
<div className="px-3 py-2 text-xs font-medium text-dark-400 bg-dark-800 sticky top-0"> <div className="sticky top-0 bg-dark-800 px-3 py-2 text-xs font-medium text-dark-400">
{FILTER_GROUP_LABELS[group] || group} {FILTER_GROUP_LABELS[group] || group}
</div> </div>
{filters.map(filter => ( {filters.map((filter) => (
<button <button
key={filter.key} key={filter.key}
onClick={() => { onClick={() => {
setTarget(filter.key) setTarget(filter.key);
setShowFilters(false) setShowFilters(false);
previewMutation.mutate(filter.key) previewMutation.mutate(filter.key);
}} }}
className={`w-full px-3 py-2 text-left hover:bg-dark-600 transition-colors flex items-center justify-between ${ className={`flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-dark-600 ${
target === filter.key ? 'bg-accent-500/20' : '' target === filter.key ? 'bg-accent-500/20' : ''
}`} }`}
> >
@@ -345,29 +381,27 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
{/* Message text */} {/* Message text */}
<div> <div>
<label className="block text-sm font-medium text-dark-300 mb-2"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.messageText')} {t('admin.broadcasts.messageText')}
</label> </label>
<textarea <textarea
value={messageText} value={messageText}
onChange={e => setMessageText(e.target.value)} onChange={(e) => setMessageText(e.target.value)}
placeholder={t('admin.broadcasts.messageTextPlaceholder')} placeholder={t('admin.broadcasts.messageTextPlaceholder')}
rows={5} rows={5}
maxLength={4000} maxLength={4000}
className="w-full p-3 bg-dark-700 rounded-lg text-dark-100 placeholder-dark-400 resize-none focus:outline-none focus:ring-2 focus:ring-accent-500" className="w-full resize-none rounded-lg bg-dark-700 p-3 text-dark-100 placeholder-dark-400 focus:outline-none focus:ring-2 focus:ring-accent-500"
/> />
<div className="text-xs text-dark-400 mt-1 text-right"> <div className="mt-1 text-right text-xs text-dark-400">{messageText.length}/4000</div>
{messageText.length}/4000
</div>
</div> </div>
{/* Media upload */} {/* Media upload */}
<div> <div>
<label className="block text-sm font-medium text-dark-300 mb-2"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.media')} {t('admin.broadcasts.media')}
</label> </label>
{mediaFile ? ( {mediaFile ? (
<div className="p-3 bg-dark-700 rounded-lg"> <div className="rounded-lg bg-dark-700 p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{mediaType === 'photo' && <PhotoIcon />} {mediaType === 'photo' && <PhotoIcon />}
@@ -382,7 +416,7 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
</div> </div>
<button <button
onClick={handleRemoveMedia} onClick={handleRemoveMedia}
className="p-2 hover:bg-dark-600 rounded-lg text-dark-400 hover:text-red-400" className="rounded-lg p-2 text-dark-400 hover:bg-dark-600 hover:text-red-400"
disabled={isUploading} disabled={isUploading}
> >
<XIcon /> <XIcon />
@@ -396,7 +430,9 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
/> />
)} )}
{isUploading && ( {isUploading && (
<div className="mt-2 text-sm text-accent-400">{t('admin.broadcasts.uploading')}</div> <div className="mt-2 text-sm text-accent-400">
{t('admin.broadcasts.uploading')}
</div>
)} )}
</div> </div>
) : ( ) : (
@@ -410,7 +446,7 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
/> />
<button <button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className="flex-1 p-3 bg-dark-700 rounded-lg text-dark-400 hover:bg-dark-600 hover:text-dark-100 transition-colors flex items-center justify-center gap-2" className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-dark-700 p-3 text-dark-400 transition-colors hover:bg-dark-600 hover:text-dark-100"
> >
<PhotoIcon /> <PhotoIcon />
<span>{t('admin.broadcasts.addMedia')}</span> <span>{t('admin.broadcasts.addMedia')}</span>
@@ -421,15 +457,15 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
{/* Buttons selection */} {/* Buttons selection */}
<div> <div>
<label className="block text-sm font-medium text-dark-300 mb-2"> <label className="mb-2 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.buttons')} {t('admin.broadcasts.buttons')}
</label> </label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{buttonsData?.buttons.map(button => ( {buttonsData?.buttons.map((button) => (
<button <button
key={button.key} key={button.key}
onClick={() => toggleButton(button.key)} onClick={() => toggleButton(button.key)}
className={`px-3 py-2 rounded-lg text-sm transition-colors ${ className={`rounded-lg px-3 py-2 text-sm transition-colors ${
selectedButtons.includes(button.key) selectedButtons.includes(button.key)
? 'bg-accent-500 text-white' ? 'bg-accent-500 text-white'
: 'bg-dark-700 text-dark-300 hover:bg-dark-600' : 'bg-dark-700 text-dark-300 hover:bg-dark-600'
@@ -443,76 +479,74 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-dark-700"> <div className="flex items-center justify-between border-t border-dark-700 p-4">
<div className="text-sm text-dark-400"> <div className="text-sm text-dark-400">
{recipientsCount !== null && ( {recipientsCount !== null && (
<span> <span>
{t('admin.broadcasts.willBeSent')}: <strong className="text-accent-400">{recipientsCount}</strong> {t('admin.broadcasts.users')} {t('admin.broadcasts.willBeSent')}:{' '}
<strong className="text-accent-400">{recipientsCount}</strong>{' '}
{t('admin.broadcasts.users')}
</span> </span>
)} )}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={onClose} onClick={onClose}
className="px-4 py-2 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 transition-colors" className="rounded-lg bg-dark-700 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-600"
> >
{t('common.cancel')} {t('common.cancel')}
</button> </button>
<button <button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!target || !messageText.trim() || createMutation.isPending || isUploading} disabled={!target || !messageText.trim() || createMutation.isPending || isUploading}
className="px-4 py-2 bg-accent-500 rounded-lg text-white hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2" className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
> >
{createMutation.isPending ? ( {createMutation.isPending ? <RefreshIcon /> : <BroadcastIcon />}
<RefreshIcon />
) : (
<BroadcastIcon />
)}
{t('admin.broadcasts.send')} {t('admin.broadcasts.send')}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
) );
} }
// Broadcast detail modal // Broadcast detail modal
interface DetailModalProps { interface DetailModalProps {
broadcast: Broadcast broadcast: Broadcast;
onClose: () => void onClose: () => void;
onStop: () => void onStop: () => void;
isStopping: boolean isStopping: boolean;
} }
function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: DetailModalProps) { function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: DetailModalProps) {
const { t } = useTranslation() const { t } = useTranslation();
const isRunning = ['queued', 'in_progress', 'cancelling'].includes(broadcast.status) const isRunning = ['queued', 'in_progress', 'cancelling'].includes(broadcast.status);
return ( return (
<div className="fixed inset-0 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="bg-dark-800 rounded-xl w-full max-w-lg"> <div className="w-full max-w-lg rounded-xl bg-dark-800">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-dark-700"> <div className="flex items-center justify-between border-b border-dark-700 p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<StatusBadge status={broadcast.status} /> <StatusBadge status={broadcast.status} />
<span className="text-dark-400">#{broadcast.id}</span> <span className="text-dark-400">#{broadcast.id}</span>
</div> </div>
<button onClick={onClose} className="p-2 hover:bg-dark-700 rounded-lg transition-colors"> <button onClick={onClose} className="rounded-lg p-2 transition-colors hover:bg-dark-700">
<XIcon /> <XIcon />
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="p-4 space-y-4"> <div className="space-y-4 p-4">
{/* Progress */} {/* Progress */}
{isRunning && ( {isRunning && (
<div> <div>
<div className="flex justify-between text-sm mb-1"> <div className="mb-1 flex justify-between text-sm">
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span> <span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
<span className="text-dark-100">{broadcast.progress_percent.toFixed(1)}%</span> <span className="text-dark-100">{broadcast.progress_percent.toFixed(1)}%</span>
</div> </div>
<div className="h-2 bg-dark-700 rounded-full overflow-hidden"> <div className="h-2 overflow-hidden rounded-full bg-dark-700">
<div <div
className="h-full bg-accent-500 transition-all duration-300" className="h-full bg-accent-500 transition-all duration-300"
style={{ width: `${broadcast.progress_percent}%` }} style={{ width: `${broadcast.progress_percent}%` }}
@@ -523,15 +557,15 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="p-3 bg-dark-700 rounded-lg text-center"> <div className="rounded-lg bg-dark-700 p-3 text-center">
<p className="text-2xl font-bold text-dark-100">{broadcast.total_count}</p> <p className="text-2xl font-bold text-dark-100">{broadcast.total_count}</p>
<p className="text-xs text-dark-400">{t('admin.broadcasts.total')}</p> <p className="text-xs text-dark-400">{t('admin.broadcasts.total')}</p>
</div> </div>
<div className="p-3 bg-dark-700 rounded-lg text-center"> <div className="rounded-lg bg-dark-700 p-3 text-center">
<p className="text-2xl font-bold text-green-400">{broadcast.sent_count}</p> <p className="text-2xl font-bold text-green-400">{broadcast.sent_count}</p>
<p className="text-xs text-dark-400">{t('admin.broadcasts.sent')}</p> <p className="text-xs text-dark-400">{t('admin.broadcasts.sent')}</p>
</div> </div>
<div className="p-3 bg-dark-700 rounded-lg text-center"> <div className="rounded-lg bg-dark-700 p-3 text-center">
<p className="text-2xl font-bold text-red-400">{broadcast.failed_count}</p> <p className="text-2xl font-bold text-red-400">{broadcast.failed_count}</p>
<p className="text-xs text-dark-400">{t('admin.broadcasts.failed')}</p> <p className="text-xs text-dark-400">{t('admin.broadcasts.failed')}</p>
</div> </div>
@@ -539,14 +573,14 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
{/* Target */} {/* Target */}
<div> <div>
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.filter')}</p> <p className="mb-1 text-sm text-dark-400">{t('admin.broadcasts.filter')}</p>
<p className="text-dark-100">{broadcast.target_type}</p> <p className="text-dark-100">{broadcast.target_type}</p>
</div> </div>
{/* Message */} {/* Message */}
<div> <div>
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.message')}</p> <p className="mb-1 text-sm text-dark-400">{t('admin.broadcasts.message')}</p>
<div className="p-3 bg-dark-700 rounded-lg text-dark-100 whitespace-pre-wrap text-sm max-h-40 overflow-y-auto"> <div className="max-h-40 overflow-y-auto whitespace-pre-wrap rounded-lg bg-dark-700 p-3 text-sm text-dark-100">
{broadcast.message_text} {broadcast.message_text}
</div> </div>
</div> </div>
@@ -554,7 +588,7 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
{/* Media */} {/* Media */}
{broadcast.has_media && ( {broadcast.has_media && (
<div> <div>
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.media')}</p> <p className="mb-1 text-sm text-dark-400">{t('admin.broadcasts.media')}</p>
<div className="flex items-center gap-2 text-dark-100"> <div className="flex items-center gap-2 text-dark-100">
{broadcast.media_type === 'photo' && <PhotoIcon />} {broadcast.media_type === 'photo' && <PhotoIcon />}
{broadcast.media_type === 'video' && <VideoIcon />} {broadcast.media_type === 'video' && <VideoIcon />}
@@ -573,11 +607,11 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
{/* Footer */} {/* Footer */}
{isRunning && broadcast.status !== 'cancelling' && ( {isRunning && broadcast.status !== 'cancelling' && (
<div className="p-4 border-t border-dark-700"> <div className="border-t border-dark-700 p-4">
<button <button
onClick={onStop} onClick={onStop}
disabled={isStopping} disabled={isStopping}
className="w-full py-2 bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors flex items-center justify-center gap-2 disabled:opacity-50" className="flex w-full items-center justify-center gap-2 rounded-lg bg-red-500/20 py-2 text-red-400 transition-colors hover:bg-red-500/30 disabled:opacity-50"
> >
<StopIcon /> <StopIcon />
{t('admin.broadcasts.stop')} {t('admin.broadcasts.stop')}
@@ -586,48 +620,48 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
)} )}
</div> </div>
</div> </div>
) );
} }
// Main component // Main component
export default function AdminBroadcasts() { export default function AdminBroadcasts() {
const { t } = useTranslation() const { t } = useTranslation();
const queryClient = useQueryClient() const queryClient = useQueryClient();
const [showCreateModal, setShowCreateModal] = useState(false) const [showCreateModal, setShowCreateModal] = useState(false);
const [selectedBroadcast, setSelectedBroadcast] = useState<Broadcast | null>(null) const [selectedBroadcast, setSelectedBroadcast] = useState<Broadcast | null>(null);
const [page, setPage] = useState(0) const [page, setPage] = useState(0);
const limit = 20 const limit = 20;
// Fetch broadcasts // Fetch broadcasts
const { data, isLoading, refetch } = useQuery({ const { data, isLoading, refetch } = useQuery({
queryKey: ['admin', 'broadcasts', 'list', page], queryKey: ['admin', 'broadcasts', 'list', page],
queryFn: () => adminBroadcastsApi.list(limit, page * limit), queryFn: () => adminBroadcastsApi.list(limit, page * limit),
refetchInterval: 5000, // Auto refresh every 5s refetchInterval: 5000, // Auto refresh every 5s
}) });
// Stop mutation // Stop mutation
const stopMutation = useMutation({ const stopMutation = useMutation({
mutationFn: adminBroadcastsApi.stop, mutationFn: adminBroadcastsApi.stop,
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] }) queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
setSelectedBroadcast(null) setSelectedBroadcast(null);
}, },
}) });
const broadcasts = data?.items || [] const broadcasts = data?.items || [];
const total = data?.total || 0 const total = data?.total || 0;
const totalPages = Math.ceil(total / limit) const totalPages = Math.ceil(total / limit);
return ( return (
<div className="min-h-screen bg-dark-900 p-4 pb-20 md:pb-4"> <div className="min-h-screen bg-dark-900 p-4 pb-20 md:pb-4">
<div className="max-w-4xl mx-auto"> <div className="mx-auto max-w-4xl">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-6"> <div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link <Link
to="/admin" to="/admin"
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors" className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
> >
<BackIcon /> <BackIcon />
</Link> </Link>
@@ -639,13 +673,13 @@ export default function AdminBroadcasts() {
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
onClick={() => refetch()} onClick={() => refetch()}
className="p-2 bg-dark-800 rounded-lg text-dark-400 hover:text-dark-100 transition-colors" className="rounded-lg bg-dark-800 p-2 text-dark-400 transition-colors hover:text-dark-100"
> >
<RefreshIcon /> <RefreshIcon />
</button> </button>
<button <button
onClick={() => setShowCreateModal(true)} onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-accent-500 rounded-lg text-white hover:bg-accent-600 transition-colors flex items-center gap-2" className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
> >
<PlusIcon /> <PlusIcon />
<span className="hidden sm:inline">{t('admin.broadcasts.create')}</span> <span className="hidden sm:inline">{t('admin.broadcasts.create')}</span>
@@ -654,7 +688,7 @@ export default function AdminBroadcasts() {
</div> </div>
{/* Broadcasts list */} {/* Broadcasts list */}
<div className="bg-dark-800 rounded-xl overflow-hidden"> <div className="overflow-hidden rounded-xl bg-dark-800">
{isLoading ? ( {isLoading ? (
<div className="p-8 text-center text-dark-400"> <div className="p-8 text-center text-dark-400">
<RefreshIcon /> <RefreshIcon />
@@ -667,15 +701,15 @@ export default function AdminBroadcasts() {
</div> </div>
) : ( ) : (
<div className="divide-y divide-dark-700"> <div className="divide-y divide-dark-700">
{broadcasts.map(broadcast => ( {broadcasts.map((broadcast) => (
<button <button
key={broadcast.id} key={broadcast.id}
onClick={() => setSelectedBroadcast(broadcast)} onClick={() => setSelectedBroadcast(broadcast)}
className="w-full p-4 hover:bg-dark-700/50 transition-colors text-left" className="w-full p-4 text-left transition-colors hover:bg-dark-700/50"
> >
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1"> <div className="mb-1 flex items-center gap-2">
<StatusBadge status={broadcast.status} /> <StatusBadge status={broadcast.status} />
<span className="text-xs text-dark-400">#{broadcast.id}</span> <span className="text-xs text-dark-400">#{broadcast.id}</span>
{broadcast.has_media && ( {broadcast.has_media && (
@@ -686,10 +720,8 @@ export default function AdminBroadcasts() {
</span> </span>
)} )}
</div> </div>
<p className="text-dark-100 text-sm truncate"> <p className="truncate text-sm text-dark-100">{broadcast.message_text}</p>
{broadcast.message_text} <div className="mt-2 flex items-center gap-4 text-xs text-dark-400">
</p>
<div className="flex items-center gap-4 mt-2 text-xs text-dark-400">
<span>{broadcast.target_type}</span> <span>{broadcast.target_type}</span>
<span> <span>
{broadcast.sent_count}/{broadcast.total_count} {broadcast.sent_count}/{broadcast.total_count}
@@ -699,13 +731,13 @@ export default function AdminBroadcasts() {
</div> </div>
{['queued', 'in_progress'].includes(broadcast.status) && ( {['queued', 'in_progress'].includes(broadcast.status) && (
<div className="w-16"> <div className="w-16">
<div className="h-1.5 bg-dark-600 rounded-full overflow-hidden"> <div className="h-1.5 overflow-hidden rounded-full bg-dark-600">
<div <div
className="h-full bg-accent-500" className="h-full bg-accent-500"
style={{ width: `${broadcast.progress_percent}%` }} style={{ width: `${broadcast.progress_percent}%` }}
/> />
</div> </div>
<p className="text-xs text-dark-400 text-center mt-1"> <p className="mt-1 text-center text-xs text-dark-400">
{broadcast.progress_percent.toFixed(0)}% {broadcast.progress_percent.toFixed(0)}%
</p> </p>
</div> </div>
@@ -718,11 +750,11 @@ export default function AdminBroadcasts() {
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-center gap-2 p-4 border-t border-dark-700"> <div className="flex items-center justify-center gap-2 border-t border-dark-700 p-4">
<button <button
onClick={() => setPage(p => Math.max(0, p - 1))} onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0} disabled={page === 0}
className="px-3 py-1 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed" className="rounded-lg bg-dark-700 px-3 py-1 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
> >
{t('common.prev')} {t('common.prev')}
</button> </button>
@@ -730,9 +762,9 @@ export default function AdminBroadcasts() {
{page + 1} / {totalPages} {page + 1} / {totalPages}
</span> </span>
<button <button
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))} onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1} disabled={page >= totalPages - 1}
className="px-3 py-1 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed" className="rounded-lg bg-dark-700 px-3 py-1 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
> >
{t('common.next')} {t('common.next')}
</button> </button>
@@ -746,7 +778,7 @@ export default function AdminBroadcasts() {
<CreateBroadcastModal <CreateBroadcastModal
onClose={() => setShowCreateModal(false)} onClose={() => setShowCreateModal(false)}
onSuccess={() => { onSuccess={() => {
refetch() refetch();
}} }}
/> />
)} )}
@@ -760,5 +792,5 @@ export default function AdminBroadcasts() {
/> />
)} )}
</div> </div>
) );
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

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