mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-09-15 01:03:08 +00:00
@@ -1,6 +0,0 @@
|
|||||||
dist
|
|
||||||
node_modules
|
|
||||||
*.config.js
|
|
||||||
*.config.ts
|
|
||||||
.github
|
|
||||||
public
|
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
14
.github/workflows/ci.yml
vendored
14
.github/workflows/ci.yml
vendored
@@ -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:
|
||||||
|
|||||||
4
.github/workflows/docker.yml
vendored
4
.github/workflows/docker.yml
vendored
@@ -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
29
.github/workflows/lint.yml
vendored
Normal 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
|
||||||
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
@@ -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
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
npx lint-staged
|
||||||
6
.prettierignore
Normal file
6
.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
|
public
|
||||||
|
*.min.js
|
||||||
|
*.min.css
|
||||||
|
package-lock.json
|
||||||
13
.prettierrc
Normal file
13
.prettierrc
Normal 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
48
eslint.config.js
Normal 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
1963
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@@ -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"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
664
src/App.tsx
664
src/App.tsx
@@ -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;
|
||||||
|
|||||||
434
src/api/admin.ts
434
src/api/admin.ts
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
103
src/api/promo.ts
103
src/api/promo.ts
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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}`);
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}`;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
344
src/api/wheel.ts
344
src/api/wheel.ts
@@ -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;
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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(/ /g, ' ')
|
.replace(/ /g, ' ')
|
||||||
@@ -24,12 +26,12 @@ export function stripHtml(html: string): string {
|
|||||||
.replace(/</g, '<')
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, '>')
|
.replace(/>/g, '>')
|
||||||
.replace(/"/g, '"')
|
.replace(/"/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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/i18n.ts
20
src/i18n.ts
@@ -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;
|
||||||
|
|||||||
@@ -933,4 +933,4 @@
|
|||||||
"checkError": "بررسی ناموفق بود. لطفاً بعداً دوباره امتحان کنید."
|
"checkError": "بررسی ناموفق بود. لطفاً بعداً دوباره امتحان کنید."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -934,4 +934,4 @@
|
|||||||
"checkError": "检查失败。请稍后重试。"
|
"checkError": "检查失败。请稍后重试。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
src/main.tsx
30
src/main.tsx
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user