mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
refactor: migrate to eslint flat config and format codebase with prettier
- Remove legacy .eslintrc.cjs and .eslintignore - Add eslint.config.js with flat config, security rules (no-eval, no-implied-eval, no-new-func, no-script-url) - Add .prettierrc and .prettierignore - Format entire codebase with prettier
This commit is contained in:
@@ -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',
|
||||
},
|
||||
}
|
||||
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,
|
||||
)
|
||||
664
src/App.tsx
664
src/App.tsx
@@ -1,364 +1,416 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import { useAuthStore } from './store/auth'
|
||||
import { useBlockingStore } from './store/blocking'
|
||||
import Layout from './components/layout/Layout'
|
||||
import PageLoader from './components/common/PageLoader'
|
||||
import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking'
|
||||
import { saveReturnUrl } from './utils/token'
|
||||
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters'
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from './store/auth';
|
||||
import { useBlockingStore } from './store/blocking';
|
||||
import Layout from './components/layout/Layout';
|
||||
import PageLoader from './components/common/PageLoader';
|
||||
import { MaintenanceScreen, ChannelSubscriptionScreen } from './components/blocking';
|
||||
import { saveReturnUrl } from './utils/token';
|
||||
import { useAnalyticsCounters } from './hooks/useAnalyticsCounters';
|
||||
|
||||
// Auth pages - load immediately (small)
|
||||
import Login from './pages/Login'
|
||||
import TelegramCallback from './pages/TelegramCallback'
|
||||
import TelegramRedirect from './pages/TelegramRedirect'
|
||||
import DeepLinkRedirect from './pages/DeepLinkRedirect'
|
||||
import VerifyEmail from './pages/VerifyEmail'
|
||||
import ResetPassword from './pages/ResetPassword'
|
||||
import Login from './pages/Login';
|
||||
import TelegramCallback from './pages/TelegramCallback';
|
||||
import TelegramRedirect from './pages/TelegramRedirect';
|
||||
import DeepLinkRedirect from './pages/DeepLinkRedirect';
|
||||
import VerifyEmail from './pages/VerifyEmail';
|
||||
import ResetPassword from './pages/ResetPassword';
|
||||
|
||||
// User pages - lazy load
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||
const Subscription = lazy(() => import('./pages/Subscription'))
|
||||
const Balance = lazy(() => import('./pages/Balance'))
|
||||
const Referral = lazy(() => import('./pages/Referral'))
|
||||
const Support = lazy(() => import('./pages/Support'))
|
||||
const Profile = lazy(() => import('./pages/Profile'))
|
||||
const Contests = lazy(() => import('./pages/Contests'))
|
||||
const Polls = lazy(() => import('./pages/Polls'))
|
||||
const Info = lazy(() => import('./pages/Info'))
|
||||
const Wheel = lazy(() => import('./pages/Wheel'))
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
const Subscription = lazy(() => import('./pages/Subscription'));
|
||||
const Balance = lazy(() => import('./pages/Balance'));
|
||||
const Referral = lazy(() => import('./pages/Referral'));
|
||||
const Support = lazy(() => import('./pages/Support'));
|
||||
const Profile = lazy(() => import('./pages/Profile'));
|
||||
const Contests = lazy(() => import('./pages/Contests'));
|
||||
const Polls = lazy(() => import('./pages/Polls'));
|
||||
const Info = lazy(() => import('./pages/Info'));
|
||||
const Wheel = lazy(() => import('./pages/Wheel'));
|
||||
|
||||
// Admin pages - lazy load (only for admins)
|
||||
const AdminPanel = lazy(() => import('./pages/AdminPanel'))
|
||||
const AdminTickets = lazy(() => import('./pages/AdminTickets'))
|
||||
const AdminSettings = lazy(() => import('./pages/AdminSettings'))
|
||||
const AdminApps = lazy(() => import('./pages/AdminApps'))
|
||||
const AdminWheel = lazy(() => import('./pages/AdminWheel'))
|
||||
const AdminTariffs = lazy(() => import('./pages/AdminTariffs'))
|
||||
const AdminServers = lazy(() => import('./pages/AdminServers'))
|
||||
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'))
|
||||
const AdminBanSystem = lazy(() => import('./pages/AdminBanSystem'))
|
||||
const AdminBroadcasts = lazy(() => import('./pages/AdminBroadcasts'))
|
||||
const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes'))
|
||||
const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns'))
|
||||
const AdminUsers = lazy(() => import('./pages/AdminUsers'))
|
||||
const AdminPayments = lazy(() => import('./pages/AdminPayments'))
|
||||
const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'))
|
||||
const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers'))
|
||||
const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave'))
|
||||
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'))
|
||||
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
|
||||
const AdminTickets = lazy(() => import('./pages/AdminTickets'));
|
||||
const AdminSettings = lazy(() => import('./pages/AdminSettings'));
|
||||
const AdminApps = lazy(() => import('./pages/AdminApps'));
|
||||
const AdminWheel = lazy(() => import('./pages/AdminWheel'));
|
||||
const AdminTariffs = lazy(() => import('./pages/AdminTariffs'));
|
||||
const AdminServers = lazy(() => import('./pages/AdminServers'));
|
||||
const AdminDashboard = lazy(() => import('./pages/AdminDashboard'));
|
||||
const AdminBanSystem = lazy(() => import('./pages/AdminBanSystem'));
|
||||
const AdminBroadcasts = lazy(() => import('./pages/AdminBroadcasts'));
|
||||
const AdminPromocodes = lazy(() => import('./pages/AdminPromocodes'));
|
||||
const AdminCampaigns = lazy(() => import('./pages/AdminCampaigns'));
|
||||
const AdminUsers = lazy(() => import('./pages/AdminUsers'));
|
||||
const AdminPayments = lazy(() => import('./pages/AdminPayments'));
|
||||
const AdminPaymentMethods = lazy(() => import('./pages/AdminPaymentMethods'));
|
||||
const AdminPromoOffers = lazy(() => import('./pages/AdminPromoOffers'));
|
||||
const AdminRemnawave = lazy(() => import('./pages/AdminRemnawave'));
|
||||
const AdminEmailTemplates = lazy(() => import('./pages/AdminEmailTemplates'));
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuthStore()
|
||||
const location = useLocation()
|
||||
const { isAuthenticated, isLoading } = useAuthStore();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader variant="dark" />
|
||||
return <PageLoader variant="dark" />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Сохраняем текущий URL для возврата после авторизации
|
||||
saveReturnUrl()
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />
|
||||
saveReturnUrl();
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
}
|
||||
|
||||
return <Layout>{children}</Layout>
|
||||
return <Layout>{children}</Layout>;
|
||||
}
|
||||
|
||||
function AdminRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, isLoading, isAdmin } = useAuthStore()
|
||||
const location = useLocation()
|
||||
const { isAuthenticated, isLoading, isAdmin } = useAuthStore();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return <PageLoader variant="light" />
|
||||
return <PageLoader variant="light" />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Сохраняем текущий URL для возврата после авторизации
|
||||
saveReturnUrl()
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />
|
||||
saveReturnUrl();
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/" replace />
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <Layout>{children}</Layout>
|
||||
return <Layout>{children}</Layout>;
|
||||
}
|
||||
|
||||
// Suspense wrapper for lazy components
|
||||
function LazyPage({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Suspense fallback={<PageLoader variant="dark" />}>
|
||||
{children}
|
||||
</Suspense>
|
||||
)
|
||||
return <Suspense fallback={<PageLoader variant="dark" />}>{children}</Suspense>;
|
||||
}
|
||||
|
||||
function BlockingOverlay() {
|
||||
const { blockingType } = useBlockingStore()
|
||||
const { blockingType } = useBlockingStore();
|
||||
|
||||
if (blockingType === 'maintenance') {
|
||||
return <MaintenanceScreen />
|
||||
return <MaintenanceScreen />;
|
||||
}
|
||||
|
||||
if (blockingType === 'channel_subscription') {
|
||||
return <ChannelSubscriptionScreen />
|
||||
return <ChannelSubscriptionScreen />;
|
||||
}
|
||||
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
function App() {
|
||||
useAnalyticsCounters()
|
||||
useAnalyticsCounters();
|
||||
|
||||
return (
|
||||
<>
|
||||
<BlockingOverlay />
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/auth/telegram/callback" element={<TelegramCallback />} />
|
||||
<Route path="/auth/telegram" element={<TelegramRedirect />} />
|
||||
<Route path="/tg" element={<TelegramRedirect />} />
|
||||
<Route path="/connect" element={<DeepLinkRedirect />} />
|
||||
<Route path="/add" element={<DeepLinkRedirect />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/auth/telegram/callback" element={<TelegramCallback />} />
|
||||
<Route path="/auth/telegram" element={<TelegramRedirect />} />
|
||||
<Route path="/tg" element={<TelegramRedirect />} />
|
||||
<Route path="/connect" element={<DeepLinkRedirect />} />
|
||||
<Route path="/add" element={<DeepLinkRedirect />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage><Dashboard /></LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/subscription"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage><Subscription /></LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/balance"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage><Balance /></LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/referral"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage><Referral /></LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/support"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage><Support /></LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage><Profile /></LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/contests"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage><Contests /></LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/polls"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage><Polls /></LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/info"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage><Info /></LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/wheel"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage><Wheel /></LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<Dashboard />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/subscription"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<Subscription />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/balance"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<Balance />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/referral"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<Referral />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/support"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<Support />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/profile"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<Profile />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/contests"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<Contests />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/polls"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<Polls />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/info"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<Info />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/wheel"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LazyPage>
|
||||
<Wheel />
|
||||
</LazyPage>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Admin routes */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage><AdminPanel /></LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/tickets"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage><AdminTickets /></LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/settings"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage><AdminSettings /></LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/apps"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage><AdminApps /></LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/wheel"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage><AdminWheel /></LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/tariffs"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage><AdminTariffs /></LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/servers"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage><AdminServers /></LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/dashboard"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage><AdminDashboard /></LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/ban-system"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage><AdminBanSystem /></LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/broadcasts"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage><AdminBroadcasts /></LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/promocodes"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage><AdminPromocodes /></LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/campaigns"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage><AdminCampaigns /></LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage><AdminUsers /></LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/payments"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage><AdminPayments /></LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
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>
|
||||
}
|
||||
/>
|
||||
{/* Admin routes */}
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminPanel />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/tickets"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminTickets />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/settings"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminSettings />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/apps"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminApps />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/wheel"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminWheel />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/tariffs"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminTariffs />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/servers"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminServers />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/dashboard"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminDashboard />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/ban-system"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminBanSystem />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/broadcasts"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminBroadcasts />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/promocodes"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminPromocodes />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/campaigns"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminCampaigns />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminUsers />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/payments"
|
||||
element={
|
||||
<AdminRoute>
|
||||
<LazyPage>
|
||||
<AdminPayments />
|
||||
</LazyPage>
|
||||
</AdminRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
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 */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
{/* Catch all */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</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 {
|
||||
id: number
|
||||
telegram_id: number
|
||||
username: string | null
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
id: number;
|
||||
telegram_id: number;
|
||||
username: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
}
|
||||
|
||||
export interface AdminTicketMessage {
|
||||
id: number
|
||||
message_text: string
|
||||
is_from_admin: boolean
|
||||
has_media: boolean
|
||||
media_type: string | null
|
||||
media_file_id: string | null
|
||||
media_caption: string | null
|
||||
created_at: string
|
||||
id: number;
|
||||
message_text: string;
|
||||
is_from_admin: boolean;
|
||||
has_media: boolean;
|
||||
media_type: string | null;
|
||||
media_file_id: string | null;
|
||||
media_caption: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdminTicket {
|
||||
id: number
|
||||
title: string
|
||||
status: string
|
||||
priority: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
closed_at: string | null
|
||||
messages_count: number
|
||||
user: AdminTicketUser | null
|
||||
last_message: AdminTicketMessage | null
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
closed_at: string | null;
|
||||
messages_count: number;
|
||||
user: AdminTicketUser | null;
|
||||
last_message: AdminTicketMessage | null;
|
||||
}
|
||||
|
||||
export interface AdminTicketDetail {
|
||||
id: number
|
||||
title: string
|
||||
status: string
|
||||
priority: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
closed_at: string | null
|
||||
is_reply_blocked: boolean
|
||||
user: AdminTicketUser | null
|
||||
messages: AdminTicketMessage[]
|
||||
id: number;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
closed_at: string | null;
|
||||
is_reply_blocked: boolean;
|
||||
user: AdminTicketUser | null;
|
||||
messages: AdminTicketMessage[];
|
||||
}
|
||||
|
||||
export interface AdminTicketStats {
|
||||
total: number
|
||||
open: number
|
||||
pending: number
|
||||
answered: number
|
||||
closed: number
|
||||
total: number;
|
||||
open: number;
|
||||
pending: number;
|
||||
answered: number;
|
||||
closed: number;
|
||||
}
|
||||
|
||||
export interface TicketSettings {
|
||||
sla_enabled: boolean
|
||||
sla_minutes: number
|
||||
sla_check_interval_seconds: number
|
||||
sla_reminder_cooldown_minutes: number
|
||||
support_system_mode: string // tickets, contact, both
|
||||
cabinet_user_notifications_enabled: boolean
|
||||
cabinet_admin_notifications_enabled: boolean
|
||||
sla_enabled: boolean;
|
||||
sla_minutes: number;
|
||||
sla_check_interval_seconds: number;
|
||||
sla_reminder_cooldown_minutes: number;
|
||||
support_system_mode: string; // tickets, contact, both
|
||||
cabinet_user_notifications_enabled: boolean;
|
||||
cabinet_admin_notifications_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface TicketSettingsUpdate {
|
||||
sla_enabled?: boolean
|
||||
sla_minutes?: number
|
||||
sla_check_interval_seconds?: number
|
||||
sla_reminder_cooldown_minutes?: number
|
||||
support_system_mode?: string
|
||||
cabinet_user_notifications_enabled?: boolean
|
||||
cabinet_admin_notifications_enabled?: boolean
|
||||
sla_enabled?: boolean;
|
||||
sla_minutes?: number;
|
||||
sla_check_interval_seconds?: number;
|
||||
sla_reminder_cooldown_minutes?: number;
|
||||
support_system_mode?: string;
|
||||
cabinet_user_notifications_enabled?: boolean;
|
||||
cabinet_admin_notifications_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface AdminTicketListResponse {
|
||||
items: AdminTicket[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
pages: number
|
||||
items: AdminTicket[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
// Check if current user is admin
|
||||
checkIsAdmin: async (): Promise<{ is_admin: boolean }> => {
|
||||
const response = await apiClient.get('/cabinet/auth/me/is-admin')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/auth/me/is-admin');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get ticket statistics
|
||||
getTicketStats: async (): Promise<AdminTicketStats> => {
|
||||
const response = await apiClient.get('/cabinet/admin/tickets/stats')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/tickets/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get all tickets
|
||||
getTickets: async (params: {
|
||||
page?: number
|
||||
per_page?: number
|
||||
status?: string
|
||||
priority?: string
|
||||
} = {}): Promise<AdminTicketListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/tickets', { params })
|
||||
return response.data
|
||||
getTickets: async (
|
||||
params: {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
} = {},
|
||||
): Promise<AdminTicketListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/tickets', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get single ticket with messages
|
||||
getTicket: async (ticketId: number): Promise<AdminTicketDetail> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/tickets/${ticketId}`)
|
||||
return response.data
|
||||
const response = await apiClient.get(`/cabinet/admin/tickets/${ticketId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Reply to ticket
|
||||
replyToTicket: async (ticketId: number, message: string): Promise<AdminTicketMessage> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, { message })
|
||||
return response.data
|
||||
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/reply`, { message });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update ticket status
|
||||
updateTicketStatus: async (ticketId: number, status: string): Promise<AdminTicketDetail> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/status`, { status })
|
||||
return response.data
|
||||
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/status`, { status });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update ticket priority
|
||||
updateTicketPriority: async (ticketId: number, priority: string): Promise<AdminTicketDetail> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/priority`, { priority })
|
||||
return response.data
|
||||
const response = await apiClient.post(`/cabinet/admin/tickets/${ticketId}/priority`, {
|
||||
priority,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get ticket settings
|
||||
getTicketSettings: async (): Promise<TicketSettings> => {
|
||||
const response = await apiClient.get('/cabinet/admin/tickets/settings')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/tickets/settings');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update ticket settings
|
||||
updateTicketSettings: async (settings: TicketSettingsUpdate): Promise<TicketSettings> => {
|
||||
const response = await apiClient.patch('/cabinet/admin/tickets/settings', settings)
|
||||
return response.data
|
||||
const response = await apiClient.patch('/cabinet/admin/tickets/settings', settings);
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
// ============ Dashboard Stats Types ============
|
||||
|
||||
export interface NodeStatus {
|
||||
uuid: string
|
||||
name: string
|
||||
address: string
|
||||
is_connected: boolean
|
||||
is_disabled: boolean
|
||||
users_online: number
|
||||
traffic_used_bytes?: number
|
||||
uptime?: string
|
||||
xray_version?: string
|
||||
node_version?: string
|
||||
last_status_message?: string
|
||||
xray_uptime?: string
|
||||
is_xray_running?: boolean
|
||||
cpu_count?: number
|
||||
cpu_model?: string
|
||||
total_ram?: string
|
||||
country_code?: string
|
||||
uuid: string;
|
||||
name: string;
|
||||
address: string;
|
||||
is_connected: boolean;
|
||||
is_disabled: boolean;
|
||||
users_online: number;
|
||||
traffic_used_bytes?: number;
|
||||
uptime?: string;
|
||||
xray_version?: string;
|
||||
node_version?: string;
|
||||
last_status_message?: string;
|
||||
xray_uptime?: string;
|
||||
is_xray_running?: boolean;
|
||||
cpu_count?: number;
|
||||
cpu_model?: string;
|
||||
total_ram?: string;
|
||||
country_code?: string;
|
||||
}
|
||||
|
||||
export interface NodesOverview {
|
||||
total: number
|
||||
online: number
|
||||
offline: number
|
||||
disabled: number
|
||||
total_users_online: number
|
||||
nodes: NodeStatus[]
|
||||
total: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
disabled: number;
|
||||
total_users_online: number;
|
||||
nodes: NodeStatus[];
|
||||
}
|
||||
|
||||
export interface RevenueData {
|
||||
date: string
|
||||
amount_kopeks: number
|
||||
amount_rubles: number
|
||||
date: string;
|
||||
amount_kopeks: number;
|
||||
amount_rubles: number;
|
||||
}
|
||||
|
||||
export interface SubscriptionStats {
|
||||
total: number
|
||||
active: number
|
||||
trial: number
|
||||
paid: number
|
||||
expired: number
|
||||
purchased_today: number
|
||||
purchased_week: number
|
||||
purchased_month: number
|
||||
trial_to_paid_conversion: number
|
||||
total: number;
|
||||
active: number;
|
||||
trial: number;
|
||||
paid: number;
|
||||
expired: number;
|
||||
purchased_today: number;
|
||||
purchased_week: number;
|
||||
purchased_month: number;
|
||||
trial_to_paid_conversion: number;
|
||||
}
|
||||
|
||||
export interface FinancialStats {
|
||||
income_today_kopeks: number
|
||||
income_today_rubles: number
|
||||
income_month_kopeks: number
|
||||
income_month_rubles: number
|
||||
income_total_kopeks: number
|
||||
income_total_rubles: number
|
||||
subscription_income_kopeks: number
|
||||
subscription_income_rubles: number
|
||||
income_today_kopeks: number;
|
||||
income_today_rubles: number;
|
||||
income_month_kopeks: number;
|
||||
income_month_rubles: number;
|
||||
income_total_kopeks: number;
|
||||
income_total_rubles: number;
|
||||
subscription_income_kopeks: number;
|
||||
subscription_income_rubles: number;
|
||||
}
|
||||
|
||||
export interface ServerStats {
|
||||
total_servers: number
|
||||
available_servers: number
|
||||
servers_with_connections: number
|
||||
total_revenue_kopeks: number
|
||||
total_revenue_rubles: number
|
||||
total_servers: number;
|
||||
available_servers: number;
|
||||
servers_with_connections: number;
|
||||
total_revenue_kopeks: number;
|
||||
total_revenue_rubles: number;
|
||||
}
|
||||
|
||||
export interface TariffStatItem {
|
||||
tariff_id: number
|
||||
tariff_name: string
|
||||
active_subscriptions: number
|
||||
trial_subscriptions: number
|
||||
purchased_today: number
|
||||
purchased_week: number
|
||||
purchased_month: number
|
||||
tariff_id: number;
|
||||
tariff_name: string;
|
||||
active_subscriptions: number;
|
||||
trial_subscriptions: number;
|
||||
purchased_today: number;
|
||||
purchased_week: number;
|
||||
purchased_month: number;
|
||||
}
|
||||
|
||||
export interface TariffStats {
|
||||
tariffs: TariffStatItem[]
|
||||
total_tariff_subscriptions: number
|
||||
tariffs: TariffStatItem[];
|
||||
total_tariff_subscriptions: number;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
nodes: NodesOverview
|
||||
subscriptions: SubscriptionStats
|
||||
financial: FinancialStats
|
||||
servers: ServerStats
|
||||
revenue_chart: RevenueData[]
|
||||
tariff_stats?: TariffStats
|
||||
nodes: NodesOverview;
|
||||
subscriptions: SubscriptionStats;
|
||||
financial: FinancialStats;
|
||||
servers: ServerStats;
|
||||
revenue_chart: RevenueData[];
|
||||
tariff_stats?: TariffStats;
|
||||
}
|
||||
|
||||
// ============ Extended Stats Types ============
|
||||
|
||||
export interface TopReferrerItem {
|
||||
user_id: number
|
||||
telegram_id: number
|
||||
username?: string
|
||||
display_name: string
|
||||
invited_count: number
|
||||
invited_today: number
|
||||
invited_week: number
|
||||
invited_month: number
|
||||
earnings_today_kopeks: number
|
||||
earnings_week_kopeks: number
|
||||
earnings_month_kopeks: number
|
||||
earnings_total_kopeks: number
|
||||
user_id: number;
|
||||
telegram_id: number;
|
||||
username?: string;
|
||||
display_name: string;
|
||||
invited_count: number;
|
||||
invited_today: number;
|
||||
invited_week: number;
|
||||
invited_month: number;
|
||||
earnings_today_kopeks: number;
|
||||
earnings_week_kopeks: number;
|
||||
earnings_month_kopeks: number;
|
||||
earnings_total_kopeks: number;
|
||||
}
|
||||
|
||||
export interface TopReferrersResponse {
|
||||
by_earnings: TopReferrerItem[]
|
||||
by_invited: TopReferrerItem[]
|
||||
total_referrers: number
|
||||
total_referrals: number
|
||||
total_earnings_kopeks: number
|
||||
by_earnings: TopReferrerItem[];
|
||||
by_invited: TopReferrerItem[];
|
||||
total_referrers: number;
|
||||
total_referrals: number;
|
||||
total_earnings_kopeks: number;
|
||||
}
|
||||
|
||||
export interface TopCampaignItem {
|
||||
id: number
|
||||
name: string
|
||||
start_parameter: string
|
||||
bonus_type: string
|
||||
is_active: boolean
|
||||
registrations: number
|
||||
conversions: number
|
||||
conversion_rate: number
|
||||
total_revenue_kopeks: number
|
||||
avg_revenue_per_user_kopeks: number
|
||||
created_at?: string
|
||||
id: number;
|
||||
name: string;
|
||||
start_parameter: string;
|
||||
bonus_type: string;
|
||||
is_active: boolean;
|
||||
registrations: number;
|
||||
conversions: number;
|
||||
conversion_rate: number;
|
||||
total_revenue_kopeks: number;
|
||||
avg_revenue_per_user_kopeks: number;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface TopCampaignsResponse {
|
||||
campaigns: TopCampaignItem[]
|
||||
total_campaigns: number
|
||||
total_registrations: number
|
||||
total_revenue_kopeks: number
|
||||
campaigns: TopCampaignItem[];
|
||||
total_campaigns: number;
|
||||
total_registrations: number;
|
||||
total_revenue_kopeks: number;
|
||||
}
|
||||
|
||||
export interface RecentPaymentItem {
|
||||
id: number
|
||||
user_id: number
|
||||
telegram_id: number
|
||||
username?: string
|
||||
display_name: string
|
||||
amount_kopeks: number
|
||||
amount_rubles: number
|
||||
type: string
|
||||
type_display: string
|
||||
payment_method?: string
|
||||
description?: string
|
||||
created_at: string
|
||||
is_completed: boolean
|
||||
id: number;
|
||||
user_id: number;
|
||||
telegram_id: number;
|
||||
username?: string;
|
||||
display_name: string;
|
||||
amount_kopeks: number;
|
||||
amount_rubles: number;
|
||||
type: string;
|
||||
type_display: string;
|
||||
payment_method?: string;
|
||||
description?: string;
|
||||
created_at: string;
|
||||
is_completed: boolean;
|
||||
}
|
||||
|
||||
export interface RecentPaymentsResponse {
|
||||
payments: RecentPaymentItem[]
|
||||
total_count: number
|
||||
total_today_kopeks: number
|
||||
total_week_kopeks: number
|
||||
payments: RecentPaymentItem[];
|
||||
total_count: number;
|
||||
total_today_kopeks: number;
|
||||
total_week_kopeks: number;
|
||||
}
|
||||
|
||||
// ============ Dashboard Stats API ============
|
||||
@@ -308,43 +312,51 @@ export interface RecentPaymentsResponse {
|
||||
export const statsApi = {
|
||||
// Get complete dashboard stats
|
||||
getDashboardStats: async (): Promise<DashboardStats> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/dashboard')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/stats/dashboard');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get nodes status
|
||||
getNodesStatus: async (): Promise<NodesOverview> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/nodes')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/stats/nodes');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Restart a node
|
||||
restartNode: async (nodeUuid: string): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/stats/nodes/${nodeUuid}/restart`)
|
||||
return response.data
|
||||
const response = await apiClient.post(`/cabinet/admin/stats/nodes/${nodeUuid}/restart`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Toggle node (enable/disable)
|
||||
toggleNode: async (nodeUuid: string): Promise<{ success: boolean; message: string; is_disabled: boolean }> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/stats/nodes/${nodeUuid}/toggle`)
|
||||
return response.data
|
||||
toggleNode: async (
|
||||
nodeUuid: string,
|
||||
): Promise<{ success: boolean; message: string; is_disabled: boolean }> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/stats/nodes/${nodeUuid}/toggle`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get top referrers
|
||||
getTopReferrers: async (limit: number = 20): Promise<TopReferrersResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/referrals/top', { params: { limit } })
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/stats/referrals/top', {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get top campaigns
|
||||
getTopCampaigns: async (limit: number = 20): Promise<TopCampaignsResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/campaigns/top', { params: { limit } })
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/stats/campaigns/top', {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get recent payments
|
||||
getRecentPayments: async (limit: number = 50): Promise<RecentPaymentsResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/stats/payments/recent', { params: { limit } })
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/stats/payments/recent', {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,220 +1,240 @@
|
||||
import apiClient from './client'
|
||||
import apiClient from './client';
|
||||
|
||||
export interface LocalizedText {
|
||||
en: string
|
||||
ru: string
|
||||
zh?: string
|
||||
fa?: string
|
||||
fr?: string
|
||||
en: string;
|
||||
ru: string;
|
||||
zh?: string;
|
||||
fa?: string;
|
||||
fr?: string;
|
||||
}
|
||||
|
||||
export interface AppButton {
|
||||
id?: string // Unique identifier for React key (client-side only)
|
||||
buttonLink: string
|
||||
buttonText: LocalizedText
|
||||
id?: string; // Unique identifier for React key (client-side only)
|
||||
buttonLink: string;
|
||||
buttonText: LocalizedText;
|
||||
}
|
||||
|
||||
export interface AppStep {
|
||||
description: LocalizedText
|
||||
buttons?: AppButton[]
|
||||
title?: LocalizedText
|
||||
description: LocalizedText;
|
||||
buttons?: AppButton[];
|
||||
title?: LocalizedText;
|
||||
}
|
||||
|
||||
export interface AppDefinition {
|
||||
id: string
|
||||
name: string
|
||||
isFeatured: boolean
|
||||
urlScheme: string
|
||||
isNeedBase64Encoding?: boolean
|
||||
installationStep: AppStep
|
||||
addSubscriptionStep: AppStep
|
||||
connectAndUseStep: AppStep
|
||||
additionalBeforeAddSubscriptionStep?: AppStep
|
||||
additionalAfterAddSubscriptionStep?: AppStep
|
||||
id: string;
|
||||
name: string;
|
||||
isFeatured: boolean;
|
||||
urlScheme: string;
|
||||
isNeedBase64Encoding?: boolean;
|
||||
installationStep: AppStep;
|
||||
addSubscriptionStep: AppStep;
|
||||
connectAndUseStep: AppStep;
|
||||
additionalBeforeAddSubscriptionStep?: AppStep;
|
||||
additionalAfterAddSubscriptionStep?: AppStep;
|
||||
}
|
||||
|
||||
export interface AppConfigBranding {
|
||||
name: string
|
||||
logoUrl: string
|
||||
supportUrl: string
|
||||
name: string;
|
||||
logoUrl: string;
|
||||
supportUrl: string;
|
||||
}
|
||||
|
||||
export interface AppConfigConfig {
|
||||
additionalLocales: string[]
|
||||
branding: AppConfigBranding
|
||||
additionalLocales: string[];
|
||||
branding: AppConfigBranding;
|
||||
}
|
||||
|
||||
export interface AppConfigResponse {
|
||||
config: AppConfigConfig
|
||||
platforms: Record<string, AppDefinition[]>
|
||||
config: AppConfigConfig;
|
||||
platforms: Record<string, AppDefinition[]>;
|
||||
}
|
||||
|
||||
export const adminAppsApi = {
|
||||
// Get full app config
|
||||
getConfig: async (): Promise<AppConfigResponse> => {
|
||||
const response = await apiClient.get<AppConfigResponse>('/cabinet/admin/apps')
|
||||
return response.data
|
||||
const response = await apiClient.get<AppConfigResponse>('/cabinet/admin/apps');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get available platforms
|
||||
getPlatforms: async (): Promise<string[]> => {
|
||||
const response = await apiClient.get<string[]>('/cabinet/admin/apps/platforms')
|
||||
return response.data
|
||||
const response = await apiClient.get<string[]>('/cabinet/admin/apps/platforms');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get apps for a platform
|
||||
getPlatformApps: async (platform: string): Promise<AppDefinition[]> => {
|
||||
const response = await apiClient.get<AppDefinition[]>(`/cabinet/admin/apps/platforms/${platform}`)
|
||||
return response.data
|
||||
const response = await apiClient.get<AppDefinition[]>(
|
||||
`/cabinet/admin/apps/platforms/${platform}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create a new app
|
||||
createApp: async (platform: string, app: AppDefinition): Promise<AppDefinition> => {
|
||||
const response = await apiClient.post<AppDefinition>(`/cabinet/admin/apps/platforms/${platform}`, {
|
||||
platform,
|
||||
app,
|
||||
})
|
||||
return response.data
|
||||
const response = await apiClient.post<AppDefinition>(
|
||||
`/cabinet/admin/apps/platforms/${platform}`,
|
||||
{
|
||||
platform,
|
||||
app,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update an app
|
||||
updateApp: async (platform: string, appId: string, app: AppDefinition): Promise<AppDefinition> => {
|
||||
const response = await apiClient.put<AppDefinition>(`/cabinet/admin/apps/platforms/${platform}/${appId}`, {
|
||||
app,
|
||||
})
|
||||
return response.data
|
||||
updateApp: async (
|
||||
platform: string,
|
||||
appId: string,
|
||||
app: AppDefinition,
|
||||
): Promise<AppDefinition> => {
|
||||
const response = await apiClient.put<AppDefinition>(
|
||||
`/cabinet/admin/apps/platforms/${platform}/${appId}`,
|
||||
{
|
||||
app,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete an app
|
||||
deleteApp: async (platform: string, appId: string): Promise<void> => {
|
||||
await apiClient.delete(`/cabinet/admin/apps/platforms/${platform}/${appId}`)
|
||||
await apiClient.delete(`/cabinet/admin/apps/platforms/${platform}/${appId}`);
|
||||
},
|
||||
|
||||
// Reorder apps
|
||||
reorderApps: async (platform: string, appIds: string[]): Promise<void> => {
|
||||
await apiClient.post(`/cabinet/admin/apps/platforms/${platform}/reorder`, {
|
||||
app_ids: appIds,
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
// Copy app to another platform
|
||||
copyApp: async (platform: string, appId: string, targetPlatform: string): Promise<{ new_id: string }> => {
|
||||
copyApp: async (
|
||||
platform: string,
|
||||
appId: string,
|
||||
targetPlatform: string,
|
||||
): Promise<{ new_id: string }> => {
|
||||
const response = await apiClient.post<{ new_id: string; target_platform: string }>(
|
||||
`/cabinet/admin/apps/platforms/${platform}/copy/${appId}?target_platform=${targetPlatform}`
|
||||
)
|
||||
return response.data
|
||||
`/cabinet/admin/apps/platforms/${platform}/copy/${appId}?target_platform=${targetPlatform}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get branding
|
||||
getBranding: async (): Promise<AppConfigBranding> => {
|
||||
const response = await apiClient.get<AppConfigBranding>('/cabinet/admin/apps/branding')
|
||||
return response.data
|
||||
const response = await apiClient.get<AppConfigBranding>('/cabinet/admin/apps/branding');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update branding
|
||||
updateBranding: async (branding: AppConfigBranding): Promise<AppConfigBranding> => {
|
||||
const response = await apiClient.put<AppConfigBranding>('/cabinet/admin/apps/branding', {
|
||||
branding,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get RemnaWave config status
|
||||
getRemnaWaveStatus: async (): Promise<{ enabled: boolean; config_uuid: string | null }> => {
|
||||
const response = await apiClient.get<{ enabled: boolean; config_uuid: string | null }>(
|
||||
'/cabinet/admin/apps/remnawave/status'
|
||||
)
|
||||
return response.data
|
||||
'/cabinet/admin/apps/remnawave/status',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Set RemnaWave config UUID
|
||||
setRemnaWaveUuid: async (uuid: string | null): Promise<{ enabled: boolean; config_uuid: string | null }> => {
|
||||
setRemnaWaveUuid: async (
|
||||
uuid: string | null,
|
||||
): Promise<{ enabled: boolean; config_uuid: string | null }> => {
|
||||
const response = await apiClient.put<{ enabled: boolean; config_uuid: string | null }>(
|
||||
'/cabinet/admin/apps/remnawave/uuid',
|
||||
{ uuid }
|
||||
)
|
||||
return response.data
|
||||
{ uuid },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// List available RemnaWave configs
|
||||
listRemnaWaveConfigs: async (): Promise<{ uuid: string; name: string; view_position: number }[]> => {
|
||||
listRemnaWaveConfigs: async (): Promise<
|
||||
{ uuid: string; name: string; view_position: number }[]
|
||||
> => {
|
||||
const response = await apiClient.get<{ uuid: string; name: string; view_position: number }[]>(
|
||||
'/cabinet/admin/apps/remnawave/configs'
|
||||
)
|
||||
return response.data
|
||||
'/cabinet/admin/apps/remnawave/configs',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get RemnaWave subscription config
|
||||
getRemnaWaveConfig: async (): Promise<RemnawaveConfig> => {
|
||||
const response = await apiClient.get<{
|
||||
uuid: string
|
||||
name: string
|
||||
view_position: number
|
||||
config: RemnawaveConfig
|
||||
}>('/cabinet/admin/apps/remnawave/config')
|
||||
return response.data.config
|
||||
uuid: string;
|
||||
name: string;
|
||||
view_position: number;
|
||||
config: RemnawaveConfig;
|
||||
}>('/cabinet/admin/apps/remnawave/config');
|
||||
return response.data.config;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
// ============== RemnaWave Format Types ==============
|
||||
|
||||
export interface RemnawaveButton {
|
||||
url: string
|
||||
text: LocalizedText
|
||||
url: string;
|
||||
text: LocalizedText;
|
||||
}
|
||||
|
||||
export interface RemnawaveBlock {
|
||||
title: LocalizedText
|
||||
description: LocalizedText
|
||||
buttons?: RemnawaveButton[]
|
||||
svgIconKey?: string
|
||||
svgIconColor?: string
|
||||
title: LocalizedText;
|
||||
description: LocalizedText;
|
||||
buttons?: RemnawaveButton[];
|
||||
svgIconKey?: string;
|
||||
svgIconColor?: string;
|
||||
}
|
||||
|
||||
export interface RemnawaveApp {
|
||||
name: string
|
||||
featured?: boolean
|
||||
urlScheme?: string
|
||||
isNeedBase64Encoding?: boolean
|
||||
blocks: RemnawaveBlock[]
|
||||
name: string;
|
||||
featured?: boolean;
|
||||
urlScheme?: string;
|
||||
isNeedBase64Encoding?: boolean;
|
||||
blocks: RemnawaveBlock[];
|
||||
}
|
||||
|
||||
export interface RemnawavePlatform {
|
||||
apps: RemnawaveApp[]
|
||||
apps: RemnawaveApp[];
|
||||
}
|
||||
|
||||
export interface RemnawaveSvgItem {
|
||||
svgString: string
|
||||
tags?: string[]
|
||||
svgString: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface RemnawaveBaseSettings {
|
||||
isShowTutorialButton: boolean
|
||||
tutorialUrl: string
|
||||
isShowTutorialButton: boolean;
|
||||
tutorialUrl: string;
|
||||
}
|
||||
|
||||
export interface RemnawaveBaseTranslations {
|
||||
installApp: LocalizedText
|
||||
addSubscription: LocalizedText
|
||||
connectAndUse: LocalizedText
|
||||
copyLink: LocalizedText
|
||||
openApp: LocalizedText
|
||||
tutorial: LocalizedText
|
||||
close: LocalizedText
|
||||
installApp: LocalizedText;
|
||||
addSubscription: LocalizedText;
|
||||
connectAndUse: LocalizedText;
|
||||
copyLink: LocalizedText;
|
||||
openApp: LocalizedText;
|
||||
tutorial: LocalizedText;
|
||||
close: LocalizedText;
|
||||
}
|
||||
|
||||
export interface RemnawaveBrandingSettings {
|
||||
name: string
|
||||
logoUrl: string
|
||||
supportUrl: string
|
||||
name: string;
|
||||
logoUrl: string;
|
||||
supportUrl: string;
|
||||
}
|
||||
|
||||
export interface RemnawaveConfig {
|
||||
platforms: Record<string, RemnawavePlatform>
|
||||
svgLibrary?: Record<string, RemnawaveSvgItem>
|
||||
baseSettings?: RemnawaveBaseSettings
|
||||
baseTranslations?: RemnawaveBaseTranslations
|
||||
brandingSettings?: RemnawaveBrandingSettings
|
||||
platforms: Record<string, RemnawavePlatform>;
|
||||
svgLibrary?: Record<string, RemnawaveSvgItem>;
|
||||
baseSettings?: RemnawaveBaseSettings;
|
||||
baseTranslations?: RemnawaveBaseTranslations;
|
||||
brandingSettings?: RemnawaveBrandingSettings;
|
||||
}
|
||||
|
||||
// ============== Converter Functions ==============
|
||||
@@ -225,23 +245,29 @@ const emptyLocalizedText = (): LocalizedText => ({
|
||||
zh: '',
|
||||
fa: '',
|
||||
fr: '',
|
||||
})
|
||||
});
|
||||
|
||||
// Convert Cabinet button to RemnaWave button
|
||||
const cabinetButtonToRemnawave = (button: AppButton): RemnawaveButton => ({
|
||||
url: button.buttonLink,
|
||||
text: { ...emptyLocalizedText(), ...button.buttonText },
|
||||
})
|
||||
});
|
||||
|
||||
// Convert RemnaWave button to Cabinet button
|
||||
const remnawaveButtonToCabinet = (button: RemnawaveButton): AppButton => ({
|
||||
buttonLink: button.url,
|
||||
buttonText: { en: button.text.en || '', ru: button.text.ru || '', zh: button.text.zh, fa: button.text.fa, fr: button.text.fr },
|
||||
})
|
||||
buttonText: {
|
||||
en: button.text.en || '',
|
||||
ru: button.text.ru || '',
|
||||
zh: button.text.zh,
|
||||
fa: button.text.fa,
|
||||
fr: button.text.fr,
|
||||
},
|
||||
});
|
||||
|
||||
// Convert Cabinet app to RemnaWave app format
|
||||
export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
|
||||
const blocks: RemnawaveBlock[] = []
|
||||
const blocks: RemnawaveBlock[] = [];
|
||||
|
||||
// Block 1: Installation
|
||||
blocks.push({
|
||||
@@ -250,17 +276,27 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
|
||||
buttons: app.installationStep.buttons?.map(cabinetButtonToRemnawave),
|
||||
svgIconKey: 'download',
|
||||
svgIconColor: '#3B82F6',
|
||||
})
|
||||
});
|
||||
|
||||
// Block 2 (optional): Additional before subscription
|
||||
if (app.additionalBeforeAddSubscriptionStep?.description?.en || app.additionalBeforeAddSubscriptionStep?.description?.ru) {
|
||||
if (
|
||||
app.additionalBeforeAddSubscriptionStep?.description?.en ||
|
||||
app.additionalBeforeAddSubscriptionStep?.description?.ru
|
||||
) {
|
||||
blocks.push({
|
||||
title: app.additionalBeforeAddSubscriptionStep.title || { ...emptyLocalizedText(), en: 'Preparation', ru: 'Подготовка' },
|
||||
description: { ...emptyLocalizedText(), ...app.additionalBeforeAddSubscriptionStep.description },
|
||||
title: app.additionalBeforeAddSubscriptionStep.title || {
|
||||
...emptyLocalizedText(),
|
||||
en: 'Preparation',
|
||||
ru: 'Подготовка',
|
||||
},
|
||||
description: {
|
||||
...emptyLocalizedText(),
|
||||
...app.additionalBeforeAddSubscriptionStep.description,
|
||||
},
|
||||
buttons: app.additionalBeforeAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
|
||||
svgIconKey: 'settings',
|
||||
svgIconColor: '#8B5CF6',
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Block 3: Add subscription
|
||||
@@ -270,17 +306,27 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
|
||||
buttons: app.addSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
|
||||
svgIconKey: 'plus',
|
||||
svgIconColor: '#10B981',
|
||||
})
|
||||
});
|
||||
|
||||
// Block 4 (optional): Additional after subscription
|
||||
if (app.additionalAfterAddSubscriptionStep?.description?.en || app.additionalAfterAddSubscriptionStep?.description?.ru) {
|
||||
if (
|
||||
app.additionalAfterAddSubscriptionStep?.description?.en ||
|
||||
app.additionalAfterAddSubscriptionStep?.description?.ru
|
||||
) {
|
||||
blocks.push({
|
||||
title: app.additionalAfterAddSubscriptionStep.title || { ...emptyLocalizedText(), en: 'Configuration', ru: 'Настройка' },
|
||||
description: { ...emptyLocalizedText(), ...app.additionalAfterAddSubscriptionStep.description },
|
||||
title: app.additionalAfterAddSubscriptionStep.title || {
|
||||
...emptyLocalizedText(),
|
||||
en: 'Configuration',
|
||||
ru: 'Настройка',
|
||||
},
|
||||
description: {
|
||||
...emptyLocalizedText(),
|
||||
...app.additionalAfterAddSubscriptionStep.description,
|
||||
},
|
||||
buttons: app.additionalAfterAddSubscriptionStep.buttons?.map(cabinetButtonToRemnawave),
|
||||
svgIconKey: 'settings',
|
||||
svgIconColor: '#F59E0B',
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Block 5: Connect and use
|
||||
@@ -290,7 +336,7 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
|
||||
buttons: app.connectAndUseStep.buttons?.map(cabinetButtonToRemnawave),
|
||||
svgIconKey: 'check',
|
||||
svgIconColor: '#22C55E',
|
||||
})
|
||||
});
|
||||
|
||||
return {
|
||||
name: app.name,
|
||||
@@ -298,78 +344,104 @@ export const cabinetAppToRemnawave = (app: AppDefinition): RemnawaveApp => {
|
||||
urlScheme: app.urlScheme,
|
||||
isNeedBase64Encoding: app.isNeedBase64Encoding,
|
||||
blocks,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Convert RemnaWave app to Cabinet app format
|
||||
export const remnawaveAppToCabinet = (app: RemnawaveApp, platform: string): AppDefinition => {
|
||||
const blocks = app.blocks || []
|
||||
const blocks = app.blocks || [];
|
||||
|
||||
// Default empty step
|
||||
const emptyStep = (): AppStep => ({
|
||||
description: emptyLocalizedText(),
|
||||
buttons: [],
|
||||
})
|
||||
});
|
||||
|
||||
// Try to identify blocks by their titles or position
|
||||
let installationBlock: RemnawaveBlock | undefined
|
||||
let subscriptionBlock: RemnawaveBlock | undefined
|
||||
let connectBlock: RemnawaveBlock | undefined
|
||||
let beforeSubBlock: RemnawaveBlock | undefined
|
||||
let afterSubBlock: RemnawaveBlock | undefined
|
||||
let installationBlock: RemnawaveBlock | undefined;
|
||||
let subscriptionBlock: RemnawaveBlock | undefined;
|
||||
let connectBlock: RemnawaveBlock | undefined;
|
||||
let beforeSubBlock: RemnawaveBlock | undefined;
|
||||
let afterSubBlock: RemnawaveBlock | undefined;
|
||||
|
||||
// First pass: try to identify by title keywords
|
||||
for (const block of blocks) {
|
||||
const enTitle = (block.title?.en || '').toLowerCase()
|
||||
const ruTitle = (block.title?.ru || '').toLowerCase()
|
||||
const enTitle = (block.title?.en || '').toLowerCase();
|
||||
const ruTitle = (block.title?.ru || '').toLowerCase();
|
||||
|
||||
if (enTitle.includes('install') || ruTitle.includes('установ') || ruTitle.includes('скачай')) {
|
||||
if (!installationBlock) installationBlock = block
|
||||
} else if (enTitle.includes('subscription') || enTitle.includes('add') || ruTitle.includes('подписк') || ruTitle.includes('добав')) {
|
||||
if (!subscriptionBlock) subscriptionBlock = block
|
||||
} else if (enTitle.includes('connect') || enTitle.includes('use') || ruTitle.includes('подключ') || ruTitle.includes('использ')) {
|
||||
if (!connectBlock) connectBlock = block
|
||||
if (!installationBlock) installationBlock = block;
|
||||
} else if (
|
||||
enTitle.includes('subscription') ||
|
||||
enTitle.includes('add') ||
|
||||
ruTitle.includes('подписк') ||
|
||||
ruTitle.includes('добав')
|
||||
) {
|
||||
if (!subscriptionBlock) subscriptionBlock = block;
|
||||
} else if (
|
||||
enTitle.includes('connect') ||
|
||||
enTitle.includes('use') ||
|
||||
ruTitle.includes('подключ') ||
|
||||
ruTitle.includes('использ')
|
||||
) {
|
||||
if (!connectBlock) connectBlock = block;
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: assign remaining blocks by position if not found by title
|
||||
if (!installationBlock && blocks.length > 0) {
|
||||
installationBlock = blocks[0]
|
||||
installationBlock = blocks[0];
|
||||
}
|
||||
if (!subscriptionBlock && blocks.length > 1) {
|
||||
subscriptionBlock = blocks.find(b => b !== installationBlock) || blocks[1]
|
||||
subscriptionBlock = blocks.find((b) => b !== installationBlock) || blocks[1];
|
||||
}
|
||||
if (!connectBlock && blocks.length > 2) {
|
||||
connectBlock = blocks.find(b => b !== installationBlock && b !== subscriptionBlock) || blocks[blocks.length - 1]
|
||||
connectBlock =
|
||||
blocks.find((b) => b !== installationBlock && b !== subscriptionBlock) ||
|
||||
blocks[blocks.length - 1];
|
||||
}
|
||||
|
||||
// Assign additional blocks
|
||||
const additionalBlocks = blocks.filter(b =>
|
||||
b !== installationBlock && b !== subscriptionBlock && b !== connectBlock
|
||||
)
|
||||
const additionalBlocks = blocks.filter(
|
||||
(b) => b !== installationBlock && b !== subscriptionBlock && b !== connectBlock,
|
||||
);
|
||||
if (additionalBlocks.length >= 1) {
|
||||
// Check if block appears before subscription
|
||||
const subIndex = blocks.indexOf(subscriptionBlock!)
|
||||
const firstAdditionalIndex = blocks.indexOf(additionalBlocks[0])
|
||||
const subIndex = blocks.indexOf(subscriptionBlock!);
|
||||
const firstAdditionalIndex = blocks.indexOf(additionalBlocks[0]);
|
||||
if (firstAdditionalIndex < subIndex) {
|
||||
beforeSubBlock = additionalBlocks[0]
|
||||
beforeSubBlock = additionalBlocks[0];
|
||||
if (additionalBlocks.length >= 2) {
|
||||
afterSubBlock = additionalBlocks[1]
|
||||
afterSubBlock = additionalBlocks[1];
|
||||
}
|
||||
} else {
|
||||
afterSubBlock = additionalBlocks[0]
|
||||
afterSubBlock = additionalBlocks[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Convert block to cabinet step
|
||||
const blockToStep = (block: RemnawaveBlock | undefined): AppStep => {
|
||||
if (!block) return emptyStep()
|
||||
if (!block) return emptyStep();
|
||||
return {
|
||||
description: { en: block.description?.en || '', ru: block.description?.ru || '', zh: block.description?.zh, fa: block.description?.fa, fr: block.description?.fr },
|
||||
title: block.title ? { en: block.title.en || '', ru: block.title.ru || '', zh: block.title.zh, fa: block.title.fa, fr: block.title.fr } : undefined,
|
||||
description: {
|
||||
en: block.description?.en || '',
|
||||
ru: block.description?.ru || '',
|
||||
zh: block.description?.zh,
|
||||
fa: block.description?.fa,
|
||||
fr: block.description?.fr,
|
||||
},
|
||||
title: block.title
|
||||
? {
|
||||
en: block.title.en || '',
|
||||
ru: block.title.ru || '',
|
||||
zh: block.title.zh,
|
||||
fa: block.title.fa,
|
||||
fr: block.title.fr,
|
||||
}
|
||||
: undefined,
|
||||
buttons: block.buttons?.map(remnawaveButtonToCabinet),
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
id: `${app.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${platform}-${Date.now()}`,
|
||||
@@ -382,66 +454,114 @@ export const remnawaveAppToCabinet = (app: RemnawaveApp, platform: string): AppD
|
||||
connectAndUseStep: blockToStep(connectBlock),
|
||||
additionalBeforeAddSubscriptionStep: beforeSubBlock ? blockToStep(beforeSubBlock) : undefined,
|
||||
additionalAfterAddSubscriptionStep: afterSubBlock ? blockToStep(afterSubBlock) : undefined,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Export all apps from cabinet to RemnaWave format
|
||||
export const exportToRemnawaveFormat = (
|
||||
platformApps: Record<string, AppDefinition[]>,
|
||||
branding?: AppConfigBranding
|
||||
branding?: AppConfigBranding,
|
||||
): RemnawaveConfig => {
|
||||
const platforms: Record<string, RemnawavePlatform> = {}
|
||||
const platforms: Record<string, RemnawavePlatform> = {};
|
||||
|
||||
for (const [platform, apps] of Object.entries(platformApps)) {
|
||||
platforms[platform] = {
|
||||
apps: apps.map(cabinetAppToRemnawave),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
platforms,
|
||||
svgLibrary: {
|
||||
download: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>' },
|
||||
plus: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>' },
|
||||
check: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>' },
|
||||
settings: { svgString: '<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>' },
|
||||
download: {
|
||||
svgString:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" /></svg>',
|
||||
},
|
||||
plus: {
|
||||
svgString:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>',
|
||||
},
|
||||
check: {
|
||||
svgString:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /></svg>',
|
||||
},
|
||||
settings: {
|
||||
svgString:
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>',
|
||||
},
|
||||
},
|
||||
baseSettings: {
|
||||
isShowTutorialButton: false,
|
||||
tutorialUrl: '',
|
||||
},
|
||||
baseTranslations: {
|
||||
installApp: { en: 'Install App', ru: 'Установить приложение', zh: '安装应用', fa: 'نصب برنامه', fr: 'Installer l\'application' },
|
||||
addSubscription: { en: 'Add Subscription', ru: 'Добавить подписку', zh: '添加订阅', fa: 'اضافه کردن اشتراک', fr: 'Ajouter un abonnement' },
|
||||
connectAndUse: { en: 'Connect and Use', ru: 'Подключиться и использовать', zh: '连接并使用', fa: 'اتصال و استفاده', fr: 'Connecter et utiliser' },
|
||||
copyLink: { en: 'Copy Link', ru: 'Скопировать ссылку', zh: '复制链接', fa: 'کپی لینک', fr: 'Copier le lien' },
|
||||
openApp: { en: 'Open App', ru: 'Открыть приложение', zh: '打开应用', fa: 'باز کردن برنامه', fr: 'Ouvrir l\'application' },
|
||||
installApp: {
|
||||
en: 'Install App',
|
||||
ru: 'Установить приложение',
|
||||
zh: '安装应用',
|
||||
fa: 'نصب برنامه',
|
||||
fr: "Installer l'application",
|
||||
},
|
||||
addSubscription: {
|
||||
en: 'Add Subscription',
|
||||
ru: 'Добавить подписку',
|
||||
zh: '添加订阅',
|
||||
fa: 'اضافه کردن اشتراک',
|
||||
fr: 'Ajouter un abonnement',
|
||||
},
|
||||
connectAndUse: {
|
||||
en: 'Connect and Use',
|
||||
ru: 'Подключиться и использовать',
|
||||
zh: '连接并使用',
|
||||
fa: 'اتصال و استفاده',
|
||||
fr: 'Connecter et utiliser',
|
||||
},
|
||||
copyLink: {
|
||||
en: 'Copy Link',
|
||||
ru: 'Скопировать ссылку',
|
||||
zh: '复制链接',
|
||||
fa: 'کپی لینک',
|
||||
fr: 'Copier le lien',
|
||||
},
|
||||
openApp: {
|
||||
en: 'Open App',
|
||||
ru: 'Открыть приложение',
|
||||
zh: '打开应用',
|
||||
fa: 'باز کردن برنامه',
|
||||
fr: "Ouvrir l'application",
|
||||
},
|
||||
tutorial: { en: 'Tutorial', ru: 'Инструкция', zh: '教程', fa: 'آموزش', fr: 'Tutoriel' },
|
||||
close: { en: 'Close', ru: 'Закрыть', zh: '关闭', fa: 'بستن', fr: 'Fermer' },
|
||||
},
|
||||
brandingSettings: branding ? {
|
||||
name: branding.name,
|
||||
logoUrl: branding.logoUrl,
|
||||
supportUrl: branding.supportUrl,
|
||||
} : undefined,
|
||||
}
|
||||
}
|
||||
brandingSettings: branding
|
||||
? {
|
||||
name: branding.name,
|
||||
logoUrl: branding.logoUrl,
|
||||
supportUrl: branding.supportUrl,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
// Import apps from RemnaWave format to cabinet
|
||||
export const importFromRemnawaveFormat = (
|
||||
config: RemnawaveConfig
|
||||
config: RemnawaveConfig,
|
||||
): { platformApps: Record<string, AppDefinition[]>; branding?: AppConfigBranding } => {
|
||||
const platformApps: Record<string, AppDefinition[]> = {}
|
||||
const platformApps: Record<string, AppDefinition[]> = {};
|
||||
|
||||
for (const [platform, platformData] of Object.entries(config.platforms || {})) {
|
||||
platformApps[platform] = (platformData.apps || []).map(app => remnawaveAppToCabinet(app, platform))
|
||||
platformApps[platform] = (platformData.apps || []).map((app) =>
|
||||
remnawaveAppToCabinet(app, platform),
|
||||
);
|
||||
}
|
||||
|
||||
const branding = config.brandingSettings ? {
|
||||
name: config.brandingSettings.name,
|
||||
logoUrl: config.brandingSettings.logoUrl,
|
||||
supportUrl: config.brandingSettings.supportUrl,
|
||||
} : undefined
|
||||
const branding = config.brandingSettings
|
||||
? {
|
||||
name: config.brandingSettings.name,
|
||||
logoUrl: config.brandingSettings.logoUrl,
|
||||
supportUrl: config.brandingSettings.supportUrl,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return { platformApps, branding }
|
||||
}
|
||||
return { platformApps, branding };
|
||||
};
|
||||
|
||||
@@ -1,168 +1,187 @@
|
||||
import apiClient from './client'
|
||||
import apiClient from './client';
|
||||
|
||||
// Types
|
||||
export interface BroadcastFilter {
|
||||
key: string
|
||||
label: string
|
||||
count: number | null
|
||||
group: string | null
|
||||
key: string;
|
||||
label: string;
|
||||
count: number | null;
|
||||
group: string | null;
|
||||
}
|
||||
|
||||
export interface TariffFilter {
|
||||
key: string
|
||||
label: string
|
||||
tariff_id: number
|
||||
count: number
|
||||
key: string;
|
||||
label: string;
|
||||
tariff_id: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface BroadcastFiltersResponse {
|
||||
filters: BroadcastFilter[]
|
||||
tariff_filters: TariffFilter[]
|
||||
custom_filters: BroadcastFilter[]
|
||||
filters: BroadcastFilter[];
|
||||
tariff_filters: TariffFilter[];
|
||||
custom_filters: BroadcastFilter[];
|
||||
}
|
||||
|
||||
export interface TariffForBroadcast {
|
||||
id: number
|
||||
name: string
|
||||
filter_key: string
|
||||
active_users_count: number
|
||||
id: number;
|
||||
name: string;
|
||||
filter_key: string;
|
||||
active_users_count: number;
|
||||
}
|
||||
|
||||
export interface BroadcastTariffsResponse {
|
||||
tariffs: TariffForBroadcast[]
|
||||
tariffs: TariffForBroadcast[];
|
||||
}
|
||||
|
||||
export interface BroadcastButton {
|
||||
key: string
|
||||
label: string
|
||||
default: boolean
|
||||
key: string;
|
||||
label: string;
|
||||
default: boolean;
|
||||
}
|
||||
|
||||
export interface BroadcastButtonsResponse {
|
||||
buttons: BroadcastButton[]
|
||||
buttons: BroadcastButton[];
|
||||
}
|
||||
|
||||
export interface BroadcastMedia {
|
||||
type: 'photo' | 'video' | 'document'
|
||||
file_id: string
|
||||
caption?: string
|
||||
type: 'photo' | 'video' | 'document';
|
||||
file_id: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
export interface BroadcastCreateRequest {
|
||||
target: string
|
||||
message_text: string
|
||||
selected_buttons: string[]
|
||||
media?: BroadcastMedia
|
||||
target: string;
|
||||
message_text: string;
|
||||
selected_buttons: string[];
|
||||
media?: BroadcastMedia;
|
||||
}
|
||||
|
||||
export interface Broadcast {
|
||||
id: number
|
||||
target_type: string
|
||||
message_text: string
|
||||
has_media: boolean
|
||||
media_type: string | null
|
||||
media_file_id: string | null
|
||||
media_caption: string | null
|
||||
total_count: number
|
||||
sent_count: number
|
||||
failed_count: number
|
||||
status: 'queued' | 'in_progress' | 'completed' | 'partial' | 'failed' | 'cancelled' | 'cancelling'
|
||||
admin_id: number | null
|
||||
admin_name: string | null
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
progress_percent: number
|
||||
id: number;
|
||||
target_type: string;
|
||||
message_text: string;
|
||||
has_media: boolean;
|
||||
media_type: string | null;
|
||||
media_file_id: string | null;
|
||||
media_caption: string | null;
|
||||
total_count: number;
|
||||
sent_count: number;
|
||||
failed_count: number;
|
||||
status:
|
||||
| 'queued'
|
||||
| 'in_progress'
|
||||
| 'completed'
|
||||
| 'partial'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
| 'cancelling';
|
||||
admin_id: number | null;
|
||||
admin_name: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
progress_percent: number;
|
||||
}
|
||||
|
||||
export interface BroadcastListResponse {
|
||||
items: Broadcast[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
items: Broadcast[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface BroadcastPreviewRequest {
|
||||
target: string
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface BroadcastPreviewResponse {
|
||||
target: string
|
||||
count: number
|
||||
target: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MediaUploadResponse {
|
||||
media_type: string
|
||||
file_id: string
|
||||
file_unique_id: string | null
|
||||
media_url: string
|
||||
media_type: string;
|
||||
file_id: string;
|
||||
file_unique_id: string | null;
|
||||
media_url: string;
|
||||
}
|
||||
|
||||
export const adminBroadcastsApi = {
|
||||
// Get all available filters with counts
|
||||
getFilters: async (): Promise<BroadcastFiltersResponse> => {
|
||||
const response = await apiClient.get<BroadcastFiltersResponse>('/cabinet/admin/broadcasts/filters')
|
||||
return response.data
|
||||
const response = await apiClient.get<BroadcastFiltersResponse>(
|
||||
'/cabinet/admin/broadcasts/filters',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get tariffs for filtering
|
||||
getTariffs: async (): Promise<BroadcastTariffsResponse> => {
|
||||
const response = await apiClient.get<BroadcastTariffsResponse>('/cabinet/admin/broadcasts/tariffs')
|
||||
return response.data
|
||||
const response = await apiClient.get<BroadcastTariffsResponse>(
|
||||
'/cabinet/admin/broadcasts/tariffs',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get available buttons
|
||||
getButtons: async (): Promise<BroadcastButtonsResponse> => {
|
||||
const response = await apiClient.get<BroadcastButtonsResponse>('/cabinet/admin/broadcasts/buttons')
|
||||
return response.data
|
||||
const response = await apiClient.get<BroadcastButtonsResponse>(
|
||||
'/cabinet/admin/broadcasts/buttons',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Preview broadcast (get recipients count)
|
||||
preview: async (target: string): Promise<BroadcastPreviewResponse> => {
|
||||
const response = await apiClient.post<BroadcastPreviewResponse>('/cabinet/admin/broadcasts/preview', {
|
||||
target,
|
||||
})
|
||||
return response.data
|
||||
const response = await apiClient.post<BroadcastPreviewResponse>(
|
||||
'/cabinet/admin/broadcasts/preview',
|
||||
{
|
||||
target,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create and start broadcast
|
||||
create: async (data: BroadcastCreateRequest): Promise<Broadcast> => {
|
||||
const response = await apiClient.post<Broadcast>('/cabinet/admin/broadcasts', data)
|
||||
return response.data
|
||||
const response = await apiClient.post<Broadcast>('/cabinet/admin/broadcasts', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get list of broadcasts
|
||||
list: async (limit = 20, offset = 0): Promise<BroadcastListResponse> => {
|
||||
const response = await apiClient.get<BroadcastListResponse>('/cabinet/admin/broadcasts', {
|
||||
params: { limit, offset },
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get broadcast details
|
||||
get: async (id: number): Promise<Broadcast> => {
|
||||
const response = await apiClient.get<Broadcast>(`/cabinet/admin/broadcasts/${id}`)
|
||||
return response.data
|
||||
const response = await apiClient.get<Broadcast>(`/cabinet/admin/broadcasts/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Stop broadcast
|
||||
stop: async (id: number): Promise<Broadcast> => {
|
||||
const response = await apiClient.post<Broadcast>(`/cabinet/admin/broadcasts/${id}/stop`)
|
||||
return response.data
|
||||
const response = await apiClient.post<Broadcast>(`/cabinet/admin/broadcasts/${id}/stop`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Upload media (uses existing media endpoint)
|
||||
uploadMedia: async (file: File, mediaType: 'photo' | 'video' | 'document'): Promise<MediaUploadResponse> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('media_type', mediaType)
|
||||
uploadMedia: async (
|
||||
file: File,
|
||||
mediaType: 'photo' | 'video' | 'document',
|
||||
): Promise<MediaUploadResponse> => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('media_type', mediaType);
|
||||
|
||||
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default adminBroadcastsApi
|
||||
export default adminBroadcastsApi;
|
||||
|
||||
@@ -1,85 +1,105 @@
|
||||
import apiClient from './client'
|
||||
import apiClient from './client';
|
||||
|
||||
export interface EmailTemplateLanguageStatus {
|
||||
has_custom: boolean
|
||||
has_custom: boolean;
|
||||
}
|
||||
|
||||
export interface EmailTemplateType {
|
||||
type: string
|
||||
label: Record<string, string>
|
||||
description: Record<string, string>
|
||||
context_vars: string[]
|
||||
languages: Record<string, EmailTemplateLanguageStatus>
|
||||
type: string;
|
||||
label: Record<string, string>;
|
||||
description: Record<string, string>;
|
||||
context_vars: string[];
|
||||
languages: Record<string, EmailTemplateLanguageStatus>;
|
||||
}
|
||||
|
||||
export interface EmailTemplateListResponse {
|
||||
items: EmailTemplateType[]
|
||||
available_languages: string[]
|
||||
items: EmailTemplateType[];
|
||||
available_languages: string[];
|
||||
}
|
||||
|
||||
export interface EmailTemplateLanguageData {
|
||||
subject: string
|
||||
body_html: string
|
||||
is_default: boolean
|
||||
default_subject: string
|
||||
default_body_html: string
|
||||
subject: string;
|
||||
body_html: string;
|
||||
is_default: boolean;
|
||||
default_subject: string;
|
||||
default_body_html: string;
|
||||
}
|
||||
|
||||
export interface EmailTemplateDetail {
|
||||
notification_type: string
|
||||
label: Record<string, string>
|
||||
description: Record<string, string>
|
||||
context_vars: string[]
|
||||
languages: Record<string, EmailTemplateLanguageData>
|
||||
notification_type: string;
|
||||
label: Record<string, string>;
|
||||
description: Record<string, string>;
|
||||
context_vars: string[];
|
||||
languages: Record<string, EmailTemplateLanguageData>;
|
||||
}
|
||||
|
||||
export interface EmailTemplateUpdateRequest {
|
||||
subject: string
|
||||
body_html: string
|
||||
subject: string;
|
||||
body_html: string;
|
||||
}
|
||||
|
||||
export interface EmailTemplatePreviewRequest {
|
||||
language: string
|
||||
subject?: string
|
||||
body_html?: string
|
||||
language: string;
|
||||
subject?: string;
|
||||
body_html?: string;
|
||||
}
|
||||
|
||||
export interface EmailTemplatePreviewResponse {
|
||||
subject: string
|
||||
body_html: string
|
||||
subject: string;
|
||||
body_html: string;
|
||||
}
|
||||
|
||||
export interface EmailTemplateSendTestRequest {
|
||||
language: string
|
||||
email?: string
|
||||
language: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export const adminEmailTemplatesApi = {
|
||||
getTemplateTypes: async (): Promise<EmailTemplateListResponse> => {
|
||||
const response = await apiClient.get<EmailTemplateListResponse>('/cabinet/admin/email-templates')
|
||||
return response.data
|
||||
const response = await apiClient.get<EmailTemplateListResponse>(
|
||||
'/cabinet/admin/email-templates',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTemplate: async (notificationType: string): Promise<EmailTemplateDetail> => {
|
||||
const response = await apiClient.get<EmailTemplateDetail>(`/cabinet/admin/email-templates/${notificationType}`)
|
||||
return response.data
|
||||
const response = await apiClient.get<EmailTemplateDetail>(
|
||||
`/cabinet/admin/email-templates/${notificationType}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateTemplate: async (notificationType: string, language: string, data: EmailTemplateUpdateRequest): Promise<void> => {
|
||||
await apiClient.put(`/cabinet/admin/email-templates/${notificationType}/${language}`, data)
|
||||
updateTemplate: async (
|
||||
notificationType: string,
|
||||
language: string,
|
||||
data: EmailTemplateUpdateRequest,
|
||||
): Promise<void> => {
|
||||
await apiClient.put(`/cabinet/admin/email-templates/${notificationType}/${language}`, data);
|
||||
},
|
||||
|
||||
deleteTemplate: async (notificationType: string, language: string): Promise<void> => {
|
||||
await apiClient.delete(`/cabinet/admin/email-templates/${notificationType}/${language}`)
|
||||
await apiClient.delete(`/cabinet/admin/email-templates/${notificationType}/${language}`);
|
||||
},
|
||||
|
||||
previewTemplate: async (notificationType: string, data: EmailTemplatePreviewRequest): Promise<EmailTemplatePreviewResponse> => {
|
||||
const response = await apiClient.post<EmailTemplatePreviewResponse>(`/cabinet/admin/email-templates/${notificationType}/preview`, data)
|
||||
return response.data
|
||||
previewTemplate: async (
|
||||
notificationType: string,
|
||||
data: EmailTemplatePreviewRequest,
|
||||
): Promise<EmailTemplatePreviewResponse> => {
|
||||
const response = await apiClient.post<EmailTemplatePreviewResponse>(
|
||||
`/cabinet/admin/email-templates/${notificationType}/preview`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
sendTestEmail: async (notificationType: string, data: EmailTemplateSendTestRequest): Promise<{ sent_to: string }> => {
|
||||
const response = await apiClient.post<{ status: string; sent_to: string }>(`/cabinet/admin/email-templates/${notificationType}/test`, data)
|
||||
return response.data
|
||||
sendTestEmail: async (
|
||||
notificationType: string,
|
||||
data: EmailTemplateSendTestRequest,
|
||||
): Promise<{ sent_to: string }> => {
|
||||
const response = await apiClient.post<{ status: string; sent_to: string }>(
|
||||
`/cabinet/admin/email-templates/${notificationType}/test`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
import apiClient from './client'
|
||||
import type { PaymentMethodConfig, PromoGroupSimple } from '../types'
|
||||
import apiClient from './client';
|
||||
import type { PaymentMethodConfig, PromoGroupSimple } from '../types';
|
||||
|
||||
export const adminPaymentMethodsApi = {
|
||||
getAll: async (): Promise<PaymentMethodConfig[]> => {
|
||||
const response = await apiClient.get<PaymentMethodConfig[]>('/cabinet/admin/payment-methods')
|
||||
return response.data
|
||||
const response = await apiClient.get<PaymentMethodConfig[]>('/cabinet/admin/payment-methods');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getOne: async (methodId: string): Promise<PaymentMethodConfig> => {
|
||||
const response = await apiClient.get<PaymentMethodConfig>(`/cabinet/admin/payment-methods/${methodId}`)
|
||||
return response.data
|
||||
const response = await apiClient.get<PaymentMethodConfig>(
|
||||
`/cabinet/admin/payment-methods/${methodId}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (methodId: string, data: Record<string, unknown>): Promise<PaymentMethodConfig> => {
|
||||
const response = await apiClient.put<PaymentMethodConfig>(
|
||||
`/cabinet/admin/payment-methods/${methodId}`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateOrder: async (methodIds: string[]): Promise<void> => {
|
||||
await apiClient.put('/cabinet/admin/payment-methods/order', { method_ids: methodIds })
|
||||
await apiClient.put('/cabinet/admin/payment-methods/order', { method_ids: methodIds });
|
||||
},
|
||||
|
||||
getPromoGroups: async (): Promise<PromoGroupSimple[]> => {
|
||||
const response = await apiClient.get<PromoGroupSimple[]>('/cabinet/admin/payment-methods/promo-groups')
|
||||
return response.data
|
||||
const response = await apiClient.get<PromoGroupSimple[]>(
|
||||
'/cabinet/admin/payment-methods/promo-groups',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,39 +1,46 @@
|
||||
import apiClient from './client'
|
||||
import type { PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types'
|
||||
import apiClient from './client';
|
||||
import type { PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types';
|
||||
|
||||
export interface PaymentsStats {
|
||||
total_pending: number
|
||||
by_method: Record<string, number>
|
||||
total_pending: number;
|
||||
by_method: Record<string, number>;
|
||||
}
|
||||
|
||||
export const adminPaymentsApi = {
|
||||
// Get all pending payments (admin)
|
||||
getPendingPayments: async (params?: {
|
||||
page?: number
|
||||
per_page?: number
|
||||
method_filter?: string
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
method_filter?: string;
|
||||
}): Promise<PaginatedResponse<PendingPayment>> => {
|
||||
const response = await apiClient.get<PaginatedResponse<PendingPayment>>('/cabinet/admin/payments', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
const response = await apiClient.get<PaginatedResponse<PendingPayment>>(
|
||||
'/cabinet/admin/payments',
|
||||
{
|
||||
params,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get payments statistics
|
||||
getStats: async (): Promise<PaymentsStats> => {
|
||||
const response = await apiClient.get<PaymentsStats>('/cabinet/admin/payments/stats')
|
||||
return response.data
|
||||
const response = await apiClient.get<PaymentsStats>('/cabinet/admin/payments/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get specific payment details
|
||||
getPayment: async (method: string, paymentId: number): Promise<PendingPayment> => {
|
||||
const response = await apiClient.get<PendingPayment>(`/cabinet/admin/payments/${method}/${paymentId}`)
|
||||
return response.data
|
||||
const response = await apiClient.get<PendingPayment>(
|
||||
`/cabinet/admin/payments/${method}/${paymentId}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Manually check payment status
|
||||
checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => {
|
||||
const response = await apiClient.post<ManualCheckResponse>(`/cabinet/admin/payments/${method}/${paymentId}/check`)
|
||||
return response.data
|
||||
const response = await apiClient.post<ManualCheckResponse>(
|
||||
`/cabinet/admin/payments/${method}/${paymentId}/check`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,227 +1,227 @@
|
||||
import { apiClient } from './client'
|
||||
import { apiClient } from './client';
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
// Status & Connection
|
||||
export interface ConnectionStatus {
|
||||
status: string
|
||||
message: string
|
||||
api_url?: string
|
||||
status_code?: number
|
||||
system_info?: Record<string, unknown>
|
||||
status: string;
|
||||
message: string;
|
||||
api_url?: string;
|
||||
status_code?: number;
|
||||
system_info?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RemnaWaveStatusResponse {
|
||||
is_configured: boolean
|
||||
configuration_error?: string
|
||||
connection?: ConnectionStatus
|
||||
is_configured: boolean;
|
||||
configuration_error?: string;
|
||||
connection?: ConnectionStatus;
|
||||
}
|
||||
|
||||
// System Statistics
|
||||
export interface SystemSummary {
|
||||
users_online: number
|
||||
total_users: number
|
||||
active_connections: number
|
||||
nodes_online: number
|
||||
users_last_day: number
|
||||
users_last_week: number
|
||||
users_never_online: number
|
||||
total_user_traffic: number
|
||||
users_online: number;
|
||||
total_users: number;
|
||||
active_connections: number;
|
||||
nodes_online: number;
|
||||
users_last_day: number;
|
||||
users_last_week: number;
|
||||
users_never_online: number;
|
||||
total_user_traffic: number;
|
||||
}
|
||||
|
||||
export interface ServerInfo {
|
||||
cpu_cores: number
|
||||
cpu_physical_cores: number
|
||||
memory_total: number
|
||||
memory_used: number
|
||||
memory_free: number
|
||||
memory_available: number
|
||||
uptime_seconds: number
|
||||
cpu_cores: number;
|
||||
cpu_physical_cores: number;
|
||||
memory_total: number;
|
||||
memory_used: number;
|
||||
memory_free: number;
|
||||
memory_available: number;
|
||||
uptime_seconds: number;
|
||||
}
|
||||
|
||||
export interface Bandwidth {
|
||||
realtime_download: number
|
||||
realtime_upload: number
|
||||
realtime_total: number
|
||||
realtime_download: number;
|
||||
realtime_upload: number;
|
||||
realtime_total: number;
|
||||
}
|
||||
|
||||
export interface TrafficPeriod {
|
||||
current: number
|
||||
previous: number
|
||||
difference?: string
|
||||
current: number;
|
||||
previous: number;
|
||||
difference?: string;
|
||||
}
|
||||
|
||||
export interface TrafficPeriods {
|
||||
last_2_days: TrafficPeriod
|
||||
last_7_days: TrafficPeriod
|
||||
last_30_days: TrafficPeriod
|
||||
current_month: TrafficPeriod
|
||||
current_year: TrafficPeriod
|
||||
last_2_days: TrafficPeriod;
|
||||
last_7_days: TrafficPeriod;
|
||||
last_30_days: TrafficPeriod;
|
||||
current_month: TrafficPeriod;
|
||||
current_year: TrafficPeriod;
|
||||
}
|
||||
|
||||
export interface SystemStatsResponse {
|
||||
system: SystemSummary
|
||||
users_by_status: Record<string, number>
|
||||
server_info: ServerInfo
|
||||
bandwidth: Bandwidth
|
||||
traffic_periods: TrafficPeriods
|
||||
nodes_realtime: Record<string, unknown>[]
|
||||
nodes_weekly: Record<string, unknown>[]
|
||||
last_updated?: string
|
||||
system: SystemSummary;
|
||||
users_by_status: Record<string, number>;
|
||||
server_info: ServerInfo;
|
||||
bandwidth: Bandwidth;
|
||||
traffic_periods: TrafficPeriods;
|
||||
nodes_realtime: Record<string, unknown>[];
|
||||
nodes_weekly: Record<string, unknown>[];
|
||||
last_updated?: string;
|
||||
}
|
||||
|
||||
// Nodes
|
||||
export interface NodeInfo {
|
||||
uuid: string
|
||||
name: string
|
||||
address: string
|
||||
country_code?: string
|
||||
is_connected: boolean
|
||||
is_disabled: boolean
|
||||
is_node_online: boolean
|
||||
is_xray_running: boolean
|
||||
users_online?: number
|
||||
traffic_used_bytes?: number
|
||||
traffic_limit_bytes?: number
|
||||
last_status_change?: string
|
||||
last_status_message?: string
|
||||
xray_uptime?: string
|
||||
is_traffic_tracking_active: boolean
|
||||
traffic_reset_day?: number
|
||||
notify_percent?: number
|
||||
consumption_multiplier: number
|
||||
cpu_count?: number
|
||||
cpu_model?: string
|
||||
total_ram?: string
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
provider_uuid?: string
|
||||
uuid: string;
|
||||
name: string;
|
||||
address: string;
|
||||
country_code?: string;
|
||||
is_connected: boolean;
|
||||
is_disabled: boolean;
|
||||
is_node_online: boolean;
|
||||
is_xray_running: boolean;
|
||||
users_online?: number;
|
||||
traffic_used_bytes?: number;
|
||||
traffic_limit_bytes?: number;
|
||||
last_status_change?: string;
|
||||
last_status_message?: string;
|
||||
xray_uptime?: string;
|
||||
is_traffic_tracking_active: boolean;
|
||||
traffic_reset_day?: number;
|
||||
notify_percent?: number;
|
||||
consumption_multiplier: number;
|
||||
cpu_count?: number;
|
||||
cpu_model?: string;
|
||||
total_ram?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
provider_uuid?: string;
|
||||
}
|
||||
|
||||
export interface NodesListResponse {
|
||||
items: NodeInfo[]
|
||||
total: number
|
||||
items: NodeInfo[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface NodesOverview {
|
||||
total: number
|
||||
online: number
|
||||
offline: number
|
||||
disabled: number
|
||||
total_users_online: number
|
||||
nodes: NodeInfo[]
|
||||
total: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
disabled: number;
|
||||
total_users_online: number;
|
||||
nodes: NodeInfo[];
|
||||
}
|
||||
|
||||
export interface NodeStatisticsResponse {
|
||||
node: NodeInfo
|
||||
realtime?: Record<string, unknown>
|
||||
usage_history: Record<string, unknown>[]
|
||||
last_updated?: string
|
||||
node: NodeInfo;
|
||||
realtime?: Record<string, unknown>;
|
||||
usage_history: Record<string, unknown>[];
|
||||
last_updated?: string;
|
||||
}
|
||||
|
||||
export interface NodeActionResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
is_disabled?: boolean
|
||||
success: boolean;
|
||||
message?: string;
|
||||
is_disabled?: boolean;
|
||||
}
|
||||
|
||||
// Squads
|
||||
export interface SquadWithLocalInfo {
|
||||
uuid: string
|
||||
name: string
|
||||
members_count: number
|
||||
inbounds_count: number
|
||||
inbounds: Record<string, unknown>[]
|
||||
local_id?: number
|
||||
display_name?: string
|
||||
country_code?: string
|
||||
is_available?: boolean
|
||||
is_trial_eligible?: boolean
|
||||
price_kopeks?: number
|
||||
max_users?: number
|
||||
current_users?: number
|
||||
is_synced: boolean
|
||||
uuid: string;
|
||||
name: string;
|
||||
members_count: number;
|
||||
inbounds_count: number;
|
||||
inbounds: Record<string, unknown>[];
|
||||
local_id?: number;
|
||||
display_name?: string;
|
||||
country_code?: string;
|
||||
is_available?: boolean;
|
||||
is_trial_eligible?: boolean;
|
||||
price_kopeks?: number;
|
||||
max_users?: number;
|
||||
current_users?: number;
|
||||
is_synced: boolean;
|
||||
}
|
||||
|
||||
export interface SquadsListResponse {
|
||||
items: SquadWithLocalInfo[]
|
||||
total: number
|
||||
items: SquadWithLocalInfo[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface SquadDetailResponse extends SquadWithLocalInfo {
|
||||
description?: string
|
||||
sort_order?: number
|
||||
active_subscriptions: number
|
||||
description?: string;
|
||||
sort_order?: number;
|
||||
active_subscriptions: number;
|
||||
}
|
||||
|
||||
export interface SquadOperationResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Migration
|
||||
export interface MigrationPreviewResponse {
|
||||
squad_uuid: string
|
||||
squad_name: string
|
||||
current_users: number
|
||||
max_users?: number
|
||||
users_to_migrate: number
|
||||
squad_uuid: string;
|
||||
squad_name: string;
|
||||
current_users: number;
|
||||
max_users?: number;
|
||||
users_to_migrate: number;
|
||||
}
|
||||
|
||||
export interface MigrationStats {
|
||||
source_uuid: string
|
||||
target_uuid: string
|
||||
total: number
|
||||
updated: number
|
||||
panel_updated: number
|
||||
panel_failed: number
|
||||
source_removed: number
|
||||
target_added: number
|
||||
source_uuid: string;
|
||||
target_uuid: string;
|
||||
total: number;
|
||||
updated: number;
|
||||
panel_updated: number;
|
||||
panel_failed: number;
|
||||
source_removed: number;
|
||||
target_added: number;
|
||||
}
|
||||
|
||||
export interface MigrationResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
error?: string
|
||||
data?: MigrationStats
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
data?: MigrationStats;
|
||||
}
|
||||
|
||||
// Inbounds
|
||||
export interface InboundsListResponse {
|
||||
items: Record<string, unknown>[]
|
||||
total: number
|
||||
items: Record<string, unknown>[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
// Auto Sync
|
||||
export interface AutoSyncStatus {
|
||||
enabled: boolean
|
||||
times: string[]
|
||||
next_run?: string
|
||||
is_running: boolean
|
||||
last_run_started_at?: string
|
||||
last_run_finished_at?: string
|
||||
last_run_success?: boolean
|
||||
last_run_reason?: string
|
||||
last_run_error?: string
|
||||
last_user_stats?: Record<string, unknown>
|
||||
last_server_stats?: Record<string, unknown>
|
||||
enabled: boolean;
|
||||
times: string[];
|
||||
next_run?: string;
|
||||
is_running: boolean;
|
||||
last_run_started_at?: string;
|
||||
last_run_finished_at?: string;
|
||||
last_run_success?: boolean;
|
||||
last_run_reason?: string;
|
||||
last_run_error?: string;
|
||||
last_user_stats?: Record<string, unknown>;
|
||||
last_server_stats?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AutoSyncRunResponse {
|
||||
started: boolean
|
||||
success?: boolean
|
||||
error?: string
|
||||
user_stats?: Record<string, unknown>
|
||||
server_stats?: Record<string, unknown>
|
||||
reason?: string
|
||||
started: boolean;
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
user_stats?: Record<string, unknown>;
|
||||
server_stats?: Record<string, unknown>;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// Sync
|
||||
export interface SyncResponse {
|
||||
success: boolean
|
||||
message?: string
|
||||
data?: Record<string, unknown>
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ============ API ============
|
||||
@@ -229,158 +229,176 @@ export interface SyncResponse {
|
||||
export const adminRemnawaveApi = {
|
||||
// Status & Connection
|
||||
getStatus: async (): Promise<RemnaWaveStatusResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/status')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/status');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// System Statistics
|
||||
getSystemStats: async (): Promise<SystemStatsResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/system')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/system');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Nodes
|
||||
getNodes: async (): Promise<NodesListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/nodes')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/nodes');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getNodesOverview: async (): Promise<NodesOverview> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/overview')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/overview');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getNodesRealtime: async (): Promise<Record<string, unknown>[]> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/realtime')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/nodes/realtime');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getNode: async (uuid: string): Promise<NodeInfo> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}`)
|
||||
return response.data
|
||||
const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getNodeStatistics: async (uuid: string): Promise<NodeStatisticsResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}/statistics`)
|
||||
return response.data
|
||||
const response = await apiClient.get(`/cabinet/admin/remnawave/nodes/${uuid}/statistics`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
nodeAction: async (uuid: string, action: 'enable' | 'disable' | 'restart'): Promise<NodeActionResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/remnawave/nodes/${uuid}/action`, { action })
|
||||
return response.data
|
||||
nodeAction: async (
|
||||
uuid: string,
|
||||
action: 'enable' | 'disable' | 'restart',
|
||||
): Promise<NodeActionResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/remnawave/nodes/${uuid}/action`, {
|
||||
action,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
restartAllNodes: async (): Promise<NodeActionResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/nodes/restart-all')
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/nodes/restart-all');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Squads
|
||||
getSquads: async (): Promise<SquadsListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/squads')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/squads');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSquad: async (uuid: string): Promise<SquadDetailResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}`)
|
||||
return response.data
|
||||
const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createSquad: async (data: { name: string; inbound_uuids?: string[] }): Promise<SquadOperationResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/squads', data)
|
||||
return response.data
|
||||
createSquad: async (data: {
|
||||
name: string;
|
||||
inbound_uuids?: string[];
|
||||
}): Promise<SquadOperationResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/squads', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updateSquad: async (uuid: string, data: { name?: string; inbound_uuids?: string[] }): Promise<SquadOperationResponse> => {
|
||||
const response = await apiClient.patch(`/cabinet/admin/remnawave/squads/${uuid}`, data)
|
||||
return response.data
|
||||
updateSquad: async (
|
||||
uuid: string,
|
||||
data: { name?: string; inbound_uuids?: string[] },
|
||||
): Promise<SquadOperationResponse> => {
|
||||
const response = await apiClient.patch(`/cabinet/admin/remnawave/squads/${uuid}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deleteSquad: async (uuid: string): Promise<SquadOperationResponse> => {
|
||||
const response = await apiClient.delete(`/cabinet/admin/remnawave/squads/${uuid}`)
|
||||
return response.data
|
||||
const response = await apiClient.delete(`/cabinet/admin/remnawave/squads/${uuid}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
squadAction: async (uuid: string, data: {
|
||||
action: 'add_all_users' | 'remove_all_users' | 'delete' | 'rename' | 'update_inbounds'
|
||||
name?: string
|
||||
inbound_uuids?: string[]
|
||||
}): Promise<SquadOperationResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/remnawave/squads/${uuid}/action`, data)
|
||||
return response.data
|
||||
squadAction: async (
|
||||
uuid: string,
|
||||
data: {
|
||||
action: 'add_all_users' | 'remove_all_users' | 'delete' | 'rename' | 'update_inbounds';
|
||||
name?: string;
|
||||
inbound_uuids?: string[];
|
||||
},
|
||||
): Promise<SquadOperationResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/remnawave/squads/${uuid}/action`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Migration
|
||||
getMigrationPreview: async (uuid: string): Promise<MigrationPreviewResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/remnawave/squads/${uuid}/migration-preview`)
|
||||
return response.data
|
||||
const response = await apiClient.get(
|
||||
`/cabinet/admin/remnawave/squads/${uuid}/migration-preview`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
migrateSquad: async (sourceUuid: string, targetUuid: string): Promise<MigrationResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/squads/migrate', {
|
||||
source_uuid: sourceUuid,
|
||||
target_uuid: targetUuid,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Inbounds
|
||||
getInbounds: async (): Promise<InboundsListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/inbounds')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/inbounds');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Auto Sync
|
||||
getAutoSyncStatus: async (): Promise<AutoSyncStatus> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/sync/auto/status')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/sync/auto/status');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
toggleAutoSync: async (enabled: boolean): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/toggle', { enabled })
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/toggle', { enabled });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
runAutoSync: async (): Promise<AutoSyncRunResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/run')
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/auto/run');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Manual Sync
|
||||
syncFromPanel: async (mode: 'all' | 'new_only' | 'update_only' = 'all'): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/from-panel', { mode })
|
||||
return response.data
|
||||
syncFromPanel: async (
|
||||
mode: 'all' | 'new_only' | 'update_only' = 'all',
|
||||
): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/from-panel', { mode });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
syncToPanel: async (): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/to-panel')
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/to-panel');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
syncServers: async (): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/servers')
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/servers');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
validateSubscriptions: async (): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/validate')
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/validate');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
cleanupSubscriptions: async (): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/cleanup')
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/cleanup');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
syncSubscriptionStatuses: async (): Promise<SyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/statuses')
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/admin/remnawave/sync/subscriptions/statuses');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSyncRecommendations: async (): Promise<SyncResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/sync/recommendations')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/remnawave/sync/recommendations');
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default adminRemnawaveApi
|
||||
export default adminRemnawaveApi;
|
||||
|
||||
@@ -1,73 +1,79 @@
|
||||
import apiClient from './client'
|
||||
import apiClient from './client';
|
||||
|
||||
export interface SettingCategoryRef {
|
||||
key: string
|
||||
label: string
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SettingCategorySummary {
|
||||
key: string
|
||||
label: string
|
||||
description: string
|
||||
items: number
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
items: number;
|
||||
}
|
||||
|
||||
export interface SettingChoice {
|
||||
value: unknown
|
||||
label: string
|
||||
description?: string | null
|
||||
value: unknown;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export interface SettingHint {
|
||||
description: string
|
||||
format: string
|
||||
example: string
|
||||
warning: string
|
||||
description: string;
|
||||
format: string;
|
||||
example: string;
|
||||
warning: string;
|
||||
}
|
||||
|
||||
export interface SettingDefinition {
|
||||
key: string
|
||||
name: string
|
||||
category: SettingCategoryRef
|
||||
type: string
|
||||
is_optional: boolean
|
||||
current: unknown
|
||||
original: unknown
|
||||
has_override: boolean
|
||||
read_only: boolean
|
||||
choices: SettingChoice[]
|
||||
hint?: SettingHint | null
|
||||
key: string;
|
||||
name: string;
|
||||
category: SettingCategoryRef;
|
||||
type: string;
|
||||
is_optional: boolean;
|
||||
current: unknown;
|
||||
original: unknown;
|
||||
has_override: boolean;
|
||||
read_only: boolean;
|
||||
choices: SettingChoice[];
|
||||
hint?: SettingHint | null;
|
||||
}
|
||||
|
||||
export const adminSettingsApi = {
|
||||
// Get list of setting categories
|
||||
getCategories: async (): Promise<SettingCategorySummary[]> => {
|
||||
const response = await apiClient.get<SettingCategorySummary[]>('/cabinet/admin/settings/categories')
|
||||
return response.data
|
||||
const response = await apiClient.get<SettingCategorySummary[]>(
|
||||
'/cabinet/admin/settings/categories',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get all settings or settings for a specific category
|
||||
getSettings: async (categoryKey?: string): Promise<SettingDefinition[]> => {
|
||||
const params = categoryKey ? { category_key: categoryKey } : {}
|
||||
const response = await apiClient.get<SettingDefinition[]>('/cabinet/admin/settings', { params })
|
||||
return response.data
|
||||
const params = categoryKey ? { category_key: categoryKey } : {};
|
||||
const response = await apiClient.get<SettingDefinition[]>('/cabinet/admin/settings', {
|
||||
params,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get a specific setting by key
|
||||
getSetting: async (key: string): Promise<SettingDefinition> => {
|
||||
const response = await apiClient.get<SettingDefinition>(`/cabinet/admin/settings/${key}`)
|
||||
return response.data
|
||||
const response = await apiClient.get<SettingDefinition>(`/cabinet/admin/settings/${key}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update a setting value
|
||||
updateSetting: async (key: string, value: unknown): Promise<SettingDefinition> => {
|
||||
const response = await apiClient.put<SettingDefinition>(`/cabinet/admin/settings/${key}`, { value })
|
||||
return response.data
|
||||
const response = await apiClient.put<SettingDefinition>(`/cabinet/admin/settings/${key}`, {
|
||||
value,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Reset a setting to default
|
||||
resetSetting: async (key: string): Promise<SettingDefinition> => {
|
||||
const response = await apiClient.delete<SettingDefinition>(`/cabinet/admin/settings/${key}`)
|
||||
return response.data
|
||||
const response = await apiClient.delete<SettingDefinition>(`/cabinet/admin/settings/${key}`);
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,418 +1,478 @@
|
||||
import apiClient from './client'
|
||||
import apiClient from './client';
|
||||
|
||||
// ============ Types ============
|
||||
|
||||
export interface UserSubscriptionInfo {
|
||||
id: number
|
||||
status: string
|
||||
is_trial: boolean
|
||||
start_date: string | null
|
||||
end_date: string | null
|
||||
traffic_limit_gb: number
|
||||
traffic_used_gb: number
|
||||
device_limit: number
|
||||
tariff_id: number | null
|
||||
tariff_name: string | null
|
||||
autopay_enabled: boolean
|
||||
is_active: boolean
|
||||
days_remaining: number
|
||||
id: number;
|
||||
status: string;
|
||||
is_trial: boolean;
|
||||
start_date: string | null;
|
||||
end_date: string | null;
|
||||
traffic_limit_gb: number;
|
||||
traffic_used_gb: number;
|
||||
device_limit: number;
|
||||
tariff_id: number | null;
|
||||
tariff_name: string | null;
|
||||
autopay_enabled: boolean;
|
||||
is_active: boolean;
|
||||
days_remaining: number;
|
||||
}
|
||||
|
||||
export interface UserPromoGroupInfo {
|
||||
id: number
|
||||
name: string
|
||||
is_default: boolean
|
||||
id: number;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
}
|
||||
|
||||
export interface UserListItem {
|
||||
id: number
|
||||
telegram_id: number
|
||||
username: string | null
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
full_name: string
|
||||
status: string
|
||||
balance_kopeks: number
|
||||
balance_rubles: number
|
||||
created_at: string
|
||||
last_activity: string | null
|
||||
has_subscription: boolean
|
||||
subscription_status: string | null
|
||||
subscription_is_trial: boolean
|
||||
subscription_end_date: string | null
|
||||
promo_group_id: number | null
|
||||
promo_group_name: string | null
|
||||
total_spent_kopeks: number
|
||||
purchase_count: number
|
||||
has_restrictions: boolean
|
||||
restriction_topup: boolean
|
||||
restriction_subscription: boolean
|
||||
id: number;
|
||||
telegram_id: number;
|
||||
username: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
full_name: string;
|
||||
status: string;
|
||||
balance_kopeks: number;
|
||||
balance_rubles: number;
|
||||
created_at: string;
|
||||
last_activity: string | null;
|
||||
has_subscription: boolean;
|
||||
subscription_status: string | null;
|
||||
subscription_is_trial: boolean;
|
||||
subscription_end_date: string | null;
|
||||
promo_group_id: number | null;
|
||||
promo_group_name: string | null;
|
||||
total_spent_kopeks: number;
|
||||
purchase_count: number;
|
||||
has_restrictions: boolean;
|
||||
restriction_topup: boolean;
|
||||
restriction_subscription: boolean;
|
||||
}
|
||||
|
||||
export interface UsersListResponse {
|
||||
users: UserListItem[]
|
||||
total: number
|
||||
offset: number
|
||||
limit: number
|
||||
users: UserListItem[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface UserTransactionItem {
|
||||
id: number
|
||||
type: string
|
||||
amount_kopeks: number
|
||||
amount_rubles: number
|
||||
description: string | null
|
||||
payment_method: string | null
|
||||
is_completed: boolean
|
||||
created_at: string
|
||||
id: number;
|
||||
type: string;
|
||||
amount_kopeks: number;
|
||||
amount_rubles: number;
|
||||
description: string | null;
|
||||
payment_method: string | null;
|
||||
is_completed: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UserReferralInfo {
|
||||
referral_code: string
|
||||
referrals_count: number
|
||||
total_earnings_kopeks: number
|
||||
commission_percent: number | null
|
||||
referred_by_id: number | null
|
||||
referred_by_username: string | null
|
||||
referral_code: string;
|
||||
referrals_count: number;
|
||||
total_earnings_kopeks: number;
|
||||
commission_percent: number | null;
|
||||
referred_by_id: number | null;
|
||||
referred_by_username: string | null;
|
||||
}
|
||||
|
||||
export interface UserDetailResponse {
|
||||
id: number
|
||||
telegram_id: number
|
||||
username: string | null
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
full_name: string
|
||||
status: string
|
||||
language: string
|
||||
balance_kopeks: number
|
||||
balance_rubles: number
|
||||
email: string | null
|
||||
email_verified: boolean
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
last_activity: string | null
|
||||
cabinet_last_login: string | null
|
||||
subscription: UserSubscriptionInfo | null
|
||||
promo_group: UserPromoGroupInfo | null
|
||||
referral: UserReferralInfo
|
||||
total_spent_kopeks: number
|
||||
purchase_count: number
|
||||
used_promocodes: number
|
||||
has_had_paid_subscription: boolean
|
||||
lifetime_used_traffic_bytes: number
|
||||
restriction_topup: boolean
|
||||
restriction_subscription: boolean
|
||||
restriction_reason: string | null
|
||||
promo_offer_discount_percent: number
|
||||
promo_offer_discount_source: string | null
|
||||
promo_offer_discount_expires_at: string | null
|
||||
recent_transactions: UserTransactionItem[]
|
||||
remnawave_uuid: string | null
|
||||
id: number;
|
||||
telegram_id: number;
|
||||
username: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
full_name: string;
|
||||
status: string;
|
||||
language: string;
|
||||
balance_kopeks: number;
|
||||
balance_rubles: number;
|
||||
email: string | null;
|
||||
email_verified: boolean;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
last_activity: string | null;
|
||||
cabinet_last_login: string | null;
|
||||
subscription: UserSubscriptionInfo | null;
|
||||
promo_group: UserPromoGroupInfo | null;
|
||||
referral: UserReferralInfo;
|
||||
total_spent_kopeks: number;
|
||||
purchase_count: number;
|
||||
used_promocodes: number;
|
||||
has_had_paid_subscription: boolean;
|
||||
lifetime_used_traffic_bytes: number;
|
||||
restriction_topup: boolean;
|
||||
restriction_subscription: boolean;
|
||||
restriction_reason: string | null;
|
||||
promo_offer_discount_percent: number;
|
||||
promo_offer_discount_source: string | null;
|
||||
promo_offer_discount_expires_at: string | null;
|
||||
recent_transactions: UserTransactionItem[];
|
||||
remnawave_uuid: string | null;
|
||||
}
|
||||
|
||||
export interface UsersStatsResponse {
|
||||
total_users: number
|
||||
active_users: number
|
||||
blocked_users: number
|
||||
deleted_users: number
|
||||
new_today: number
|
||||
new_week: number
|
||||
new_month: number
|
||||
users_with_subscription: number
|
||||
users_with_active_subscription: number
|
||||
users_with_trial: number
|
||||
users_with_expired_subscription: number
|
||||
total_balance_kopeks: number
|
||||
total_balance_rubles: number
|
||||
avg_balance_kopeks: number
|
||||
active_today: number
|
||||
active_week: number
|
||||
active_month: number
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
blocked_users: number;
|
||||
deleted_users: number;
|
||||
new_today: number;
|
||||
new_week: number;
|
||||
new_month: number;
|
||||
users_with_subscription: number;
|
||||
users_with_active_subscription: number;
|
||||
users_with_trial: number;
|
||||
users_with_expired_subscription: number;
|
||||
total_balance_kopeks: number;
|
||||
total_balance_rubles: number;
|
||||
avg_balance_kopeks: number;
|
||||
active_today: number;
|
||||
active_week: number;
|
||||
active_month: number;
|
||||
}
|
||||
|
||||
// Available tariffs
|
||||
export interface PeriodPriceInfo {
|
||||
days: number
|
||||
price_kopeks: number
|
||||
price_rubles: number
|
||||
days: number;
|
||||
price_kopeks: number;
|
||||
price_rubles: number;
|
||||
}
|
||||
|
||||
export interface UserAvailableTariff {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
is_active: boolean
|
||||
is_trial_available: boolean
|
||||
traffic_limit_gb: number
|
||||
device_limit: number
|
||||
tier_level: number
|
||||
display_order: number
|
||||
period_prices: PeriodPriceInfo[]
|
||||
is_daily: boolean
|
||||
daily_price_kopeks: number
|
||||
custom_days_enabled: boolean
|
||||
price_per_day_kopeks: number
|
||||
min_days: number
|
||||
max_days: number
|
||||
is_available: boolean
|
||||
requires_promo_group: boolean
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
is_trial_available: boolean;
|
||||
traffic_limit_gb: number;
|
||||
device_limit: number;
|
||||
tier_level: number;
|
||||
display_order: number;
|
||||
period_prices: PeriodPriceInfo[];
|
||||
is_daily: boolean;
|
||||
daily_price_kopeks: number;
|
||||
custom_days_enabled: boolean;
|
||||
price_per_day_kopeks: number;
|
||||
min_days: number;
|
||||
max_days: number;
|
||||
is_available: boolean;
|
||||
requires_promo_group: boolean;
|
||||
}
|
||||
|
||||
export interface UserAvailableTariffsResponse {
|
||||
user_id: number
|
||||
promo_group_id: number | null
|
||||
promo_group_name: string | null
|
||||
tariffs: UserAvailableTariff[]
|
||||
total: number
|
||||
current_tariff_id: number | null
|
||||
current_tariff_name: string | null
|
||||
user_id: number;
|
||||
promo_group_id: number | null;
|
||||
promo_group_name: string | null;
|
||||
tariffs: UserAvailableTariff[];
|
||||
total: number;
|
||||
current_tariff_id: number | null;
|
||||
current_tariff_name: string | null;
|
||||
}
|
||||
|
||||
// Sync types
|
||||
export interface PanelUserInfo {
|
||||
uuid: string | null
|
||||
short_uuid: string | null
|
||||
username: string | null
|
||||
status: string | null
|
||||
expire_at: string | null
|
||||
traffic_limit_gb: number
|
||||
traffic_used_gb: number
|
||||
device_limit: number
|
||||
subscription_url: string | null
|
||||
active_squads: string[]
|
||||
uuid: string | null;
|
||||
short_uuid: string | null;
|
||||
username: string | null;
|
||||
status: string | null;
|
||||
expire_at: string | null;
|
||||
traffic_limit_gb: number;
|
||||
traffic_used_gb: number;
|
||||
device_limit: number;
|
||||
subscription_url: string | null;
|
||||
active_squads: string[];
|
||||
}
|
||||
|
||||
export interface SyncFromPanelResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
panel_user: PanelUserInfo | null
|
||||
changes: Record<string, unknown>
|
||||
errors: string[]
|
||||
success: boolean;
|
||||
message: string;
|
||||
panel_user: PanelUserInfo | null;
|
||||
changes: Record<string, unknown>;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface SyncToPanelResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
action: string
|
||||
panel_uuid: string | null
|
||||
changes: Record<string, unknown>
|
||||
errors: string[]
|
||||
success: boolean;
|
||||
message: string;
|
||||
action: string;
|
||||
panel_uuid: string | null;
|
||||
changes: Record<string, unknown>;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface PanelSyncStatusResponse {
|
||||
user_id: number
|
||||
telegram_id: number
|
||||
remnawave_uuid: string | null
|
||||
last_sync: string | null
|
||||
bot_subscription_status: string | null
|
||||
bot_subscription_end_date: string | null
|
||||
bot_traffic_limit_gb: number
|
||||
bot_traffic_used_gb: number
|
||||
bot_device_limit: number
|
||||
bot_squads: string[]
|
||||
panel_found: boolean
|
||||
panel_status: string | null
|
||||
panel_expire_at: string | null
|
||||
panel_traffic_limit_gb: number
|
||||
panel_traffic_used_gb: number
|
||||
panel_device_limit: number
|
||||
panel_squads: string[]
|
||||
has_differences: boolean
|
||||
differences: string[]
|
||||
user_id: number;
|
||||
telegram_id: number;
|
||||
remnawave_uuid: string | null;
|
||||
last_sync: string | null;
|
||||
bot_subscription_status: string | null;
|
||||
bot_subscription_end_date: string | null;
|
||||
bot_traffic_limit_gb: number;
|
||||
bot_traffic_used_gb: number;
|
||||
bot_device_limit: number;
|
||||
bot_squads: string[];
|
||||
panel_found: boolean;
|
||||
panel_status: string | null;
|
||||
panel_expire_at: string | null;
|
||||
panel_traffic_limit_gb: number;
|
||||
panel_traffic_used_gb: number;
|
||||
panel_device_limit: number;
|
||||
panel_squads: string[];
|
||||
has_differences: boolean;
|
||||
differences: string[];
|
||||
}
|
||||
|
||||
// Update types
|
||||
export interface UpdateBalanceRequest {
|
||||
amount_kopeks: number
|
||||
description?: string
|
||||
create_transaction?: boolean
|
||||
amount_kopeks: number;
|
||||
description?: string;
|
||||
create_transaction?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateBalanceResponse {
|
||||
success: boolean
|
||||
old_balance_kopeks: number
|
||||
new_balance_kopeks: number
|
||||
message: string
|
||||
success: boolean;
|
||||
old_balance_kopeks: number;
|
||||
new_balance_kopeks: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface UpdateSubscriptionRequest {
|
||||
action: 'extend' | 'set_end_date' | 'change_tariff' | 'set_traffic' | 'toggle_autopay' | 'cancel' | 'activate' | 'create'
|
||||
days?: number
|
||||
end_date?: string
|
||||
tariff_id?: number
|
||||
traffic_limit_gb?: number
|
||||
traffic_used_gb?: number
|
||||
autopay_enabled?: boolean
|
||||
is_trial?: boolean
|
||||
device_limit?: number
|
||||
action:
|
||||
| 'extend'
|
||||
| 'set_end_date'
|
||||
| 'change_tariff'
|
||||
| 'set_traffic'
|
||||
| 'toggle_autopay'
|
||||
| 'cancel'
|
||||
| 'activate'
|
||||
| 'create';
|
||||
days?: number;
|
||||
end_date?: string;
|
||||
tariff_id?: number;
|
||||
traffic_limit_gb?: number;
|
||||
traffic_used_gb?: number;
|
||||
autopay_enabled?: boolean;
|
||||
is_trial?: boolean;
|
||||
device_limit?: number;
|
||||
}
|
||||
|
||||
export interface UpdateSubscriptionResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
subscription: UserSubscriptionInfo | null
|
||||
success: boolean;
|
||||
message: string;
|
||||
subscription: UserSubscriptionInfo | null;
|
||||
}
|
||||
|
||||
export interface UpdateUserStatusResponse {
|
||||
success: boolean
|
||||
old_status: string
|
||||
new_status: string
|
||||
message: string
|
||||
success: boolean;
|
||||
old_status: string;
|
||||
new_status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface UpdateRestrictionsRequest {
|
||||
restriction_topup?: boolean
|
||||
restriction_subscription?: boolean
|
||||
restriction_reason?: string
|
||||
restriction_topup?: boolean;
|
||||
restriction_subscription?: boolean;
|
||||
restriction_reason?: string;
|
||||
}
|
||||
|
||||
export interface UpdateRestrictionsResponse {
|
||||
success: boolean
|
||||
restriction_topup: boolean
|
||||
restriction_subscription: boolean
|
||||
restriction_reason: string | null
|
||||
message: string
|
||||
success: boolean;
|
||||
restriction_topup: boolean;
|
||||
restriction_subscription: boolean;
|
||||
restriction_reason: string | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface SyncFromPanelRequest {
|
||||
update_subscription?: boolean
|
||||
update_traffic?: boolean
|
||||
create_if_missing?: boolean
|
||||
update_subscription?: boolean;
|
||||
update_traffic?: boolean;
|
||||
create_if_missing?: boolean;
|
||||
}
|
||||
|
||||
export interface SyncToPanelRequest {
|
||||
create_if_missing?: boolean
|
||||
update_status?: boolean
|
||||
update_traffic_limit?: boolean
|
||||
update_expire_date?: boolean
|
||||
update_squads?: boolean
|
||||
create_if_missing?: boolean;
|
||||
update_status?: boolean;
|
||||
update_traffic_limit?: boolean;
|
||||
update_expire_date?: boolean;
|
||||
update_squads?: boolean;
|
||||
}
|
||||
|
||||
// ============ API ============
|
||||
|
||||
export const adminUsersApi = {
|
||||
// List users
|
||||
getUsers: async (params: {
|
||||
offset?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
status?: 'active' | 'blocked' | 'deleted'
|
||||
sort_by?: 'created_at' | 'balance' | 'traffic' | 'last_activity' | 'total_spent' | 'purchase_count'
|
||||
} = {}): Promise<UsersListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/users', { params })
|
||||
return response.data
|
||||
getUsers: async (
|
||||
params: {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
status?: 'active' | 'blocked' | 'deleted';
|
||||
sort_by?:
|
||||
| 'created_at'
|
||||
| 'balance'
|
||||
| 'traffic'
|
||||
| 'last_activity'
|
||||
| 'total_spent'
|
||||
| 'purchase_count';
|
||||
} = {},
|
||||
): Promise<UsersListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/users', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get users stats
|
||||
getStats: async (): Promise<UsersStatsResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/users/stats')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/users/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get user detail
|
||||
getUser: async (userId: number): Promise<UserDetailResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}`)
|
||||
return response.data
|
||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get user by telegram ID
|
||||
getUserByTelegram: async (telegramId: number): Promise<UserDetailResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/users/by-telegram/${telegramId}`)
|
||||
return response.data
|
||||
const response = await apiClient.get(`/cabinet/admin/users/by-telegram/${telegramId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get available tariffs for user
|
||||
getAvailableTariffs: async (userId: number, includeInactive = false): Promise<UserAvailableTariffsResponse> => {
|
||||
getAvailableTariffs: async (
|
||||
userId: number,
|
||||
includeInactive = false,
|
||||
): Promise<UserAvailableTariffsResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/available-tariffs`, {
|
||||
params: { include_inactive: includeInactive }
|
||||
})
|
||||
return response.data
|
||||
params: { include_inactive: includeInactive },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update balance
|
||||
updateBalance: async (userId: number, data: UpdateBalanceRequest): Promise<UpdateBalanceResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/balance`, data)
|
||||
return response.data
|
||||
updateBalance: async (
|
||||
userId: number,
|
||||
data: UpdateBalanceRequest,
|
||||
): Promise<UpdateBalanceResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/balance`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update subscription
|
||||
updateSubscription: async (userId: number, data: UpdateSubscriptionRequest): Promise<UpdateSubscriptionResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/subscription`, data)
|
||||
return response.data
|
||||
updateSubscription: async (
|
||||
userId: number,
|
||||
data: UpdateSubscriptionRequest,
|
||||
): Promise<UpdateSubscriptionResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/subscription`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update status
|
||||
updateStatus: async (userId: number, status: 'active' | 'blocked' | 'deleted', reason?: string): Promise<UpdateUserStatusResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/status`, { status, reason })
|
||||
return response.data
|
||||
updateStatus: async (
|
||||
userId: number,
|
||||
status: 'active' | 'blocked' | 'deleted',
|
||||
reason?: string,
|
||||
): Promise<UpdateUserStatusResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/status`, {
|
||||
status,
|
||||
reason,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Block user
|
||||
blockUser: async (userId: number, reason?: string): Promise<UpdateUserStatusResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/block`, null, { params: { reason } })
|
||||
return response.data
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/block`, null, {
|
||||
params: { reason },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Unblock user
|
||||
unblockUser: async (userId: number): Promise<UpdateUserStatusResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/unblock`)
|
||||
return response.data
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/unblock`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update restrictions
|
||||
updateRestrictions: async (userId: number, data: UpdateRestrictionsRequest): Promise<UpdateRestrictionsResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/restrictions`, data)
|
||||
return response.data
|
||||
updateRestrictions: async (
|
||||
userId: number,
|
||||
data: UpdateRestrictionsRequest,
|
||||
): Promise<UpdateRestrictionsResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/restrictions`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update promo group
|
||||
updatePromoGroup: async (userId: number, promoGroupId: number | null): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/promo-group`, { promo_group_id: promoGroupId })
|
||||
return response.data
|
||||
updatePromoGroup: async (
|
||||
userId: number,
|
||||
promoGroupId: number | null,
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/promo-group`, {
|
||||
promo_group_id: promoGroupId,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete user
|
||||
deleteUser: async (userId: number, softDelete = true, reason?: string): Promise<{ success: boolean; message: string }> => {
|
||||
deleteUser: async (
|
||||
userId: number,
|
||||
softDelete = true,
|
||||
reason?: string,
|
||||
): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.delete(`/cabinet/admin/users/${userId}`, {
|
||||
data: { soft_delete: softDelete, reason }
|
||||
})
|
||||
return response.data
|
||||
data: { soft_delete: softDelete, reason },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get referrals
|
||||
getReferrals: async (userId: number, offset = 0, limit = 50): Promise<UsersListResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/referrals`, {
|
||||
params: { offset, limit }
|
||||
})
|
||||
return response.data
|
||||
params: { offset, limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get transactions
|
||||
getTransactions: async (userId: number, params: {
|
||||
offset?: number
|
||||
limit?: number
|
||||
transaction_type?: string
|
||||
} = {}): Promise<{ transactions: UserTransactionItem[]; total: number; offset: number; limit: number }> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/transactions`, { params })
|
||||
return response.data
|
||||
getTransactions: async (
|
||||
userId: number,
|
||||
params: {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
transaction_type?: string;
|
||||
} = {},
|
||||
): Promise<{
|
||||
transactions: UserTransactionItem[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/transactions`, { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Sync status
|
||||
getSyncStatus: async (userId: number): Promise<PanelSyncStatusResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/sync/status`)
|
||||
return response.data
|
||||
const response = await apiClient.get(`/cabinet/admin/users/${userId}/sync/status`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Sync from panel
|
||||
syncFromPanel: async (userId: number, data: SyncFromPanelRequest = {}): Promise<SyncFromPanelResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/from-panel`, data)
|
||||
return response.data
|
||||
syncFromPanel: async (
|
||||
userId: number,
|
||||
data: SyncFromPanelRequest = {},
|
||||
): Promise<SyncFromPanelResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/from-panel`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Sync to panel
|
||||
syncToPanel: async (userId: number, data: SyncToPanelRequest = {}): Promise<SyncToPanelResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/to-panel`, data)
|
||||
return response.data
|
||||
syncToPanel: async (
|
||||
userId: number,
|
||||
data: SyncToPanelRequest = {},
|
||||
): Promise<SyncToPanelResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/users/${userId}/sync/to-panel`, data);
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import apiClient from './client'
|
||||
import type { AuthResponse, RegisterResponse, TokenResponse, User } from '../types'
|
||||
import apiClient from './client';
|
||||
import type { AuthResponse, RegisterResponse, TokenResponse, User } from '../types';
|
||||
|
||||
export const authApi = {
|
||||
// Telegram WebApp authentication
|
||||
loginTelegram: async (initData: string): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram', {
|
||||
init_data: initData,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Telegram Login Widget authentication
|
||||
loginTelegramWidget: async (data: {
|
||||
id: number
|
||||
first_name: string
|
||||
last_name?: string
|
||||
username?: string
|
||||
photo_url?: string
|
||||
auth_date: number
|
||||
hash: string
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
photo_url?: string;
|
||||
auth_date: number;
|
||||
hash: string;
|
||||
}): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', data)
|
||||
return response.data
|
||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/telegram/widget', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Email login
|
||||
@@ -29,63 +29,69 @@ export const authApi = {
|
||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/login', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Register email (link to existing Telegram account)
|
||||
registerEmail: async (email: string, password: string): Promise<{ message: string; email: string }> => {
|
||||
registerEmail: async (
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ message: string; email: string }> => {
|
||||
const response = await apiClient.post('/cabinet/auth/email/register', {
|
||||
email,
|
||||
password,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Register standalone email account (no Telegram required)
|
||||
// Returns message - user must verify email before login
|
||||
registerEmailStandalone: async (data: {
|
||||
email: string
|
||||
password: string
|
||||
first_name?: string
|
||||
language?: string
|
||||
referral_code?: string
|
||||
email: string;
|
||||
password: string;
|
||||
first_name?: string;
|
||||
language?: string;
|
||||
referral_code?: string;
|
||||
}): Promise<RegisterResponse> => {
|
||||
const response = await apiClient.post<RegisterResponse>('/cabinet/auth/email/register/standalone', data)
|
||||
return response.data
|
||||
const response = await apiClient.post<RegisterResponse>(
|
||||
'/cabinet/auth/email/register/standalone',
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Verify email and get auth tokens
|
||||
verifyEmail: async (token: string): Promise<AuthResponse> => {
|
||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/verify', { token })
|
||||
return response.data
|
||||
const response = await apiClient.post<AuthResponse>('/cabinet/auth/email/verify', { token });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Resend verification email
|
||||
resendVerification: async (): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post('/cabinet/auth/email/resend')
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/auth/email/resend');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Refresh token
|
||||
refreshToken: async (refreshToken: string): Promise<TokenResponse> => {
|
||||
const response = await apiClient.post<TokenResponse>('/cabinet/auth/refresh', {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Logout
|
||||
logout: async (refreshToken: string): Promise<void> => {
|
||||
await apiClient.post('/cabinet/auth/logout', {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
});
|
||||
},
|
||||
|
||||
// Forgot password
|
||||
forgotPassword: async (email: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.post('/cabinet/auth/password/forgot', { email })
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/auth/password/forgot', { email });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Reset password
|
||||
@@ -93,13 +99,13 @@ export const authApi = {
|
||||
const response = await apiClient.post('/cabinet/auth/password/reset', {
|
||||
token,
|
||||
password,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get current user
|
||||
getMe: async (): Promise<User> => {
|
||||
const response = await apiClient.get<User>('/cabinet/auth/me')
|
||||
return response.data
|
||||
const response = await apiClient.get<User>('/cabinet/auth/me');
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,100 +1,124 @@
|
||||
import apiClient from './client'
|
||||
import type { Balance, Transaction, PaymentMethod, PaginatedResponse, PendingPayment, ManualCheckResponse } from '../types'
|
||||
import apiClient from './client';
|
||||
import type {
|
||||
Balance,
|
||||
Transaction,
|
||||
PaymentMethod,
|
||||
PaginatedResponse,
|
||||
PendingPayment,
|
||||
ManualCheckResponse,
|
||||
} from '../types';
|
||||
|
||||
export const balanceApi = {
|
||||
// Get current balance
|
||||
getBalance: async (): Promise<Balance> => {
|
||||
const response = await apiClient.get<Balance>('/cabinet/balance')
|
||||
return response.data
|
||||
const response = await apiClient.get<Balance>('/cabinet/balance');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get transaction history
|
||||
getTransactions: async (params?: {
|
||||
page?: number
|
||||
per_page?: number
|
||||
type?: string
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
type?: string;
|
||||
}): Promise<PaginatedResponse<Transaction>> => {
|
||||
const response = await apiClient.get<PaginatedResponse<Transaction>>('/cabinet/balance/transactions', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
const response = await apiClient.get<PaginatedResponse<Transaction>>(
|
||||
'/cabinet/balance/transactions',
|
||||
{
|
||||
params,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get available payment methods
|
||||
getPaymentMethods: async (): Promise<PaymentMethod[]> => {
|
||||
const response = await apiClient.get<PaymentMethod[]>('/cabinet/balance/payment-methods')
|
||||
return response.data
|
||||
const response = await apiClient.get<PaymentMethod[]>('/cabinet/balance/payment-methods');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create top-up payment
|
||||
createTopUp: async (amountKopeks: number, paymentMethod: string, paymentOption?: string): Promise<{
|
||||
payment_id: string
|
||||
payment_url: string
|
||||
amount_kopeks: number
|
||||
amount_rubles: number
|
||||
status: string
|
||||
expires_at: string | null
|
||||
createTopUp: async (
|
||||
amountKopeks: number,
|
||||
paymentMethod: string,
|
||||
paymentOption?: string,
|
||||
): Promise<{
|
||||
payment_id: string;
|
||||
payment_url: string;
|
||||
amount_kopeks: number;
|
||||
amount_rubles: number;
|
||||
status: string;
|
||||
expires_at: string | null;
|
||||
}> => {
|
||||
const payload: {
|
||||
amount_kopeks: number
|
||||
payment_method: string
|
||||
payment_option?: string
|
||||
amount_kopeks: number;
|
||||
payment_method: string;
|
||||
payment_option?: string;
|
||||
} = {
|
||||
amount_kopeks: amountKopeks,
|
||||
payment_method: paymentMethod,
|
||||
}
|
||||
};
|
||||
if (paymentOption) {
|
||||
payload.payment_option = paymentOption
|
||||
payload.payment_option = paymentOption;
|
||||
}
|
||||
const response = await apiClient.post('/cabinet/balance/topup', payload)
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/balance/topup', payload);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Activate promo code
|
||||
activatePromocode: async (code: string): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
balance_before: number
|
||||
balance_after: number
|
||||
bonus_description: string | null
|
||||
activatePromocode: async (
|
||||
code: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
balance_before: number;
|
||||
balance_after: number;
|
||||
bonus_description: string | null;
|
||||
}> => {
|
||||
const response = await apiClient.post('/cabinet/promocode/activate', { code })
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/promocode/activate', { code });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create Telegram Stars invoice for Mini App balance top-up
|
||||
createStarsInvoice: async (amountKopeks: number): Promise<{
|
||||
invoice_url: string
|
||||
stars_amount?: number
|
||||
amount_kopeks?: number
|
||||
createStarsInvoice: async (
|
||||
amountKopeks: number,
|
||||
): Promise<{
|
||||
invoice_url: string;
|
||||
stars_amount?: number;
|
||||
amount_kopeks?: number;
|
||||
}> => {
|
||||
const response = await apiClient.post('/cabinet/balance/stars-invoice', {
|
||||
amount_kopeks: amountKopeks,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get pending payments for manual verification
|
||||
getPendingPayments: async (params?: {
|
||||
page?: number
|
||||
per_page?: number
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
}): Promise<PaginatedResponse<PendingPayment>> => {
|
||||
const response = await apiClient.get<PaginatedResponse<PendingPayment>>('/cabinet/balance/pending-payments', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
const response = await apiClient.get<PaginatedResponse<PendingPayment>>(
|
||||
'/cabinet/balance/pending-payments',
|
||||
{
|
||||
params,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get specific pending payment details
|
||||
getPendingPayment: async (method: string, paymentId: number): Promise<PendingPayment> => {
|
||||
const response = await apiClient.get<PendingPayment>(`/cabinet/balance/pending-payments/${method}/${paymentId}`)
|
||||
return response.data
|
||||
const response = await apiClient.get<PendingPayment>(
|
||||
`/cabinet/balance/pending-payments/${method}/${paymentId}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Manually check payment status
|
||||
checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => {
|
||||
const response = await apiClient.post<ManualCheckResponse>(`/cabinet/balance/pending-payments/${method}/${paymentId}/check`)
|
||||
return response.data
|
||||
const response = await apiClient.post<ManualCheckResponse>(
|
||||
`/cabinet/balance/pending-payments/${method}/${paymentId}/check`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -1,270 +1,270 @@
|
||||
import apiClient from './client'
|
||||
import apiClient from './client';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface BanSystemStatus {
|
||||
enabled: boolean
|
||||
configured: boolean
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export interface BanSystemStats {
|
||||
total_users: number
|
||||
active_users: number
|
||||
users_over_limit: number
|
||||
total_requests: number
|
||||
total_punishments: number
|
||||
active_punishments: number
|
||||
nodes_online: number
|
||||
nodes_total: number
|
||||
agents_online: number
|
||||
agents_total: number
|
||||
panel_connected: boolean
|
||||
uptime_seconds: number | null
|
||||
total_users: number;
|
||||
active_users: number;
|
||||
users_over_limit: number;
|
||||
total_requests: number;
|
||||
total_punishments: number;
|
||||
active_punishments: number;
|
||||
nodes_online: number;
|
||||
nodes_total: number;
|
||||
agents_online: number;
|
||||
agents_total: number;
|
||||
panel_connected: boolean;
|
||||
uptime_seconds: number | null;
|
||||
}
|
||||
|
||||
export interface BanUserIPInfo {
|
||||
ip: string
|
||||
first_seen: string | null
|
||||
last_seen: string | null
|
||||
node: string | null
|
||||
request_count: number
|
||||
country_code: string | null
|
||||
country_name: string | null
|
||||
city: string | null
|
||||
ip: string;
|
||||
first_seen: string | null;
|
||||
last_seen: string | null;
|
||||
node: string | null;
|
||||
request_count: number;
|
||||
country_code: string | null;
|
||||
country_name: string | null;
|
||||
city: string | null;
|
||||
}
|
||||
|
||||
export interface BanUserRequestLog {
|
||||
timestamp: string
|
||||
source_ip: string
|
||||
destination: string | null
|
||||
dest_port: number | null
|
||||
protocol: string | null
|
||||
action: string | null
|
||||
node: string | null
|
||||
timestamp: string;
|
||||
source_ip: string;
|
||||
destination: string | null;
|
||||
dest_port: number | null;
|
||||
protocol: string | null;
|
||||
action: string | null;
|
||||
node: string | null;
|
||||
}
|
||||
|
||||
export interface BanUserListItem {
|
||||
email: string
|
||||
unique_ip_count: number
|
||||
total_requests: number
|
||||
limit: number | null
|
||||
is_over_limit: boolean
|
||||
blocked_count: number
|
||||
last_seen: string | null
|
||||
email: string;
|
||||
unique_ip_count: number;
|
||||
total_requests: number;
|
||||
limit: number | null;
|
||||
is_over_limit: boolean;
|
||||
blocked_count: number;
|
||||
last_seen: string | null;
|
||||
}
|
||||
|
||||
export interface BanUsersListResponse {
|
||||
users: BanUserListItem[]
|
||||
total: number
|
||||
offset: number
|
||||
limit: number
|
||||
users: BanUserListItem[];
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export interface BanUserDetailResponse {
|
||||
email: string
|
||||
unique_ip_count: number
|
||||
total_requests: number
|
||||
limit: number | null
|
||||
is_over_limit: boolean
|
||||
blocked_count: number
|
||||
ips: BanUserIPInfo[]
|
||||
recent_requests: BanUserRequestLog[]
|
||||
network_type: string | null
|
||||
email: string;
|
||||
unique_ip_count: number;
|
||||
total_requests: number;
|
||||
limit: number | null;
|
||||
is_over_limit: boolean;
|
||||
blocked_count: number;
|
||||
ips: BanUserIPInfo[];
|
||||
recent_requests: BanUserRequestLog[];
|
||||
network_type: string | null;
|
||||
}
|
||||
|
||||
export interface BanPunishmentItem {
|
||||
id: number | null
|
||||
user_id: string
|
||||
uuid: string | null
|
||||
username: string
|
||||
reason: string | null
|
||||
punished_at: string
|
||||
enable_at: string | null
|
||||
ip_count: number
|
||||
limit: number
|
||||
enabled: boolean
|
||||
enabled_at: string | null
|
||||
node_name: string | null
|
||||
id: number | null;
|
||||
user_id: string;
|
||||
uuid: string | null;
|
||||
username: string;
|
||||
reason: string | null;
|
||||
punished_at: string;
|
||||
enable_at: string | null;
|
||||
ip_count: number;
|
||||
limit: number;
|
||||
enabled: boolean;
|
||||
enabled_at: string | null;
|
||||
node_name: string | null;
|
||||
}
|
||||
|
||||
export interface BanPunishmentsListResponse {
|
||||
punishments: BanPunishmentItem[]
|
||||
total: number
|
||||
punishments: BanPunishmentItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface BanHistoryResponse {
|
||||
items: BanPunishmentItem[]
|
||||
total: number
|
||||
items: BanPunishmentItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface BanUserRequest {
|
||||
username: string
|
||||
minutes: number
|
||||
reason?: string
|
||||
username: string;
|
||||
minutes: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface UnbanResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BanNodeItem {
|
||||
name: string
|
||||
address: string | null
|
||||
is_connected: boolean
|
||||
last_seen: string | null
|
||||
users_count: number
|
||||
agent_stats: Record<string, unknown> | null
|
||||
name: string;
|
||||
address: string | null;
|
||||
is_connected: boolean;
|
||||
last_seen: string | null;
|
||||
users_count: number;
|
||||
agent_stats: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface BanNodesListResponse {
|
||||
nodes: BanNodeItem[]
|
||||
total: number
|
||||
online: number
|
||||
nodes: BanNodeItem[];
|
||||
total: number;
|
||||
online: number;
|
||||
}
|
||||
|
||||
export interface BanAgentItem {
|
||||
node_name: string
|
||||
sent_total: number
|
||||
dropped_total: number
|
||||
batches_total: number
|
||||
reconnects: number
|
||||
failures: number
|
||||
queue_size: number
|
||||
queue_max: number
|
||||
dedup_checked: number
|
||||
dedup_skipped: number
|
||||
filter_checked: number
|
||||
filter_filtered: number
|
||||
health: string
|
||||
is_online: boolean
|
||||
last_report: string | null
|
||||
node_name: string;
|
||||
sent_total: number;
|
||||
dropped_total: number;
|
||||
batches_total: number;
|
||||
reconnects: number;
|
||||
failures: number;
|
||||
queue_size: number;
|
||||
queue_max: number;
|
||||
dedup_checked: number;
|
||||
dedup_skipped: number;
|
||||
filter_checked: number;
|
||||
filter_filtered: number;
|
||||
health: string;
|
||||
is_online: boolean;
|
||||
last_report: string | null;
|
||||
}
|
||||
|
||||
export interface BanAgentsSummary {
|
||||
total_agents: number
|
||||
online_agents: number
|
||||
total_sent: number
|
||||
total_dropped: number
|
||||
avg_queue_size: number
|
||||
healthy_count: number
|
||||
warning_count: number
|
||||
critical_count: number
|
||||
total_agents: number;
|
||||
online_agents: number;
|
||||
total_sent: number;
|
||||
total_dropped: number;
|
||||
avg_queue_size: number;
|
||||
healthy_count: number;
|
||||
warning_count: number;
|
||||
critical_count: number;
|
||||
}
|
||||
|
||||
export interface BanAgentsListResponse {
|
||||
agents: BanAgentItem[]
|
||||
summary: BanAgentsSummary | null
|
||||
total: number
|
||||
online: number
|
||||
agents: BanAgentItem[];
|
||||
summary: BanAgentsSummary | null;
|
||||
total: number;
|
||||
online: number;
|
||||
}
|
||||
|
||||
export interface BanTrafficViolationItem {
|
||||
id: number | null
|
||||
username: string
|
||||
email: string | null
|
||||
violation_type: string
|
||||
description: string | null
|
||||
bytes_used: number
|
||||
bytes_limit: number
|
||||
detected_at: string
|
||||
resolved: boolean
|
||||
id: number | null;
|
||||
username: string;
|
||||
email: string | null;
|
||||
violation_type: string;
|
||||
description: string | null;
|
||||
bytes_used: number;
|
||||
bytes_limit: number;
|
||||
detected_at: string;
|
||||
resolved: boolean;
|
||||
}
|
||||
|
||||
export interface BanTrafficViolationsResponse {
|
||||
violations: BanTrafficViolationItem[]
|
||||
total: number
|
||||
violations: BanTrafficViolationItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface BanTrafficTopItem {
|
||||
username: string
|
||||
bytes_total: number
|
||||
bytes_limit: number | null
|
||||
over_limit: boolean
|
||||
username: string;
|
||||
bytes_total: number;
|
||||
bytes_limit: number | null;
|
||||
over_limit: boolean;
|
||||
}
|
||||
|
||||
export interface BanTrafficResponse {
|
||||
enabled: boolean
|
||||
stats: Record<string, unknown> | null
|
||||
top_users: BanTrafficTopItem[]
|
||||
recent_violations: BanTrafficViolationItem[]
|
||||
enabled: boolean;
|
||||
stats: Record<string, unknown> | null;
|
||||
top_users: BanTrafficTopItem[];
|
||||
recent_violations: BanTrafficViolationItem[];
|
||||
}
|
||||
|
||||
// === Settings Types ===
|
||||
|
||||
export interface BanSettingDefinition {
|
||||
key: string
|
||||
value: unknown
|
||||
type: string
|
||||
min_value: number | null
|
||||
max_value: number | null
|
||||
editable: boolean
|
||||
description: string | null
|
||||
category: string | null
|
||||
key: string;
|
||||
value: unknown;
|
||||
type: string;
|
||||
min_value: number | null;
|
||||
max_value: number | null;
|
||||
editable: boolean;
|
||||
description: string | null;
|
||||
category: string | null;
|
||||
}
|
||||
|
||||
export interface BanSettingsResponse {
|
||||
settings: BanSettingDefinition[]
|
||||
settings: BanSettingDefinition[];
|
||||
}
|
||||
|
||||
export interface BanWhitelistRequest {
|
||||
username: string
|
||||
username: string;
|
||||
}
|
||||
|
||||
// === Report Types ===
|
||||
|
||||
export interface BanReportTopViolator {
|
||||
username: string
|
||||
count: number
|
||||
username: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface BanReportResponse {
|
||||
period_hours: number
|
||||
current_users: number
|
||||
current_ips: number
|
||||
punishment_stats: Record<string, unknown> | null
|
||||
top_violators: BanReportTopViolator[]
|
||||
period_hours: number;
|
||||
current_users: number;
|
||||
current_ips: number;
|
||||
punishment_stats: Record<string, unknown> | null;
|
||||
top_violators: BanReportTopViolator[];
|
||||
}
|
||||
|
||||
// === Health Types ===
|
||||
|
||||
export interface BanHealthComponent {
|
||||
name: string
|
||||
status: string
|
||||
message: string | null
|
||||
details: Record<string, unknown> | null
|
||||
name: string;
|
||||
status: string;
|
||||
message: string | null;
|
||||
details: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface BanHealthResponse {
|
||||
status: string
|
||||
uptime: number | null
|
||||
components: BanHealthComponent[]
|
||||
status: string;
|
||||
uptime: number | null;
|
||||
components: BanHealthComponent[];
|
||||
}
|
||||
|
||||
export interface BanHealthDetailedResponse {
|
||||
status: string
|
||||
uptime: number | null
|
||||
components: Record<string, unknown>
|
||||
status: string;
|
||||
uptime: number | null;
|
||||
components: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// === Agent History Types ===
|
||||
|
||||
export interface BanAgentHistoryItem {
|
||||
timestamp: string
|
||||
sent_total: number
|
||||
dropped_total: number
|
||||
queue_size: number
|
||||
batches_total: number
|
||||
timestamp: string;
|
||||
sent_total: number;
|
||||
dropped_total: number;
|
||||
queue_size: number;
|
||||
batches_total: number;
|
||||
}
|
||||
|
||||
export interface BanAgentHistoryResponse {
|
||||
node: string
|
||||
hours: number
|
||||
records: number
|
||||
delta: Record<string, unknown> | null
|
||||
first: Record<string, unknown> | null
|
||||
last: Record<string, unknown> | null
|
||||
history: BanAgentHistoryItem[]
|
||||
node: string;
|
||||
hours: number;
|
||||
records: number;
|
||||
delta: Record<string, unknown> | null;
|
||||
first: Record<string, unknown> | null;
|
||||
last: Record<string, unknown> | null;
|
||||
history: BanAgentHistoryItem[];
|
||||
}
|
||||
|
||||
// === API ===
|
||||
@@ -272,174 +272,198 @@ export interface BanAgentHistoryResponse {
|
||||
export const banSystemApi = {
|
||||
// Status
|
||||
getStatus: async (): Promise<BanSystemStatus> => {
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/status')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/status');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Stats
|
||||
getStats: async (): Promise<BanSystemStats> => {
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/stats')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/stats');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Users
|
||||
getUsers: async (params: {
|
||||
offset?: number
|
||||
limit?: number
|
||||
status?: string
|
||||
} = {}): Promise<BanUsersListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/users', { params })
|
||||
return response.data
|
||||
getUsers: async (
|
||||
params: {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
status?: string;
|
||||
} = {},
|
||||
): Promise<BanUsersListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/users', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getUsersOverLimit: async (limit: number = 50): Promise<BanUsersListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/users/over-limit', {
|
||||
params: { limit }
|
||||
})
|
||||
return response.data
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
searchUsers: async (query: string): Promise<BanUsersListResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/ban-system/users/search/${encodeURIComponent(query)}`)
|
||||
return response.data
|
||||
const response = await apiClient.get(
|
||||
`/cabinet/admin/ban-system/users/search/${encodeURIComponent(query)}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getUser: async (email: string): Promise<BanUserDetailResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/ban-system/users/${encodeURIComponent(email)}`)
|
||||
return response.data
|
||||
const response = await apiClient.get(
|
||||
`/cabinet/admin/ban-system/users/${encodeURIComponent(email)}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Punishments
|
||||
getPunishments: async (): Promise<BanPunishmentsListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/punishments')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/punishments');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
unbanUser: async (userId: string): Promise<UnbanResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/ban-system/punishments/${userId}/unban`)
|
||||
return response.data
|
||||
const response = await apiClient.post(`/cabinet/admin/ban-system/punishments/${userId}/unban`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
banUser: async (data: BanUserRequest): Promise<UnbanResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/ban-system/ban', data)
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/admin/ban-system/ban', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getPunishmentHistory: async (query: string, limit: number = 20): Promise<BanHistoryResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/ban-system/history/${encodeURIComponent(query)}`, {
|
||||
params: { limit }
|
||||
})
|
||||
return response.data
|
||||
const response = await apiClient.get(
|
||||
`/cabinet/admin/ban-system/history/${encodeURIComponent(query)}`,
|
||||
{
|
||||
params: { limit },
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Nodes
|
||||
getNodes: async (): Promise<BanNodesListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/nodes')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/nodes');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Agents
|
||||
getAgents: async (params: {
|
||||
search?: string
|
||||
health?: string
|
||||
status?: string
|
||||
} = {}): Promise<BanAgentsListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/agents', { params })
|
||||
return response.data
|
||||
getAgents: async (
|
||||
params: {
|
||||
search?: string;
|
||||
health?: string;
|
||||
status?: string;
|
||||
} = {},
|
||||
): Promise<BanAgentsListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/agents', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getAgentsSummary: async (): Promise<BanAgentsSummary> => {
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/agents/summary')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/agents/summary');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Traffic violations
|
||||
getTrafficViolations: async (limit: number = 50): Promise<BanTrafficViolationsResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/traffic/violations', {
|
||||
params: { limit }
|
||||
})
|
||||
return response.data
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Full Traffic
|
||||
getTraffic: async (): Promise<BanTrafficResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/traffic')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/traffic');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTrafficTop: async (limit: number = 20): Promise<BanTrafficTopItem[]> => {
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/traffic/top', {
|
||||
params: { limit }
|
||||
})
|
||||
return response.data
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Settings
|
||||
getSettings: async (): Promise<BanSettingsResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/settings')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/settings');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getSetting: async (key: string): Promise<BanSettingDefinition> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/ban-system/settings/${key}`)
|
||||
return response.data
|
||||
const response = await apiClient.get(`/cabinet/admin/ban-system/settings/${key}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
setSetting: async (key: string, value: string): Promise<BanSettingDefinition> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}`, null, {
|
||||
params: { value }
|
||||
})
|
||||
return response.data
|
||||
params: { value },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
toggleSetting: async (key: string): Promise<BanSettingDefinition> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}/toggle`)
|
||||
return response.data
|
||||
const response = await apiClient.post(`/cabinet/admin/ban-system/settings/${key}/toggle`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Whitelist
|
||||
whitelistAdd: async (username: string): Promise<UnbanResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/add', { username })
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/add', {
|
||||
username,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
whitelistRemove: async (username: string): Promise<UnbanResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/remove', { username })
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/admin/ban-system/settings/whitelist/remove', {
|
||||
username,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Reports
|
||||
getReport: async (hours: number = 24): Promise<BanReportResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/report', {
|
||||
params: { hours }
|
||||
})
|
||||
return response.data
|
||||
params: { hours },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Health
|
||||
getHealth: async (): Promise<BanHealthResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/health')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/health');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getHealthDetailed: async (): Promise<BanHealthDetailedResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/health/detailed')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/ban-system/health/detailed');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Agent History
|
||||
getAgentHistory: async (nodeName: string, hours: number = 24): Promise<BanAgentHistoryResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/ban-system/agents/${encodeURIComponent(nodeName)}/history`, {
|
||||
params: { hours }
|
||||
})
|
||||
return response.data
|
||||
getAgentHistory: async (
|
||||
nodeName: string,
|
||||
hours: number = 24,
|
||||
): Promise<BanAgentHistoryResponse> => {
|
||||
const response = await apiClient.get(
|
||||
`/cabinet/admin/ban-system/agents/${encodeURIComponent(nodeName)}/history`,
|
||||
{
|
||||
params: { hours },
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// User Punishment History
|
||||
getUserHistory: async (email: string, limit: number = 20): Promise<BanHistoryResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/ban-system/users/${encodeURIComponent(email)}/history`, {
|
||||
params: { limit }
|
||||
})
|
||||
return response.data
|
||||
const response = await apiClient.get(
|
||||
`/cabinet/admin/ban-system/users/${encodeURIComponent(email)}/history`,
|
||||
{
|
||||
params: { limit },
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,203 +1,209 @@
|
||||
import apiClient from './client'
|
||||
import apiClient from './client';
|
||||
|
||||
export interface BrandingInfo {
|
||||
name: string
|
||||
logo_url: string | null
|
||||
logo_letter: string
|
||||
has_custom_logo: boolean
|
||||
name: string;
|
||||
logo_url: string | null;
|
||||
logo_letter: string;
|
||||
has_custom_logo: boolean;
|
||||
}
|
||||
|
||||
export interface AnimationEnabled {
|
||||
enabled: boolean
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface FullscreenEnabled {
|
||||
enabled: boolean
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface EmailAuthEnabled {
|
||||
enabled: boolean
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AnalyticsCounters {
|
||||
yandex_metrika_id: string
|
||||
google_ads_id: string
|
||||
google_ads_label: string
|
||||
yandex_metrika_id: string;
|
||||
google_ads_id: string;
|
||||
google_ads_label: string;
|
||||
}
|
||||
|
||||
const BRANDING_CACHE_KEY = 'cabinet_branding'
|
||||
const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded'
|
||||
const BRANDING_CACHE_KEY = 'cabinet_branding';
|
||||
const LOGO_PRELOADED_KEY = 'cabinet_logo_preloaded';
|
||||
|
||||
// Check if logo was already preloaded in this session
|
||||
export const isLogoPreloaded = (): boolean => {
|
||||
try {
|
||||
const cached = getCachedBranding()
|
||||
const cached = getCachedBranding();
|
||||
if (!cached?.has_custom_logo || !cached?.logo_url) {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
const logoUrl = `${import.meta.env.VITE_API_URL || ''}${cached.logo_url}`
|
||||
const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY)
|
||||
return preloaded === logoUrl
|
||||
const logoUrl = `${import.meta.env.VITE_API_URL || ''}${cached.logo_url}`;
|
||||
const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY);
|
||||
return preloaded === logoUrl;
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Get cached branding from localStorage
|
||||
export const getCachedBranding = (): BrandingInfo | null => {
|
||||
try {
|
||||
const cached = localStorage.getItem(BRANDING_CACHE_KEY)
|
||||
const cached = localStorage.getItem(BRANDING_CACHE_KEY);
|
||||
if (cached) {
|
||||
return JSON.parse(cached)
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available or invalid JSON
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Update branding cache in localStorage
|
||||
export const setCachedBranding = (branding: BrandingInfo) => {
|
||||
try {
|
||||
localStorage.setItem(BRANDING_CACHE_KEY, JSON.stringify(branding))
|
||||
localStorage.setItem(BRANDING_CACHE_KEY, JSON.stringify(branding));
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Preload logo image for instant display
|
||||
export const preloadLogo = (branding: BrandingInfo): Promise<void> => {
|
||||
return new Promise((resolve) => {
|
||||
if (!branding.has_custom_logo || !branding.logo_url) {
|
||||
resolve()
|
||||
return
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const logoUrl = `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}`
|
||||
const logoUrl = `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}`;
|
||||
|
||||
// Check if already preloaded in this session
|
||||
const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY)
|
||||
const preloaded = sessionStorage.getItem(LOGO_PRELOADED_KEY);
|
||||
if (preloaded === logoUrl) {
|
||||
resolve()
|
||||
return
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const img = new Image()
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
sessionStorage.setItem(LOGO_PRELOADED_KEY, logoUrl)
|
||||
resolve()
|
||||
}
|
||||
img.onerror = () => resolve()
|
||||
img.src = logoUrl
|
||||
})
|
||||
}
|
||||
sessionStorage.setItem(LOGO_PRELOADED_KEY, logoUrl);
|
||||
resolve();
|
||||
};
|
||||
img.onerror = () => resolve();
|
||||
img.src = logoUrl;
|
||||
});
|
||||
};
|
||||
|
||||
// Initialize logo preload from cache on page load
|
||||
export const initLogoPreload = () => {
|
||||
const cached = getCachedBranding()
|
||||
const cached = getCachedBranding();
|
||||
if (cached) {
|
||||
preloadLogo(cached)
|
||||
preloadLogo(cached);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const brandingApi = {
|
||||
// Get current branding (public, no auth required)
|
||||
getBranding: async (): Promise<BrandingInfo> => {
|
||||
const response = await apiClient.get<BrandingInfo>('/cabinet/branding')
|
||||
return response.data
|
||||
const response = await apiClient.get<BrandingInfo>('/cabinet/branding');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update project name (admin only)
|
||||
updateName: async (name: string): Promise<BrandingInfo> => {
|
||||
const response = await apiClient.put<BrandingInfo>('/cabinet/branding/name', { name })
|
||||
return response.data
|
||||
const response = await apiClient.put<BrandingInfo>('/cabinet/branding/name', { name });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Upload custom logo (admin only)
|
||||
uploadLogo: async (file: File): Promise<BrandingInfo> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const response = await apiClient.post<BrandingInfo>('/cabinet/branding/logo', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete custom logo (admin only)
|
||||
deleteLogo: async (): Promise<BrandingInfo> => {
|
||||
const response = await apiClient.delete<BrandingInfo>('/cabinet/branding/logo')
|
||||
return response.data
|
||||
const response = await apiClient.delete<BrandingInfo>('/cabinet/branding/logo');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get logo URL (without cache busting - server handles caching via Cache-Control headers)
|
||||
getLogoUrl: (branding: BrandingInfo): string | null => {
|
||||
if (!branding.has_custom_logo || !branding.logo_url) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
return `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}`
|
||||
return `${import.meta.env.VITE_API_URL || ''}${branding.logo_url}`;
|
||||
},
|
||||
|
||||
// Get animation enabled (public, no auth required)
|
||||
getAnimationEnabled: async (): Promise<AnimationEnabled> => {
|
||||
const response = await apiClient.get<AnimationEnabled>('/cabinet/branding/animation')
|
||||
return response.data
|
||||
const response = await apiClient.get<AnimationEnabled>('/cabinet/branding/animation');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update animation enabled (admin only)
|
||||
updateAnimationEnabled: async (enabled: boolean): Promise<AnimationEnabled> => {
|
||||
const response = await apiClient.patch<AnimationEnabled>('/cabinet/branding/animation', { enabled })
|
||||
return response.data
|
||||
const response = await apiClient.patch<AnimationEnabled>('/cabinet/branding/animation', {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get fullscreen enabled (public, no auth required)
|
||||
getFullscreenEnabled: async (): Promise<FullscreenEnabled> => {
|
||||
try {
|
||||
const response = await apiClient.get<FullscreenEnabled>('/cabinet/branding/fullscreen')
|
||||
return response.data
|
||||
const response = await apiClient.get<FullscreenEnabled>('/cabinet/branding/fullscreen');
|
||||
return response.data;
|
||||
} catch {
|
||||
// If endpoint doesn't exist, default to disabled
|
||||
return { enabled: false }
|
||||
return { enabled: false };
|
||||
}
|
||||
},
|
||||
|
||||
// Update fullscreen enabled (admin only)
|
||||
updateFullscreenEnabled: async (enabled: boolean): Promise<FullscreenEnabled> => {
|
||||
const response = await apiClient.patch<FullscreenEnabled>('/cabinet/branding/fullscreen', { enabled })
|
||||
return response.data
|
||||
const response = await apiClient.patch<FullscreenEnabled>('/cabinet/branding/fullscreen', {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get email auth enabled (public, no auth required)
|
||||
getEmailAuthEnabled: async (): Promise<EmailAuthEnabled> => {
|
||||
try {
|
||||
const response = await apiClient.get<EmailAuthEnabled>('/cabinet/branding/email-auth')
|
||||
return response.data
|
||||
const response = await apiClient.get<EmailAuthEnabled>('/cabinet/branding/email-auth');
|
||||
return response.data;
|
||||
} catch {
|
||||
// If endpoint doesn't exist, default to enabled
|
||||
return { enabled: true }
|
||||
return { enabled: true };
|
||||
}
|
||||
},
|
||||
|
||||
// Update email auth enabled (admin only)
|
||||
updateEmailAuthEnabled: async (enabled: boolean): Promise<EmailAuthEnabled> => {
|
||||
const response = await apiClient.patch<EmailAuthEnabled>('/cabinet/branding/email-auth', { enabled })
|
||||
return response.data
|
||||
const response = await apiClient.patch<EmailAuthEnabled>('/cabinet/branding/email-auth', {
|
||||
enabled,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get analytics counters (public, no auth required)
|
||||
getAnalyticsCounters: async (): Promise<AnalyticsCounters> => {
|
||||
try {
|
||||
const response = await apiClient.get<AnalyticsCounters>('/cabinet/branding/analytics')
|
||||
return response.data
|
||||
const response = await apiClient.get<AnalyticsCounters>('/cabinet/branding/analytics');
|
||||
return response.data;
|
||||
} catch {
|
||||
return { yandex_metrika_id: '', google_ads_id: '', google_ads_label: '' }
|
||||
return { yandex_metrika_id: '', google_ads_id: '', google_ads_label: '' };
|
||||
}
|
||||
},
|
||||
|
||||
// Update analytics counters (admin only)
|
||||
updateAnalyticsCounters: async (data: Partial<AnalyticsCounters>): Promise<AnalyticsCounters> => {
|
||||
const response = await apiClient.patch<AnalyticsCounters>('/cabinet/branding/analytics', data)
|
||||
return response.data
|
||||
const response = await apiClient.patch<AnalyticsCounters>('/cabinet/branding/analytics', data);
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,230 +1,241 @@
|
||||
import apiClient from './client'
|
||||
import apiClient from './client';
|
||||
|
||||
// Types
|
||||
export type CampaignBonusType = 'balance' | 'subscription' | 'none' | 'tariff'
|
||||
export type CampaignBonusType = 'balance' | 'subscription' | 'none' | 'tariff';
|
||||
|
||||
export interface TariffInfo {
|
||||
id: number
|
||||
name: string
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface CampaignListItem {
|
||||
id: number
|
||||
name: string
|
||||
start_parameter: string
|
||||
bonus_type: CampaignBonusType
|
||||
is_active: boolean
|
||||
registrations_count: number
|
||||
total_revenue_kopeks: number
|
||||
conversion_rate: number
|
||||
created_at: string
|
||||
id: number;
|
||||
name: string;
|
||||
start_parameter: string;
|
||||
bonus_type: CampaignBonusType;
|
||||
is_active: boolean;
|
||||
registrations_count: number;
|
||||
total_revenue_kopeks: number;
|
||||
conversion_rate: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CampaignListResponse {
|
||||
campaigns: CampaignListItem[]
|
||||
total: number
|
||||
campaigns: CampaignListItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface CampaignDetail {
|
||||
id: number
|
||||
name: string
|
||||
start_parameter: string
|
||||
bonus_type: CampaignBonusType
|
||||
is_active: boolean
|
||||
balance_bonus_kopeks: number
|
||||
balance_bonus_rubles: number
|
||||
subscription_duration_days: number | null
|
||||
subscription_traffic_gb: number | null
|
||||
subscription_device_limit: number | null
|
||||
subscription_squads: string[]
|
||||
tariff_id: number | null
|
||||
tariff_duration_days: number | null
|
||||
tariff: TariffInfo | null
|
||||
created_by: number | null
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
deep_link: string | null
|
||||
id: number;
|
||||
name: string;
|
||||
start_parameter: string;
|
||||
bonus_type: CampaignBonusType;
|
||||
is_active: boolean;
|
||||
balance_bonus_kopeks: number;
|
||||
balance_bonus_rubles: number;
|
||||
subscription_duration_days: number | null;
|
||||
subscription_traffic_gb: number | null;
|
||||
subscription_device_limit: number | null;
|
||||
subscription_squads: string[];
|
||||
tariff_id: number | null;
|
||||
tariff_duration_days: number | null;
|
||||
tariff: TariffInfo | null;
|
||||
created_by: number | null;
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
deep_link: string | null;
|
||||
}
|
||||
|
||||
export interface CampaignCreateRequest {
|
||||
name: string
|
||||
start_parameter: string
|
||||
bonus_type: CampaignBonusType
|
||||
is_active?: boolean
|
||||
balance_bonus_kopeks?: number
|
||||
subscription_duration_days?: number
|
||||
subscription_traffic_gb?: number
|
||||
subscription_device_limit?: number
|
||||
subscription_squads?: string[]
|
||||
tariff_id?: number
|
||||
tariff_duration_days?: number
|
||||
name: string;
|
||||
start_parameter: string;
|
||||
bonus_type: CampaignBonusType;
|
||||
is_active?: boolean;
|
||||
balance_bonus_kopeks?: number;
|
||||
subscription_duration_days?: number;
|
||||
subscription_traffic_gb?: number;
|
||||
subscription_device_limit?: number;
|
||||
subscription_squads?: string[];
|
||||
tariff_id?: number;
|
||||
tariff_duration_days?: number;
|
||||
}
|
||||
|
||||
export interface CampaignUpdateRequest {
|
||||
name?: string
|
||||
start_parameter?: string
|
||||
bonus_type?: CampaignBonusType
|
||||
is_active?: boolean
|
||||
balance_bonus_kopeks?: number
|
||||
subscription_duration_days?: number
|
||||
subscription_traffic_gb?: number
|
||||
subscription_device_limit?: number
|
||||
subscription_squads?: string[]
|
||||
tariff_id?: number
|
||||
tariff_duration_days?: number
|
||||
name?: string;
|
||||
start_parameter?: string;
|
||||
bonus_type?: CampaignBonusType;
|
||||
is_active?: boolean;
|
||||
balance_bonus_kopeks?: number;
|
||||
subscription_duration_days?: number;
|
||||
subscription_traffic_gb?: number;
|
||||
subscription_device_limit?: number;
|
||||
subscription_squads?: string[];
|
||||
tariff_id?: number;
|
||||
tariff_duration_days?: number;
|
||||
}
|
||||
|
||||
export interface CampaignToggleResponse {
|
||||
id: number
|
||||
is_active: boolean
|
||||
message: string
|
||||
id: number;
|
||||
is_active: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CampaignStatistics {
|
||||
id: number
|
||||
name: string
|
||||
start_parameter: string
|
||||
bonus_type: CampaignBonusType
|
||||
is_active: boolean
|
||||
registrations: number
|
||||
balance_issued_kopeks: number
|
||||
balance_issued_rubles: number
|
||||
subscription_issued: number
|
||||
last_registration: string | null
|
||||
total_revenue_kopeks: number
|
||||
total_revenue_rubles: number
|
||||
avg_revenue_per_user_kopeks: number
|
||||
avg_revenue_per_user_rubles: number
|
||||
avg_first_payment_kopeks: number
|
||||
avg_first_payment_rubles: number
|
||||
trial_users_count: number
|
||||
active_trials_count: number
|
||||
conversion_count: number
|
||||
paid_users_count: number
|
||||
conversion_rate: number
|
||||
trial_conversion_rate: number
|
||||
deep_link: string | null
|
||||
id: number;
|
||||
name: string;
|
||||
start_parameter: string;
|
||||
bonus_type: CampaignBonusType;
|
||||
is_active: boolean;
|
||||
registrations: number;
|
||||
balance_issued_kopeks: number;
|
||||
balance_issued_rubles: number;
|
||||
subscription_issued: number;
|
||||
last_registration: string | null;
|
||||
total_revenue_kopeks: number;
|
||||
total_revenue_rubles: number;
|
||||
avg_revenue_per_user_kopeks: number;
|
||||
avg_revenue_per_user_rubles: number;
|
||||
avg_first_payment_kopeks: number;
|
||||
avg_first_payment_rubles: number;
|
||||
trial_users_count: number;
|
||||
active_trials_count: number;
|
||||
conversion_count: number;
|
||||
paid_users_count: number;
|
||||
conversion_rate: number;
|
||||
trial_conversion_rate: number;
|
||||
deep_link: string | null;
|
||||
}
|
||||
|
||||
export interface CampaignRegistrationItem {
|
||||
id: number
|
||||
user_id: number
|
||||
telegram_id: number
|
||||
username: string | null
|
||||
first_name: string | null
|
||||
bonus_type: string
|
||||
balance_bonus_kopeks: number
|
||||
subscription_duration_days: number | null
|
||||
tariff_id: number | null
|
||||
tariff_duration_days: number | null
|
||||
created_at: string
|
||||
user_balance_kopeks: number
|
||||
has_subscription: boolean
|
||||
has_paid: boolean
|
||||
id: number;
|
||||
user_id: number;
|
||||
telegram_id: number;
|
||||
username: string | null;
|
||||
first_name: string | null;
|
||||
bonus_type: string;
|
||||
balance_bonus_kopeks: number;
|
||||
subscription_duration_days: number | null;
|
||||
tariff_id: number | null;
|
||||
tariff_duration_days: number | null;
|
||||
created_at: string;
|
||||
user_balance_kopeks: number;
|
||||
has_subscription: boolean;
|
||||
has_paid: boolean;
|
||||
}
|
||||
|
||||
export interface CampaignRegistrationsResponse {
|
||||
registrations: CampaignRegistrationItem[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
registrations: CampaignRegistrationItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
}
|
||||
|
||||
export interface CampaignsOverview {
|
||||
total: number
|
||||
active: number
|
||||
inactive: number
|
||||
total_registrations: number
|
||||
total_balance_issued_kopeks: number
|
||||
total_balance_issued_rubles: number
|
||||
total_subscription_issued: number
|
||||
total_tariff_issued: number
|
||||
total: number;
|
||||
active: number;
|
||||
inactive: number;
|
||||
total_registrations: number;
|
||||
total_balance_issued_kopeks: number;
|
||||
total_balance_issued_rubles: number;
|
||||
total_subscription_issued: number;
|
||||
total_tariff_issued: number;
|
||||
}
|
||||
|
||||
export interface ServerSquadInfo {
|
||||
id: number
|
||||
squad_uuid: string
|
||||
display_name: string
|
||||
country_code: string | null
|
||||
id: number;
|
||||
squad_uuid: string;
|
||||
display_name: string;
|
||||
country_code: string | null;
|
||||
}
|
||||
|
||||
export interface TariffListItem {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
is_active: boolean
|
||||
traffic_limit_gb: number
|
||||
device_limit: number
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
traffic_limit_gb: number;
|
||||
device_limit: number;
|
||||
}
|
||||
|
||||
export const campaignsApi = {
|
||||
// Get campaigns overview
|
||||
getOverview: async (): Promise<CampaignsOverview> => {
|
||||
const response = await apiClient.get('/cabinet/admin/campaigns/overview')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/campaigns/overview');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get all campaigns
|
||||
getCampaigns: async (includeInactive = true, offset = 0, limit = 50): Promise<CampaignListResponse> => {
|
||||
getCampaigns: async (
|
||||
includeInactive = true,
|
||||
offset = 0,
|
||||
limit = 50,
|
||||
): Promise<CampaignListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/campaigns', {
|
||||
params: { include_inactive: includeInactive, offset, limit }
|
||||
})
|
||||
return response.data
|
||||
params: { include_inactive: includeInactive, offset, limit },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get single campaign
|
||||
getCampaign: async (campaignId: number): Promise<CampaignDetail> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}`)
|
||||
return response.data
|
||||
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get campaign statistics
|
||||
getCampaignStats: async (campaignId: number): Promise<CampaignStatistics> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/stats`)
|
||||
return response.data
|
||||
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/stats`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get campaign registrations
|
||||
getCampaignRegistrations: async (campaignId: number, page = 1, perPage = 50): Promise<CampaignRegistrationsResponse> => {
|
||||
getCampaignRegistrations: async (
|
||||
campaignId: number,
|
||||
page = 1,
|
||||
perPage = 50,
|
||||
): Promise<CampaignRegistrationsResponse> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/campaigns/${campaignId}/registrations`, {
|
||||
params: { page, per_page: perPage }
|
||||
})
|
||||
return response.data
|
||||
params: { page, per_page: perPage },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create campaign
|
||||
createCampaign: async (data: CampaignCreateRequest): Promise<CampaignDetail> => {
|
||||
const response = await apiClient.post('/cabinet/admin/campaigns', data)
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/admin/campaigns', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update campaign
|
||||
updateCampaign: async (campaignId: number, data: CampaignUpdateRequest): Promise<CampaignDetail> => {
|
||||
const response = await apiClient.put(`/cabinet/admin/campaigns/${campaignId}`, data)
|
||||
return response.data
|
||||
updateCampaign: async (
|
||||
campaignId: number,
|
||||
data: CampaignUpdateRequest,
|
||||
): Promise<CampaignDetail> => {
|
||||
const response = await apiClient.put(`/cabinet/admin/campaigns/${campaignId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete campaign
|
||||
deleteCampaign: async (campaignId: number): Promise<{ message: string }> => {
|
||||
const response = await apiClient.delete(`/cabinet/admin/campaigns/${campaignId}`)
|
||||
return response.data
|
||||
const response = await apiClient.delete(`/cabinet/admin/campaigns/${campaignId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Toggle campaign active status
|
||||
toggleCampaign: async (campaignId: number): Promise<CampaignToggleResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/campaigns/${campaignId}/toggle`)
|
||||
return response.data
|
||||
const response = await apiClient.post(`/cabinet/admin/campaigns/${campaignId}/toggle`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get available servers for subscription bonus
|
||||
getAvailableServers: async (): Promise<ServerSquadInfo[]> => {
|
||||
const response = await apiClient.get('/cabinet/admin/campaigns/available-servers')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/campaigns/available-servers');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get available tariffs for tariff bonus
|
||||
getAvailableTariffs: async (): Promise<TariffListItem[]> => {
|
||||
const response = await apiClient.get('/cabinet/admin/campaigns/available-tariffs')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/campaigns/available-tariffs');
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,163 +1,175 @@
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
import { tokenStorage, isTokenExpired, tokenRefreshManager, safeRedirectToLogin } from '../utils/token'
|
||||
import { useBlockingStore } from '../store/blocking'
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import {
|
||||
tokenStorage,
|
||||
isTokenExpired,
|
||||
tokenRefreshManager,
|
||||
safeRedirectToLogin,
|
||||
} from '../utils/token';
|
||||
import { useBlockingStore } from '../store/blocking';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api'
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
// Настраиваем endpoint для refresh
|
||||
tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`)
|
||||
tokenRefreshManager.setRefreshEndpoint(`${API_BASE_URL}/cabinet/auth/refresh`);
|
||||
|
||||
// CSRF token management
|
||||
const CSRF_COOKIE_NAME = 'csrf_token'
|
||||
const CSRF_HEADER_NAME = 'X-CSRF-Token'
|
||||
const CSRF_COOKIE_NAME = 'csrf_token';
|
||||
const CSRF_HEADER_NAME = 'X-CSRF-Token';
|
||||
|
||||
function getCsrfToken(): string | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
const match = document.cookie.match(new RegExp(`(^| )${CSRF_COOKIE_NAME}=([^;]+)`))
|
||||
return match ? match[2] : null
|
||||
if (typeof document === 'undefined') return null;
|
||||
const match = document.cookie.match(new RegExp(`(^| )${CSRF_COOKIE_NAME}=([^;]+)`));
|
||||
return match ? match[2] : null;
|
||||
}
|
||||
|
||||
function generateCsrfToken(): string {
|
||||
const array = new Uint8Array(32)
|
||||
crypto.getRandomValues(array)
|
||||
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('')
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function ensureCsrfToken(): string {
|
||||
let token = getCsrfToken()
|
||||
let token = getCsrfToken();
|
||||
if (!token) {
|
||||
token = generateCsrfToken()
|
||||
token = generateCsrfToken();
|
||||
// Set cookie with SameSite=Strict for CSRF protection
|
||||
document.cookie = `${CSRF_COOKIE_NAME}=${token}; path=/; SameSite=Strict; Secure`
|
||||
document.cookie = `${CSRF_COOKIE_NAME}=${token}; path=/; SameSite=Strict; Secure`;
|
||||
}
|
||||
return token
|
||||
return token;
|
||||
}
|
||||
|
||||
const getTelegramInitData = (): string | null => {
|
||||
if (typeof window === 'undefined') return null
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const initData = window.Telegram?.WebApp?.initData
|
||||
const initData = window.Telegram?.WebApp?.initData;
|
||||
if (initData) {
|
||||
tokenStorage.setTelegramInitData(initData)
|
||||
return initData
|
||||
tokenStorage.setTelegramInitData(initData);
|
||||
return initData;
|
||||
}
|
||||
|
||||
return tokenStorage.getTelegramInitData()
|
||||
}
|
||||
return tokenStorage.getTelegramInitData();
|
||||
};
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Request interceptor - add auth token with expiration check
|
||||
apiClient.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
||||
let token = tokenStorage.getAccessToken()
|
||||
let token = tokenStorage.getAccessToken();
|
||||
|
||||
// Проверяем срок действия токена перед запросом
|
||||
if (token && isTokenExpired(token)) {
|
||||
// Используем централизованный менеджер для refresh
|
||||
const newToken = await tokenRefreshManager.refreshAccessToken()
|
||||
const newToken = await tokenRefreshManager.refreshAccessToken();
|
||||
if (newToken) {
|
||||
token = newToken
|
||||
token = newToken;
|
||||
} else {
|
||||
// Refresh не удался - редирект на логин
|
||||
tokenStorage.clearTokens()
|
||||
safeRedirectToLogin()
|
||||
return config
|
||||
tokenStorage.clearTokens();
|
||||
safeRedirectToLogin();
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const telegramInitData = getTelegramInitData()
|
||||
const telegramInitData = getTelegramInitData();
|
||||
if (telegramInitData && config.headers) {
|
||||
config.headers['X-Telegram-Init-Data'] = telegramInitData
|
||||
config.headers['X-Telegram-Init-Data'] = telegramInitData;
|
||||
}
|
||||
|
||||
// Add CSRF token for state-changing methods
|
||||
const method = config.method?.toUpperCase()
|
||||
const method = config.method?.toUpperCase();
|
||||
if (method && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method) && config.headers) {
|
||||
config.headers[CSRF_HEADER_NAME] = ensureCsrfToken()
|
||||
config.headers[CSRF_HEADER_NAME] = ensureCsrfToken();
|
||||
}
|
||||
|
||||
return config
|
||||
})
|
||||
return config;
|
||||
});
|
||||
|
||||
// Custom error types for special handling
|
||||
export interface MaintenanceError {
|
||||
code: 'maintenance'
|
||||
message: string
|
||||
reason?: string
|
||||
code: 'maintenance';
|
||||
message: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface ChannelSubscriptionError {
|
||||
code: 'channel_subscription_required'
|
||||
message: string
|
||||
channel_link?: string
|
||||
code: 'channel_subscription_required';
|
||||
message: string;
|
||||
channel_link?: string;
|
||||
}
|
||||
|
||||
export function isMaintenanceError(error: unknown): error is { response: { status: 503, data: { detail: MaintenanceError } } } {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
const err = error as AxiosError<{ detail: MaintenanceError }>
|
||||
return err.response?.status === 503 && err.response?.data?.detail?.code === 'maintenance'
|
||||
export function isMaintenanceError(
|
||||
error: unknown,
|
||||
): error is { response: { status: 503; data: { detail: MaintenanceError } } } {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const err = error as AxiosError<{ detail: MaintenanceError }>;
|
||||
return err.response?.status === 503 && err.response?.data?.detail?.code === 'maintenance';
|
||||
}
|
||||
|
||||
export function isChannelSubscriptionError(error: unknown): error is { response: { status: 403, data: { detail: ChannelSubscriptionError } } } {
|
||||
if (!error || typeof error !== 'object') return false
|
||||
const err = error as AxiosError<{ detail: ChannelSubscriptionError }>
|
||||
return err.response?.status === 403 && err.response?.data?.detail?.code === 'channel_subscription_required'
|
||||
export function isChannelSubscriptionError(
|
||||
error: unknown,
|
||||
): error is { response: { status: 403; data: { detail: ChannelSubscriptionError } } } {
|
||||
if (!error || typeof error !== 'object') return false;
|
||||
const err = error as AxiosError<{ detail: ChannelSubscriptionError }>;
|
||||
return (
|
||||
err.response?.status === 403 &&
|
||||
err.response?.data?.detail?.code === 'channel_subscription_required'
|
||||
);
|
||||
}
|
||||
|
||||
// Response interceptor - handle 401, 503 (maintenance), 403 (channel subscription)
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
// Handle maintenance mode (503)
|
||||
if (isMaintenanceError(error)) {
|
||||
const detail = (error.response?.data as { detail: MaintenanceError }).detail
|
||||
const detail = (error.response?.data as { detail: MaintenanceError }).detail;
|
||||
useBlockingStore.getState().setMaintenance({
|
||||
message: detail.message,
|
||||
reason: detail.reason,
|
||||
})
|
||||
return Promise.reject(error)
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Handle channel subscription required (403)
|
||||
if (isChannelSubscriptionError(error)) {
|
||||
const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail
|
||||
const detail = (error.response?.data as { detail: ChannelSubscriptionError }).detail;
|
||||
useBlockingStore.getState().setChannelSubscription({
|
||||
message: detail.message,
|
||||
channel_link: detail.channel_link,
|
||||
})
|
||||
return Promise.reject(error)
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Если получили 401 и ещё не пробовали refresh (на случай если проверка exp не сработала)
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true
|
||||
originalRequest._retry = true;
|
||||
|
||||
const newToken = await tokenRefreshManager.refreshAccessToken()
|
||||
const newToken = await tokenRefreshManager.refreshAccessToken();
|
||||
if (newToken) {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||
}
|
||||
return apiClient(originalRequest)
|
||||
return apiClient(originalRequest);
|
||||
} else {
|
||||
// Refresh не удался
|
||||
tokenStorage.clearTokens()
|
||||
safeRedirectToLogin()
|
||||
tokenStorage.clearTokens();
|
||||
safeRedirectToLogin();
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
export default apiClient
|
||||
export default apiClient;
|
||||
|
||||
@@ -1,49 +1,50 @@
|
||||
import apiClient from './client'
|
||||
import apiClient from './client';
|
||||
|
||||
export interface ContestInfo {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
description: string | null
|
||||
prize_days: number
|
||||
is_available: boolean
|
||||
already_played: boolean
|
||||
id: number;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
prize_days: number;
|
||||
is_available: boolean;
|
||||
already_played: boolean;
|
||||
}
|
||||
|
||||
export interface ContestGameData {
|
||||
round_id: number
|
||||
game_type: string
|
||||
game_data: Record<string, any>
|
||||
instructions: string
|
||||
round_id: number;
|
||||
game_type: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
game_data: Record<string, any>;
|
||||
instructions: string;
|
||||
}
|
||||
|
||||
export interface ContestResult {
|
||||
is_winner: boolean
|
||||
message: string
|
||||
prize_days?: number
|
||||
is_winner: boolean;
|
||||
message: string;
|
||||
prize_days?: number;
|
||||
}
|
||||
|
||||
export interface ContestsCountResponse {
|
||||
count: number
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const contestsApi = {
|
||||
// Get count of available contests
|
||||
getCount: async (): Promise<ContestsCountResponse> => {
|
||||
const response = await apiClient.get<ContestsCountResponse>('/cabinet/contests/count')
|
||||
return response.data
|
||||
const response = await apiClient.get<ContestsCountResponse>('/cabinet/contests/count');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get list of available contests
|
||||
getContests: async (): Promise<ContestInfo[]> => {
|
||||
const response = await apiClient.get<ContestInfo[]>('/cabinet/contests')
|
||||
return response.data
|
||||
const response = await apiClient.get<ContestInfo[]>('/cabinet/contests');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get game data for a specific contest
|
||||
getContestGame: async (roundId: number): Promise<ContestGameData> => {
|
||||
const response = await apiClient.get<ContestGameData>(`/cabinet/contests/${roundId}`)
|
||||
return response.data
|
||||
const response = await apiClient.get<ContestGameData>(`/cabinet/contests/${roundId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Submit answer for a contest
|
||||
@@ -51,7 +52,7 @@ export const contestsApi = {
|
||||
const response = await apiClient.post<ContestResult>(`/cabinet/contests/${roundId}/answer`, {
|
||||
round_id: roundId,
|
||||
answer,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
// Currency exchange rate API
|
||||
// Uses free exchangerate.host API
|
||||
|
||||
import axios from 'axios'
|
||||
import axios from 'axios';
|
||||
|
||||
interface ExchangeRates {
|
||||
USD: number
|
||||
CNY: number
|
||||
IRR: number
|
||||
USD: number;
|
||||
CNY: number;
|
||||
IRR: number;
|
||||
}
|
||||
|
||||
interface CachedRates {
|
||||
rates: ExchangeRates
|
||||
timestamp: number
|
||||
rates: ExchangeRates;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const CACHE_DURATION = 60 * 60 * 1000 // 1 hour in milliseconds
|
||||
const CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds
|
||||
|
||||
// Fallback rates if API fails (approximate)
|
||||
const FALLBACK_RATES: ExchangeRates = {
|
||||
USD: 100, // 1 USD = 100 RUB
|
||||
CNY: 14, // 1 CNY = 14 RUB
|
||||
IRR: 0.0024, // 1 IRR = 0.0024 RUB (or 1 RUB = ~420 IRR)
|
||||
}
|
||||
USD: 100, // 1 USD = 100 RUB
|
||||
CNY: 14, // 1 CNY = 14 RUB
|
||||
IRR: 0.0024, // 1 IRR = 0.0024 RUB (or 1 RUB = ~420 IRR)
|
||||
};
|
||||
|
||||
let cachedRates: CachedRates | null = null
|
||||
let cachedRates: CachedRates | null = null;
|
||||
|
||||
export const currencyApi = {
|
||||
// Get all exchange rates (RUB to other currencies)
|
||||
getExchangeRates: async (): Promise<ExchangeRates> => {
|
||||
// Check cache first
|
||||
if (cachedRates && Date.now() - cachedRates.timestamp < CACHE_DURATION) {
|
||||
return cachedRates.rates
|
||||
return cachedRates.rates;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try primary API (exchangerate.host) - get RUB based rates
|
||||
const response = await axios.get<{
|
||||
success?: boolean
|
||||
rates?: { USD?: number; CNY?: number; IRR?: number }
|
||||
success?: boolean;
|
||||
rates?: { USD?: number; CNY?: number; IRR?: number };
|
||||
}>('https://api.exchangerate.host/latest', {
|
||||
params: { base: 'RUB', symbols: 'USD,CNY,IRR' },
|
||||
})
|
||||
});
|
||||
|
||||
if (response.data.success && response.data.rates) {
|
||||
// API returns how much of each currency equals 1 RUB
|
||||
@@ -49,51 +49,65 @@ export const currencyApi = {
|
||||
USD: response.data.rates.USD ? 1 / response.data.rates.USD : FALLBACK_RATES.USD,
|
||||
CNY: response.data.rates.CNY ? 1 / response.data.rates.CNY : FALLBACK_RATES.CNY,
|
||||
IRR: response.data.rates.IRR ? 1 / response.data.rates.IRR : FALLBACK_RATES.IRR,
|
||||
}
|
||||
cachedRates = { rates, timestamp: Date.now() }
|
||||
return rates
|
||||
};
|
||||
cachedRates = { rates, timestamp: Date.now() };
|
||||
return rates;
|
||||
}
|
||||
|
||||
// Try backup API (open.er-api.com)
|
||||
const backupResponse = await axios.get<{
|
||||
rates?: { USD?: number; CNY?: number; IRR?: number }
|
||||
}>('https://open.er-api.com/v6/latest/RUB')
|
||||
rates?: { USD?: number; CNY?: number; IRR?: number };
|
||||
}>('https://open.er-api.com/v6/latest/RUB');
|
||||
|
||||
if (backupResponse.data.rates) {
|
||||
const rates: ExchangeRates = {
|
||||
USD: backupResponse.data.rates.USD ? 1 / backupResponse.data.rates.USD : FALLBACK_RATES.USD,
|
||||
CNY: backupResponse.data.rates.CNY ? 1 / backupResponse.data.rates.CNY : FALLBACK_RATES.CNY,
|
||||
IRR: backupResponse.data.rates.IRR ? 1 / backupResponse.data.rates.IRR : FALLBACK_RATES.IRR,
|
||||
}
|
||||
cachedRates = { rates, timestamp: Date.now() }
|
||||
return rates
|
||||
USD: backupResponse.data.rates.USD
|
||||
? 1 / backupResponse.data.rates.USD
|
||||
: FALLBACK_RATES.USD,
|
||||
CNY: backupResponse.data.rates.CNY
|
||||
? 1 / backupResponse.data.rates.CNY
|
||||
: FALLBACK_RATES.CNY,
|
||||
IRR: backupResponse.data.rates.IRR
|
||||
? 1 / backupResponse.data.rates.IRR
|
||||
: FALLBACK_RATES.IRR,
|
||||
};
|
||||
cachedRates = { rates, timestamp: Date.now() };
|
||||
return rates;
|
||||
}
|
||||
|
||||
// Return fallback rates if both APIs fail
|
||||
return FALLBACK_RATES
|
||||
return FALLBACK_RATES;
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch exchange rates, using fallback:', error)
|
||||
return FALLBACK_RATES
|
||||
console.warn('Failed to fetch exchange rates, using fallback:', error);
|
||||
return FALLBACK_RATES;
|
||||
}
|
||||
},
|
||||
|
||||
// Convert RUB to target currency
|
||||
convertFromRub: (rubAmount: number, targetCurrency: keyof ExchangeRates, rates: ExchangeRates): number => {
|
||||
const rate = rates[targetCurrency]
|
||||
convertFromRub: (
|
||||
rubAmount: number,
|
||||
targetCurrency: keyof ExchangeRates,
|
||||
rates: ExchangeRates,
|
||||
): number => {
|
||||
const rate = rates[targetCurrency];
|
||||
if (!rate || rate <= 0) {
|
||||
return rubAmount / FALLBACK_RATES[targetCurrency]
|
||||
return rubAmount / FALLBACK_RATES[targetCurrency];
|
||||
}
|
||||
return rubAmount / rate
|
||||
return rubAmount / rate;
|
||||
},
|
||||
|
||||
// Convert from target currency to RUB
|
||||
convertToRub: (amount: number, sourceCurrency: keyof ExchangeRates, rates: ExchangeRates): number => {
|
||||
const rate = rates[sourceCurrency]
|
||||
convertToRub: (
|
||||
amount: number,
|
||||
sourceCurrency: keyof ExchangeRates,
|
||||
rates: ExchangeRates,
|
||||
): number => {
|
||||
const rate = rates[sourceCurrency];
|
||||
if (!rate || rate <= 0) {
|
||||
return amount * FALLBACK_RATES[sourceCurrency]
|
||||
return amount * FALLBACK_RATES[sourceCurrency];
|
||||
}
|
||||
return amount * rate
|
||||
return amount * rate;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export type { ExchangeRates }
|
||||
export type { ExchangeRates };
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
export { apiClient } from './client'
|
||||
export { authApi } from './auth'
|
||||
export { subscriptionApi } from './subscription'
|
||||
export { balanceApi } from './balance'
|
||||
export { referralApi } from './referral'
|
||||
export { ticketsApi } from './tickets'
|
||||
export { contestsApi } from './contests'
|
||||
export { pollsApi } from './polls'
|
||||
export { promoApi } from './promo'
|
||||
export { notificationsApi } from './notifications'
|
||||
export { infoApi } from './info'
|
||||
export { adminSettingsApi } from './adminSettings'
|
||||
export { apiClient } from './client';
|
||||
export { authApi } from './auth';
|
||||
export { subscriptionApi } from './subscription';
|
||||
export { balanceApi } from './balance';
|
||||
export { referralApi } from './referral';
|
||||
export { ticketsApi } from './tickets';
|
||||
export { contestsApi } from './contests';
|
||||
export { pollsApi } from './polls';
|
||||
export { promoApi } from './promo';
|
||||
export { notificationsApi } from './notifications';
|
||||
export { infoApi } from './info';
|
||||
export { adminSettingsApi } from './adminSettings';
|
||||
|
||||
@@ -1,102 +1,102 @@
|
||||
import apiClient from './client'
|
||||
import type { SupportConfig } from '../types'
|
||||
import apiClient from './client';
|
||||
import type { SupportConfig } from '../types';
|
||||
|
||||
export interface FaqPage {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
order: number
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface RulesResponse {
|
||||
content: string
|
||||
updated_at: string | null
|
||||
content: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface PrivacyPolicyResponse {
|
||||
content: string
|
||||
updated_at: string | null
|
||||
content: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface PublicOfferResponse {
|
||||
content: string
|
||||
updated_at: string | null
|
||||
content: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface ServiceInfo {
|
||||
name: string
|
||||
description: string | null
|
||||
support_email: string | null
|
||||
support_telegram: string | null
|
||||
website: string | null
|
||||
name: string;
|
||||
description: string | null;
|
||||
support_email: string | null;
|
||||
support_telegram: string | null;
|
||||
website: string | null;
|
||||
}
|
||||
|
||||
export interface LanguageInfo {
|
||||
code: string
|
||||
name: string
|
||||
flag: string
|
||||
code: string;
|
||||
name: string;
|
||||
flag: string;
|
||||
}
|
||||
|
||||
export const infoApi = {
|
||||
// Get FAQ pages list
|
||||
getFaqPages: async (): Promise<FaqPage[]> => {
|
||||
const response = await apiClient.get<FaqPage[]>('/cabinet/info/faq')
|
||||
return response.data
|
||||
const response = await apiClient.get<FaqPage[]>('/cabinet/info/faq');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get specific FAQ page
|
||||
getFaqPage: async (pageId: number): Promise<FaqPage> => {
|
||||
const response = await apiClient.get<FaqPage>(`/cabinet/info/faq/${pageId}`)
|
||||
return response.data
|
||||
const response = await apiClient.get<FaqPage>(`/cabinet/info/faq/${pageId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get service rules
|
||||
getRules: async (): Promise<RulesResponse> => {
|
||||
const response = await apiClient.get<RulesResponse>('/cabinet/info/rules')
|
||||
return response.data
|
||||
const response = await apiClient.get<RulesResponse>('/cabinet/info/rules');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get privacy policy
|
||||
getPrivacyPolicy: async (): Promise<PrivacyPolicyResponse> => {
|
||||
const response = await apiClient.get<PrivacyPolicyResponse>('/cabinet/info/privacy-policy')
|
||||
return response.data
|
||||
const response = await apiClient.get<PrivacyPolicyResponse>('/cabinet/info/privacy-policy');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get public offer
|
||||
getPublicOffer: async (): Promise<PublicOfferResponse> => {
|
||||
const response = await apiClient.get<PublicOfferResponse>('/cabinet/info/public-offer')
|
||||
return response.data
|
||||
const response = await apiClient.get<PublicOfferResponse>('/cabinet/info/public-offer');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get service info
|
||||
getServiceInfo: async (): Promise<ServiceInfo> => {
|
||||
const response = await apiClient.get<ServiceInfo>('/cabinet/info/service')
|
||||
return response.data
|
||||
const response = await apiClient.get<ServiceInfo>('/cabinet/info/service');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get available languages
|
||||
getLanguages: async (): Promise<{ languages: LanguageInfo[]; default: string }> => {
|
||||
const response = await apiClient.get('/cabinet/info/languages')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/info/languages');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get user language
|
||||
getUserLanguage: async (): Promise<{ language: string }> => {
|
||||
const response = await apiClient.get<{ language: string }>('/cabinet/info/user/language')
|
||||
return response.data
|
||||
const response = await apiClient.get<{ language: string }>('/cabinet/info/user/language');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update user language
|
||||
updateUserLanguage: async (language: string): Promise<{ language: string }> => {
|
||||
const response = await apiClient.patch<{ language: string }>('/cabinet/info/user/language', {
|
||||
language,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get support configuration
|
||||
getSupportConfig: async (): Promise<SupportConfig> => {
|
||||
const response = await apiClient.get<SupportConfig>('/cabinet/info/support-config')
|
||||
return response.data
|
||||
const response = await apiClient.get<SupportConfig>('/cabinet/info/support-config');
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import axios, { AxiosError } from 'axios'
|
||||
import axios, { AxiosError } from 'axios';
|
||||
|
||||
export interface MiniappCreatePaymentPayload {
|
||||
initData: string
|
||||
method: string
|
||||
amountKopeks?: number | null
|
||||
option?: string | null
|
||||
initData: string;
|
||||
method: string;
|
||||
amountKopeks?: number | null;
|
||||
option?: string | null;
|
||||
}
|
||||
|
||||
export interface MiniappCreatePaymentResponse {
|
||||
payment_url: string
|
||||
amount_kopeks?: number
|
||||
extra?: Record<string, unknown>
|
||||
payment_url: string;
|
||||
amount_kopeks?: number;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const miniappApi = {
|
||||
// Create payment inside Telegram Mini App (same flow as miniapp/index.html)
|
||||
createPayment: async (
|
||||
payload: MiniappCreatePaymentPayload
|
||||
payload: MiniappCreatePaymentPayload,
|
||||
): Promise<MiniappCreatePaymentResponse> => {
|
||||
try {
|
||||
const response = await axios.post<MiniappCreatePaymentResponse>(
|
||||
@@ -29,15 +29,16 @@ export const miniappApi = {
|
||||
},
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ detail?: string; message?: string }>
|
||||
const message = axiosError.response?.data?.detail
|
||||
|| axiosError.response?.data?.message
|
||||
|| 'Failed to create payment'
|
||||
throw new Error(message)
|
||||
const axiosError = error as AxiosError<{ detail?: string; message?: string }>;
|
||||
const message =
|
||||
axiosError.response?.data?.detail ||
|
||||
axiosError.response?.data?.message ||
|
||||
'Failed to create payment';
|
||||
throw new Error(message);
|
||||
}
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,58 +1,64 @@
|
||||
import apiClient from './client'
|
||||
import apiClient from './client';
|
||||
|
||||
export interface NotificationSettings {
|
||||
subscription_expiry_enabled: boolean
|
||||
subscription_expiry_days: number
|
||||
traffic_warning_enabled: boolean
|
||||
traffic_warning_percent: number
|
||||
balance_low_enabled: boolean
|
||||
balance_low_threshold: number
|
||||
news_enabled: boolean
|
||||
promo_offers_enabled: boolean
|
||||
subscription_expiry_enabled: boolean;
|
||||
subscription_expiry_days: number;
|
||||
traffic_warning_enabled: boolean;
|
||||
traffic_warning_percent: number;
|
||||
balance_low_enabled: boolean;
|
||||
balance_low_threshold: number;
|
||||
news_enabled: boolean;
|
||||
promo_offers_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface NotificationSettingsUpdate {
|
||||
subscription_expiry_enabled?: boolean
|
||||
subscription_expiry_days?: number
|
||||
traffic_warning_enabled?: boolean
|
||||
traffic_warning_percent?: number
|
||||
balance_low_enabled?: boolean
|
||||
balance_low_threshold?: number
|
||||
news_enabled?: boolean
|
||||
promo_offers_enabled?: boolean
|
||||
subscription_expiry_enabled?: boolean;
|
||||
subscription_expiry_days?: number;
|
||||
traffic_warning_enabled?: boolean;
|
||||
traffic_warning_percent?: number;
|
||||
balance_low_enabled?: boolean;
|
||||
balance_low_threshold?: number;
|
||||
news_enabled?: boolean;
|
||||
promo_offers_enabled?: boolean;
|
||||
}
|
||||
|
||||
export const notificationsApi = {
|
||||
// Get notification settings
|
||||
getSettings: async (): Promise<NotificationSettings> => {
|
||||
const response = await apiClient.get<NotificationSettings>('/cabinet/notifications')
|
||||
return response.data
|
||||
const response = await apiClient.get<NotificationSettings>('/cabinet/notifications');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update notification settings
|
||||
updateSettings: async (settings: NotificationSettingsUpdate): Promise<NotificationSettings> => {
|
||||
const response = await apiClient.patch<NotificationSettings>('/cabinet/notifications', settings)
|
||||
return response.data
|
||||
const response = await apiClient.patch<NotificationSettings>(
|
||||
'/cabinet/notifications',
|
||||
settings,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Send test notification
|
||||
sendTestNotification: async (): Promise<{ success: boolean; message: string }> => {
|
||||
const response = await apiClient.post<{ success: boolean; message: string }>(
|
||||
'/cabinet/notifications/test'
|
||||
)
|
||||
return response.data
|
||||
'/cabinet/notifications/test',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get notification history
|
||||
getHistory: async (limit = 20, offset = 0): Promise<{
|
||||
notifications: any[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
getHistory: async (
|
||||
limit = 20,
|
||||
offset = 0,
|
||||
): Promise<{
|
||||
notifications: unknown[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}> => {
|
||||
const response = await apiClient.get('/cabinet/notifications/history', {
|
||||
params: { limit, offset },
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,85 +1,85 @@
|
||||
import apiClient from './client'
|
||||
import apiClient from './client';
|
||||
|
||||
export interface PollOption {
|
||||
id: number
|
||||
text: string
|
||||
order: number
|
||||
id: number;
|
||||
text: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface PollQuestion {
|
||||
id: number
|
||||
text: string
|
||||
order: number
|
||||
options: PollOption[]
|
||||
id: number;
|
||||
text: string;
|
||||
order: number;
|
||||
options: PollOption[];
|
||||
}
|
||||
|
||||
export interface PollInfo {
|
||||
id: number
|
||||
response_id: number
|
||||
title: string
|
||||
description: string | null
|
||||
total_questions: number
|
||||
answered_questions: number
|
||||
is_completed: boolean
|
||||
reward_amount: number | null
|
||||
id: number;
|
||||
response_id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
total_questions: number;
|
||||
answered_questions: number;
|
||||
is_completed: boolean;
|
||||
reward_amount: number | null;
|
||||
}
|
||||
|
||||
export interface PollStartResponse {
|
||||
response_id: number
|
||||
current_question_index: number
|
||||
total_questions: number
|
||||
question: PollQuestion
|
||||
response_id: number;
|
||||
current_question_index: number;
|
||||
total_questions: number;
|
||||
question: PollQuestion;
|
||||
}
|
||||
|
||||
export interface PollAnswerResponse {
|
||||
success: boolean
|
||||
is_completed: boolean
|
||||
next_question: PollQuestion | null
|
||||
current_question_index: number | null
|
||||
total_questions: number
|
||||
reward_granted: number | null
|
||||
message: string | null
|
||||
success: boolean;
|
||||
is_completed: boolean;
|
||||
next_question: PollQuestion | null;
|
||||
current_question_index: number | null;
|
||||
total_questions: number;
|
||||
reward_granted: number | null;
|
||||
message: string | null;
|
||||
}
|
||||
|
||||
export interface PollsCountResponse {
|
||||
count: number
|
||||
count: number;
|
||||
}
|
||||
|
||||
export const pollsApi = {
|
||||
// Get count of available polls
|
||||
getCount: async (): Promise<PollsCountResponse> => {
|
||||
const response = await apiClient.get<PollsCountResponse>('/cabinet/polls/count')
|
||||
return response.data
|
||||
const response = await apiClient.get<PollsCountResponse>('/cabinet/polls/count');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get available polls
|
||||
getPolls: async (): Promise<PollInfo[]> => {
|
||||
const response = await apiClient.get<PollInfo[]>('/cabinet/polls')
|
||||
return response.data
|
||||
const response = await apiClient.get<PollInfo[]>('/cabinet/polls');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get poll details
|
||||
getPollDetails: async (responseId: number): Promise<PollInfo> => {
|
||||
const response = await apiClient.get<PollInfo>(`/cabinet/polls/${responseId}`)
|
||||
return response.data
|
||||
const response = await apiClient.get<PollInfo>(`/cabinet/polls/${responseId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Start or continue poll
|
||||
startPoll: async (responseId: number): Promise<PollStartResponse> => {
|
||||
const response = await apiClient.post<PollStartResponse>(`/cabinet/polls/${responseId}/start`)
|
||||
return response.data
|
||||
const response = await apiClient.post<PollStartResponse>(`/cabinet/polls/${responseId}/start`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Answer a question
|
||||
answerQuestion: async (
|
||||
responseId: number,
|
||||
questionId: number,
|
||||
optionId: number
|
||||
optionId: number,
|
||||
): Promise<PollAnswerResponse> => {
|
||||
const response = await apiClient.post<PollAnswerResponse>(
|
||||
`/cabinet/polls/${responseId}/questions/${questionId}/answer`,
|
||||
{ option_id: optionId }
|
||||
)
|
||||
return response.data
|
||||
{ option_id: optionId },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
id: number
|
||||
notification_type: string
|
||||
discount_percent: number | null
|
||||
effect_type: string
|
||||
expires_at: string
|
||||
is_active: boolean
|
||||
is_claimed: boolean
|
||||
claimed_at: string | null
|
||||
extra_data: Record<string, any> | null
|
||||
id: number;
|
||||
notification_type: string;
|
||||
discount_percent: number | null;
|
||||
effect_type: string;
|
||||
expires_at: string;
|
||||
is_active: boolean;
|
||||
is_claimed: boolean;
|
||||
claimed_at: string | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
extra_data: Record<string, any> | null;
|
||||
}
|
||||
|
||||
export interface ActiveDiscount {
|
||||
discount_percent: number
|
||||
source: string | null
|
||||
expires_at: string | null
|
||||
is_active: boolean
|
||||
discount_percent: number;
|
||||
source: string | null;
|
||||
expires_at: string | null;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface PromoGroupDiscounts {
|
||||
group_name: string | null
|
||||
server_discount_percent: number
|
||||
traffic_discount_percent: number
|
||||
device_discount_percent: number
|
||||
period_discounts: Record<string, number>
|
||||
group_name: string | null;
|
||||
server_discount_percent: number;
|
||||
traffic_discount_percent: number;
|
||||
device_discount_percent: number;
|
||||
period_discounts: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface ClaimOfferResponse {
|
||||
success: boolean
|
||||
message: string
|
||||
discount_percent: number | null
|
||||
expires_at: string | null
|
||||
success: boolean;
|
||||
message: string;
|
||||
discount_percent: number | null;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export interface LoyaltyTierInfo {
|
||||
id: number
|
||||
name: string
|
||||
threshold_rubles: number
|
||||
server_discount_percent: number
|
||||
traffic_discount_percent: number
|
||||
device_discount_percent: number
|
||||
period_discounts: Record<string, number>
|
||||
is_current: boolean
|
||||
is_achieved: boolean
|
||||
id: number;
|
||||
name: string;
|
||||
threshold_rubles: number;
|
||||
server_discount_percent: number;
|
||||
traffic_discount_percent: number;
|
||||
device_discount_percent: number;
|
||||
period_discounts: Record<string, number>;
|
||||
is_current: boolean;
|
||||
is_achieved: boolean;
|
||||
}
|
||||
|
||||
export interface LoyaltyTiersResponse {
|
||||
tiers: LoyaltyTierInfo[]
|
||||
current_spent_rubles: number
|
||||
current_tier_name: string | null
|
||||
next_tier_name: string | null
|
||||
next_tier_threshold_rubles: number | null
|
||||
progress_percent: number
|
||||
tiers: LoyaltyTierInfo[];
|
||||
current_spent_rubles: number;
|
||||
current_tier_name: string | null;
|
||||
next_tier_name: string | null;
|
||||
next_tier_threshold_rubles: number | null;
|
||||
progress_percent: number;
|
||||
}
|
||||
|
||||
export const promoApi = {
|
||||
// Get available promo offers
|
||||
getOffers: async (): Promise<PromoOffer[]> => {
|
||||
const response = await apiClient.get<PromoOffer[]>('/cabinet/promo/offers')
|
||||
return response.data
|
||||
const response = await apiClient.get<PromoOffer[]>('/cabinet/promo/offers');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get active discount
|
||||
getActiveDiscount: async (): Promise<ActiveDiscount> => {
|
||||
const response = await apiClient.get<ActiveDiscount>('/cabinet/promo/active-discount')
|
||||
return response.data
|
||||
const response = await apiClient.get<ActiveDiscount>('/cabinet/promo/active-discount');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get promo group discounts
|
||||
getGroupDiscounts: async (): Promise<PromoGroupDiscounts> => {
|
||||
const response = await apiClient.get<PromoGroupDiscounts>('/cabinet/promo/group-discounts')
|
||||
return response.data
|
||||
const response = await apiClient.get<PromoGroupDiscounts>('/cabinet/promo/group-discounts');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get loyalty tiers (promo groups with spending thresholds)
|
||||
getLoyaltyTiers: async (): Promise<LoyaltyTiersResponse> => {
|
||||
const response = await apiClient.get<LoyaltyTiersResponse>('/cabinet/promo/loyalty-tiers')
|
||||
return response.data
|
||||
const response = await apiClient.get<LoyaltyTiersResponse>('/cabinet/promo/loyalty-tiers');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Claim a promo offer
|
||||
claimOffer: async (offerId: number): Promise<ClaimOfferResponse> => {
|
||||
const response = await apiClient.post<ClaimOfferResponse>('/cabinet/promo/claim', {
|
||||
offer_id: offerId,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Clear active discount
|
||||
clearActiveDiscount: async (): Promise<{ message: string }> => {
|
||||
const response = await apiClient.delete<{ message: string }>('/cabinet/promo/active-discount')
|
||||
return response.data
|
||||
const response = await apiClient.delete<{ message: string }>('/cabinet/promo/active-discount');
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,139 +1,140 @@
|
||||
import apiClient from './client'
|
||||
import apiClient from './client';
|
||||
|
||||
// ============== Types ==============
|
||||
|
||||
export interface PromoOfferUserInfo {
|
||||
id: number
|
||||
telegram_id: number
|
||||
username: string | null
|
||||
first_name: string | null
|
||||
last_name: string | null
|
||||
full_name: string | null
|
||||
id: number;
|
||||
telegram_id: number;
|
||||
username: string | null;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
full_name: string | null;
|
||||
}
|
||||
|
||||
export interface PromoOfferSubscriptionInfo {
|
||||
id: number
|
||||
status: string
|
||||
is_trial: boolean
|
||||
start_date: string
|
||||
end_date: string
|
||||
autopay_enabled: boolean
|
||||
id: number;
|
||||
status: string;
|
||||
is_trial: boolean;
|
||||
start_date: string;
|
||||
end_date: string;
|
||||
autopay_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface PromoOffer {
|
||||
id: number
|
||||
user_id: number
|
||||
subscription_id: number | null
|
||||
notification_type: string
|
||||
discount_percent: number
|
||||
bonus_amount_kopeks: number
|
||||
expires_at: string
|
||||
claimed_at: string | null
|
||||
is_active: boolean
|
||||
effect_type: string
|
||||
extra_data: Record<string, any>
|
||||
created_at: string
|
||||
updated_at: string
|
||||
user: PromoOfferUserInfo | null
|
||||
subscription: PromoOfferSubscriptionInfo | null
|
||||
id: number;
|
||||
user_id: number;
|
||||
subscription_id: number | null;
|
||||
notification_type: string;
|
||||
discount_percent: number;
|
||||
bonus_amount_kopeks: number;
|
||||
expires_at: string;
|
||||
claimed_at: string | null;
|
||||
is_active: boolean;
|
||||
effect_type: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
extra_data: Record<string, any>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
user: PromoOfferUserInfo | null;
|
||||
subscription: PromoOfferSubscriptionInfo | null;
|
||||
}
|
||||
|
||||
export interface PromoOfferListResponse {
|
||||
items: PromoOffer[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
items: PromoOffer[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface PromoOfferBroadcastRequest {
|
||||
notification_type: string
|
||||
valid_hours: number
|
||||
discount_percent?: number
|
||||
bonus_amount_kopeks?: number
|
||||
effect_type?: string
|
||||
extra_data?: Record<string, any>
|
||||
target?: string
|
||||
user_id?: number
|
||||
telegram_id?: number
|
||||
notification_type: string;
|
||||
valid_hours: number;
|
||||
discount_percent?: number;
|
||||
bonus_amount_kopeks?: number;
|
||||
effect_type?: string;
|
||||
extra_data?: Record<string, unknown>;
|
||||
target?: string;
|
||||
user_id?: number;
|
||||
telegram_id?: number;
|
||||
// Telegram notification options
|
||||
send_notification?: boolean
|
||||
message_text?: string
|
||||
button_text?: string
|
||||
send_notification?: boolean;
|
||||
message_text?: string;
|
||||
button_text?: string;
|
||||
}
|
||||
|
||||
export interface PromoOfferBroadcastResponse {
|
||||
created_offers: number
|
||||
user_ids: number[]
|
||||
target: string | null
|
||||
notifications_sent: number
|
||||
notifications_failed: number
|
||||
created_offers: number;
|
||||
user_ids: number[];
|
||||
target: string | null;
|
||||
notifications_sent: number;
|
||||
notifications_failed: number;
|
||||
}
|
||||
|
||||
export interface PromoOfferTemplate {
|
||||
id: number
|
||||
name: string
|
||||
offer_type: string
|
||||
message_text: string
|
||||
button_text: string
|
||||
valid_hours: number
|
||||
discount_percent: number
|
||||
bonus_amount_kopeks: number
|
||||
active_discount_hours: number | null
|
||||
test_duration_hours: number | null
|
||||
test_squad_uuids: string[]
|
||||
is_active: boolean
|
||||
created_by: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
id: number;
|
||||
name: string;
|
||||
offer_type: string;
|
||||
message_text: string;
|
||||
button_text: string;
|
||||
valid_hours: number;
|
||||
discount_percent: number;
|
||||
bonus_amount_kopeks: number;
|
||||
active_discount_hours: number | null;
|
||||
test_duration_hours: number | null;
|
||||
test_squad_uuids: string[];
|
||||
is_active: boolean;
|
||||
created_by: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PromoOfferTemplateListResponse {
|
||||
items: PromoOfferTemplate[]
|
||||
items: PromoOfferTemplate[];
|
||||
}
|
||||
|
||||
export interface PromoOfferTemplateUpdateRequest {
|
||||
name?: string
|
||||
message_text?: string
|
||||
button_text?: string
|
||||
valid_hours?: number
|
||||
discount_percent?: number
|
||||
bonus_amount_kopeks?: number
|
||||
active_discount_hours?: number
|
||||
test_duration_hours?: number
|
||||
test_squad_uuids?: string[]
|
||||
is_active?: boolean
|
||||
name?: string;
|
||||
message_text?: string;
|
||||
button_text?: string;
|
||||
valid_hours?: number;
|
||||
discount_percent?: number;
|
||||
bonus_amount_kopeks?: number;
|
||||
active_discount_hours?: number;
|
||||
test_duration_hours?: number;
|
||||
test_squad_uuids?: string[];
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface PromoOfferLogOfferInfo {
|
||||
id: number
|
||||
notification_type: string | null
|
||||
discount_percent: number | null
|
||||
bonus_amount_kopeks: number | null
|
||||
effect_type: string | null
|
||||
expires_at: string | null
|
||||
claimed_at: string | null
|
||||
is_active: boolean | null
|
||||
id: number;
|
||||
notification_type: string | null;
|
||||
discount_percent: number | null;
|
||||
bonus_amount_kopeks: number | null;
|
||||
effect_type: string | null;
|
||||
expires_at: string | null;
|
||||
claimed_at: string | null;
|
||||
is_active: boolean | null;
|
||||
}
|
||||
|
||||
export interface PromoOfferLog {
|
||||
id: number
|
||||
user_id: number | null
|
||||
offer_id: number | null
|
||||
action: string
|
||||
source: string | null
|
||||
percent: number | null
|
||||
effect_type: string | null
|
||||
details: Record<string, any>
|
||||
created_at: string
|
||||
user: PromoOfferUserInfo | null
|
||||
offer: PromoOfferLogOfferInfo | null
|
||||
id: number;
|
||||
user_id: number | null;
|
||||
offer_id: number | null;
|
||||
action: string;
|
||||
source: string | null;
|
||||
percent: number | null;
|
||||
effect_type: string | null;
|
||||
details: Record<string, unknown>;
|
||||
created_at: string;
|
||||
user: PromoOfferUserInfo | null;
|
||||
offer: PromoOfferLogOfferInfo | null;
|
||||
}
|
||||
|
||||
export interface PromoOfferLogListResponse {
|
||||
items: PromoOfferLog[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
items: PromoOfferLog[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
// Target segments for broadcast
|
||||
@@ -154,9 +155,9 @@ export const TARGET_SEGMENTS = {
|
||||
custom_week: 'Зарегистрированы за неделю',
|
||||
custom_month: 'Зарегистрированы за месяц',
|
||||
custom_active_today: 'Активны сегодня',
|
||||
} as const
|
||||
} as const;
|
||||
|
||||
export type TargetSegment = keyof typeof TARGET_SEGMENTS
|
||||
export type TargetSegment = keyof typeof TARGET_SEGMENTS;
|
||||
|
||||
// Offer type configurations
|
||||
export const OFFER_TYPE_CONFIG = {
|
||||
@@ -178,56 +179,61 @@ export const OFFER_TYPE_CONFIG = {
|
||||
effect: 'percent_discount',
|
||||
description: 'Скидка для новых пользователей',
|
||||
},
|
||||
} as const
|
||||
} as const;
|
||||
|
||||
export type OfferType = keyof typeof OFFER_TYPE_CONFIG
|
||||
export type OfferType = keyof typeof OFFER_TYPE_CONFIG;
|
||||
|
||||
// ============== API ==============
|
||||
|
||||
export const promoOffersApi = {
|
||||
// Get list of promo offers
|
||||
getOffers: async (params?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
user_id?: number
|
||||
is_active?: boolean
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
user_id?: number;
|
||||
is_active?: boolean;
|
||||
}): Promise<PromoOfferListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/promo-offers', { params })
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/promo-offers', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Broadcast offer to multiple users
|
||||
broadcastOffer: async (data: PromoOfferBroadcastRequest): Promise<PromoOfferBroadcastResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/promo-offers/broadcast', data)
|
||||
return response.data
|
||||
broadcastOffer: async (
|
||||
data: PromoOfferBroadcastRequest,
|
||||
): Promise<PromoOfferBroadcastResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/promo-offers/broadcast', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get promo offer logs
|
||||
getLogs: async (params?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
user_id?: number
|
||||
action?: string
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
user_id?: number;
|
||||
action?: string;
|
||||
}): Promise<PromoOfferLogListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/promo-offers/logs', { params })
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/promo-offers/logs', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get all templates
|
||||
getTemplates: async (): Promise<PromoOfferTemplateListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/promo-offers/templates')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/promo-offers/templates');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get single template
|
||||
getTemplate: async (id: number): Promise<PromoOfferTemplate> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/promo-offers/templates/${id}`)
|
||||
return response.data
|
||||
const response = await apiClient.get(`/cabinet/admin/promo-offers/templates/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update template
|
||||
updateTemplate: async (id: number, data: PromoOfferTemplateUpdateRequest): Promise<PromoOfferTemplate> => {
|
||||
const response = await apiClient.patch(`/cabinet/admin/promo-offers/templates/${id}`, data)
|
||||
return response.data
|
||||
updateTemplate: async (
|
||||
id: number,
|
||||
data: PromoOfferTemplateUpdateRequest,
|
||||
): Promise<PromoOfferTemplate> => {
|
||||
const response = await apiClient.patch(`/cabinet/admin/promo-offers/templates/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,122 +1,127 @@
|
||||
import apiClient from './client'
|
||||
import apiClient from './client';
|
||||
|
||||
// ============== Types ==============
|
||||
|
||||
export type PromoCodeType = 'balance' | 'subscription_days' | 'trial_subscription' | 'promo_group' | 'discount'
|
||||
export type PromoCodeType =
|
||||
| 'balance'
|
||||
| 'subscription_days'
|
||||
| 'trial_subscription'
|
||||
| 'promo_group'
|
||||
| 'discount';
|
||||
|
||||
export interface PromoCode {
|
||||
id: number
|
||||
code: string
|
||||
type: PromoCodeType
|
||||
balance_bonus_kopeks: number
|
||||
balance_bonus_rubles: number
|
||||
subscription_days: number
|
||||
max_uses: number
|
||||
current_uses: number
|
||||
uses_left: number
|
||||
is_active: boolean
|
||||
is_valid: boolean
|
||||
first_purchase_only: boolean
|
||||
valid_from: string
|
||||
valid_until: string | null
|
||||
promo_group_id: number | null
|
||||
created_by: number | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
id: number;
|
||||
code: string;
|
||||
type: PromoCodeType;
|
||||
balance_bonus_kopeks: number;
|
||||
balance_bonus_rubles: number;
|
||||
subscription_days: number;
|
||||
max_uses: number;
|
||||
current_uses: number;
|
||||
uses_left: number;
|
||||
is_active: boolean;
|
||||
is_valid: boolean;
|
||||
first_purchase_only: boolean;
|
||||
valid_from: string;
|
||||
valid_until: string | null;
|
||||
promo_group_id: number | null;
|
||||
created_by: number | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PromoCodeRecentUse {
|
||||
id: number
|
||||
user_id: number
|
||||
user_username: string | null
|
||||
user_full_name: string | null
|
||||
user_telegram_id: number | null
|
||||
used_at: string
|
||||
id: number;
|
||||
user_id: number;
|
||||
user_username: string | null;
|
||||
user_full_name: string | null;
|
||||
user_telegram_id: number | null;
|
||||
used_at: string;
|
||||
}
|
||||
|
||||
export interface PromoCodeDetail extends PromoCode {
|
||||
total_uses: number
|
||||
today_uses: number
|
||||
recent_uses: PromoCodeRecentUse[]
|
||||
total_uses: number;
|
||||
today_uses: number;
|
||||
recent_uses: PromoCodeRecentUse[];
|
||||
}
|
||||
|
||||
export interface PromoCodeListResponse {
|
||||
items: PromoCode[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
items: PromoCode[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface PromoCodeCreateRequest {
|
||||
code: string
|
||||
type: PromoCodeType
|
||||
balance_bonus_kopeks?: number
|
||||
subscription_days?: number
|
||||
max_uses?: number
|
||||
valid_from?: string
|
||||
valid_until?: string | null
|
||||
is_active?: boolean
|
||||
first_purchase_only?: boolean
|
||||
promo_group_id?: number | null
|
||||
code: string;
|
||||
type: PromoCodeType;
|
||||
balance_bonus_kopeks?: number;
|
||||
subscription_days?: number;
|
||||
max_uses?: number;
|
||||
valid_from?: string;
|
||||
valid_until?: string | null;
|
||||
is_active?: boolean;
|
||||
first_purchase_only?: boolean;
|
||||
promo_group_id?: number | null;
|
||||
}
|
||||
|
||||
export interface PromoCodeUpdateRequest {
|
||||
code?: string
|
||||
type?: PromoCodeType
|
||||
balance_bonus_kopeks?: number
|
||||
subscription_days?: number
|
||||
max_uses?: number
|
||||
valid_from?: string
|
||||
valid_until?: string | null
|
||||
is_active?: boolean
|
||||
first_purchase_only?: boolean
|
||||
promo_group_id?: number | null
|
||||
code?: string;
|
||||
type?: PromoCodeType;
|
||||
balance_bonus_kopeks?: number;
|
||||
subscription_days?: number;
|
||||
max_uses?: number;
|
||||
valid_from?: string;
|
||||
valid_until?: string | null;
|
||||
is_active?: boolean;
|
||||
first_purchase_only?: boolean;
|
||||
promo_group_id?: number | null;
|
||||
}
|
||||
|
||||
// ============== PromoGroup Types ==============
|
||||
|
||||
export interface PromoGroup {
|
||||
id: number
|
||||
name: string
|
||||
server_discount_percent: number
|
||||
traffic_discount_percent: number
|
||||
device_discount_percent: number
|
||||
period_discounts: Record<number, number>
|
||||
auto_assign_total_spent_kopeks: number | null
|
||||
apply_discounts_to_addons: boolean
|
||||
is_default: boolean
|
||||
members_count: number
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
id: number;
|
||||
name: string;
|
||||
server_discount_percent: number;
|
||||
traffic_discount_percent: number;
|
||||
device_discount_percent: number;
|
||||
period_discounts: Record<number, number>;
|
||||
auto_assign_total_spent_kopeks: number | null;
|
||||
apply_discounts_to_addons: boolean;
|
||||
is_default: boolean;
|
||||
members_count: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface PromoGroupListResponse {
|
||||
items: PromoGroup[]
|
||||
total: number
|
||||
limit: number
|
||||
offset: number
|
||||
items: PromoGroup[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface PromoGroupCreateRequest {
|
||||
name: string
|
||||
server_discount_percent?: number
|
||||
traffic_discount_percent?: number
|
||||
device_discount_percent?: number
|
||||
period_discounts?: Record<number, number>
|
||||
auto_assign_total_spent_kopeks?: number | null
|
||||
apply_discounts_to_addons?: boolean
|
||||
is_default?: boolean
|
||||
name: string;
|
||||
server_discount_percent?: number;
|
||||
traffic_discount_percent?: number;
|
||||
device_discount_percent?: number;
|
||||
period_discounts?: Record<number, number>;
|
||||
auto_assign_total_spent_kopeks?: number | null;
|
||||
apply_discounts_to_addons?: boolean;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
export interface PromoGroupUpdateRequest {
|
||||
name?: string
|
||||
server_discount_percent?: number
|
||||
traffic_discount_percent?: number
|
||||
device_discount_percent?: number
|
||||
period_discounts?: Record<number, number>
|
||||
auto_assign_total_spent_kopeks?: number | null
|
||||
apply_discounts_to_addons?: boolean
|
||||
is_default?: boolean
|
||||
name?: string;
|
||||
server_discount_percent?: number;
|
||||
traffic_discount_percent?: number;
|
||||
device_discount_percent?: number;
|
||||
period_discounts?: Record<number, number>;
|
||||
auto_assign_total_spent_kopeks?: number | null;
|
||||
apply_discounts_to_addons?: boolean;
|
||||
is_default?: boolean;
|
||||
}
|
||||
|
||||
// ============== API ==============
|
||||
@@ -124,58 +129,58 @@ export interface PromoGroupUpdateRequest {
|
||||
export const promocodesApi = {
|
||||
// Promocodes
|
||||
getPromocodes: async (params?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
is_active?: boolean
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
is_active?: boolean;
|
||||
}): Promise<PromoCodeListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/promocodes', { params })
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/promocodes', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getPromocode: async (id: number): Promise<PromoCodeDetail> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/promocodes/${id}`)
|
||||
return response.data
|
||||
const response = await apiClient.get(`/cabinet/admin/promocodes/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createPromocode: async (data: PromoCodeCreateRequest): Promise<PromoCode> => {
|
||||
const response = await apiClient.post('/cabinet/admin/promocodes', data)
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/admin/promocodes', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updatePromocode: async (id: number, data: PromoCodeUpdateRequest): Promise<PromoCode> => {
|
||||
const response = await apiClient.patch(`/cabinet/admin/promocodes/${id}`, data)
|
||||
return response.data
|
||||
const response = await apiClient.patch(`/cabinet/admin/promocodes/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deletePromocode: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(`/cabinet/admin/promocodes/${id}`)
|
||||
await apiClient.delete(`/cabinet/admin/promocodes/${id}`);
|
||||
},
|
||||
|
||||
// Promo Groups
|
||||
getPromoGroups: async (params?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<PromoGroupListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/promo-groups', { params })
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/promo-groups', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getPromoGroup: async (id: number): Promise<PromoGroup> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/promo-groups/${id}`)
|
||||
return response.data
|
||||
const response = await apiClient.get(`/cabinet/admin/promo-groups/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
createPromoGroup: async (data: PromoGroupCreateRequest): Promise<PromoGroup> => {
|
||||
const response = await apiClient.post('/cabinet/admin/promo-groups', data)
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/admin/promo-groups', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
updatePromoGroup: async (id: number, data: PromoGroupUpdateRequest): Promise<PromoGroup> => {
|
||||
const response = await apiClient.patch(`/cabinet/admin/promo-groups/${id}`, data)
|
||||
return response.data
|
||||
const response = await apiClient.patch(`/cabinet/admin/promo-groups/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
deletePromoGroup: async (id: number): Promise<void> => {
|
||||
await apiClient.delete(`/cabinet/admin/promo-groups/${id}`)
|
||||
await apiClient.delete(`/cabinet/admin/promo-groups/${id}`);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,62 +1,65 @@
|
||||
import apiClient from './client'
|
||||
import type { ReferralInfo, ReferralTerms, PaginatedResponse } from '../types'
|
||||
import apiClient from './client';
|
||||
import type { ReferralInfo, ReferralTerms, PaginatedResponse } from '../types';
|
||||
|
||||
interface ReferralItem {
|
||||
id: number
|
||||
username: string | null
|
||||
first_name: string | null
|
||||
created_at: string
|
||||
has_subscription: boolean
|
||||
has_paid: boolean
|
||||
id: number;
|
||||
username: string | null;
|
||||
first_name: string | null;
|
||||
created_at: string;
|
||||
has_subscription: boolean;
|
||||
has_paid: boolean;
|
||||
}
|
||||
|
||||
interface ReferralEarning {
|
||||
id: number
|
||||
amount_kopeks: number
|
||||
amount_rubles: number
|
||||
reason: string
|
||||
referral_username: string | null
|
||||
referral_first_name: string | null
|
||||
created_at: string
|
||||
id: number;
|
||||
amount_kopeks: number;
|
||||
amount_rubles: number;
|
||||
reason: string;
|
||||
referral_username: string | null;
|
||||
referral_first_name: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ReferralEarningsList extends PaginatedResponse<ReferralEarning> {
|
||||
total_amount_kopeks: number
|
||||
total_amount_rubles: number
|
||||
total_amount_kopeks: number;
|
||||
total_amount_rubles: number;
|
||||
}
|
||||
|
||||
export const referralApi = {
|
||||
// Get referral info
|
||||
getReferralInfo: async (): Promise<ReferralInfo> => {
|
||||
const response = await apiClient.get<ReferralInfo>('/cabinet/referral')
|
||||
return response.data
|
||||
const response = await apiClient.get<ReferralInfo>('/cabinet/referral');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get referral list
|
||||
getReferralList: async (params?: {
|
||||
page?: number
|
||||
per_page?: number
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
}): Promise<PaginatedResponse<ReferralItem>> => {
|
||||
const response = await apiClient.get<PaginatedResponse<ReferralItem>>('/cabinet/referral/list', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
const response = await apiClient.get<PaginatedResponse<ReferralItem>>(
|
||||
'/cabinet/referral/list',
|
||||
{
|
||||
params,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get referral earnings
|
||||
getReferralEarnings: async (params?: {
|
||||
page?: number
|
||||
per_page?: number
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
}): Promise<ReferralEarningsList> => {
|
||||
const response = await apiClient.get<ReferralEarningsList>('/cabinet/referral/earnings', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get referral terms
|
||||
getReferralTerms: async (): Promise<ReferralTerms> => {
|
||||
const response = await apiClient.get<ReferralTerms>('/cabinet/referral/terms')
|
||||
return response.data
|
||||
const response = await apiClient.get<ReferralTerms>('/cabinet/referral/terms');
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,142 +1,142 @@
|
||||
import apiClient from './client'
|
||||
import apiClient from './client';
|
||||
|
||||
// Types
|
||||
export interface PromoGroupInfo {
|
||||
id: number
|
||||
name: string
|
||||
is_selected: boolean
|
||||
id: number;
|
||||
name: string;
|
||||
is_selected: boolean;
|
||||
}
|
||||
|
||||
export interface ServerListItem {
|
||||
id: number
|
||||
squad_uuid: string
|
||||
display_name: string
|
||||
original_name: string | null
|
||||
country_code: string | null
|
||||
is_available: boolean
|
||||
is_trial_eligible: boolean
|
||||
price_kopeks: number
|
||||
price_rubles: number
|
||||
max_users: number | null
|
||||
current_users: number
|
||||
sort_order: number
|
||||
is_full: boolean
|
||||
availability_status: string
|
||||
created_at: string
|
||||
id: number;
|
||||
squad_uuid: string;
|
||||
display_name: string;
|
||||
original_name: string | null;
|
||||
country_code: string | null;
|
||||
is_available: boolean;
|
||||
is_trial_eligible: boolean;
|
||||
price_kopeks: number;
|
||||
price_rubles: number;
|
||||
max_users: number | null;
|
||||
current_users: number;
|
||||
sort_order: number;
|
||||
is_full: boolean;
|
||||
availability_status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ServerListResponse {
|
||||
servers: ServerListItem[]
|
||||
total: number
|
||||
servers: ServerListItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ServerDetail {
|
||||
id: number
|
||||
squad_uuid: string
|
||||
display_name: string
|
||||
original_name: string | null
|
||||
country_code: string | null
|
||||
description: string | null
|
||||
is_available: boolean
|
||||
is_trial_eligible: boolean
|
||||
price_kopeks: number
|
||||
price_rubles: number
|
||||
max_users: number | null
|
||||
current_users: number
|
||||
sort_order: number
|
||||
is_full: boolean
|
||||
availability_status: string
|
||||
promo_groups: PromoGroupInfo[]
|
||||
active_subscriptions: number
|
||||
tariffs_using: string[]
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
id: number;
|
||||
squad_uuid: string;
|
||||
display_name: string;
|
||||
original_name: string | null;
|
||||
country_code: string | null;
|
||||
description: string | null;
|
||||
is_available: boolean;
|
||||
is_trial_eligible: boolean;
|
||||
price_kopeks: number;
|
||||
price_rubles: number;
|
||||
max_users: number | null;
|
||||
current_users: number;
|
||||
sort_order: number;
|
||||
is_full: boolean;
|
||||
availability_status: string;
|
||||
promo_groups: PromoGroupInfo[];
|
||||
active_subscriptions: number;
|
||||
tariffs_using: string[];
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface ServerUpdateRequest {
|
||||
display_name?: string
|
||||
description?: string
|
||||
country_code?: string
|
||||
is_available?: boolean
|
||||
is_trial_eligible?: boolean
|
||||
price_kopeks?: number
|
||||
max_users?: number
|
||||
sort_order?: number
|
||||
promo_group_ids?: number[]
|
||||
display_name?: string;
|
||||
description?: string;
|
||||
country_code?: string;
|
||||
is_available?: boolean;
|
||||
is_trial_eligible?: boolean;
|
||||
price_kopeks?: number;
|
||||
max_users?: number;
|
||||
sort_order?: number;
|
||||
promo_group_ids?: number[];
|
||||
}
|
||||
|
||||
export interface ServerToggleResponse {
|
||||
id: number
|
||||
is_available: boolean
|
||||
message: string
|
||||
id: number;
|
||||
is_available: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ServerTrialToggleResponse {
|
||||
id: number
|
||||
is_trial_eligible: boolean
|
||||
message: string
|
||||
id: number;
|
||||
is_trial_eligible: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ServerStats {
|
||||
id: number
|
||||
display_name: string
|
||||
squad_uuid: string
|
||||
current_users: number
|
||||
max_users: number | null
|
||||
active_subscriptions: number
|
||||
trial_subscriptions: number
|
||||
usage_percent: number | null
|
||||
id: number;
|
||||
display_name: string;
|
||||
squad_uuid: string;
|
||||
current_users: number;
|
||||
max_users: number | null;
|
||||
active_subscriptions: number;
|
||||
trial_subscriptions: number;
|
||||
usage_percent: number | null;
|
||||
}
|
||||
|
||||
export interface ServerSyncResponse {
|
||||
created: number
|
||||
updated: number
|
||||
removed: number
|
||||
message: string
|
||||
created: number;
|
||||
updated: number;
|
||||
removed: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export const serversApi = {
|
||||
// Get all servers
|
||||
getServers: async (includeUnavailable = true): Promise<ServerListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/servers', {
|
||||
params: { include_unavailable: includeUnavailable }
|
||||
})
|
||||
return response.data
|
||||
params: { include_unavailable: includeUnavailable },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get single server
|
||||
getServer: async (serverId: number): Promise<ServerDetail> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/servers/${serverId}`)
|
||||
return response.data
|
||||
const response = await apiClient.get(`/cabinet/admin/servers/${serverId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update server
|
||||
updateServer: async (serverId: number, data: ServerUpdateRequest): Promise<ServerDetail> => {
|
||||
const response = await apiClient.put(`/cabinet/admin/servers/${serverId}`, data)
|
||||
return response.data
|
||||
const response = await apiClient.put(`/cabinet/admin/servers/${serverId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Toggle server availability
|
||||
toggleServer: async (serverId: number): Promise<ServerToggleResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/servers/${serverId}/toggle`)
|
||||
return response.data
|
||||
const response = await apiClient.post(`/cabinet/admin/servers/${serverId}/toggle`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Toggle trial eligibility
|
||||
toggleTrial: async (serverId: number): Promise<ServerTrialToggleResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/servers/${serverId}/trial`)
|
||||
return response.data
|
||||
const response = await apiClient.post(`/cabinet/admin/servers/${serverId}/trial`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get server stats
|
||||
getServerStats: async (serverId: number): Promise<ServerStats> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/servers/${serverId}/stats`)
|
||||
return response.data
|
||||
const response = await apiClient.get(`/cabinet/admin/servers/${serverId}/stats`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Sync servers with RemnaWave
|
||||
syncServers: async (): Promise<ServerSyncResponse> => {
|
||||
const response = await apiClient.post('/cabinet/admin/servers/sync')
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/admin/servers/sync');
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,218 +1,256 @@
|
||||
import apiClient from './client'
|
||||
import type { Subscription, RenewalOption, TrafficPackage, TrialInfo, PurchaseOptions, PurchaseSelection, PurchasePreview, AppConfig } from '../types'
|
||||
import apiClient from './client';
|
||||
import type {
|
||||
Subscription,
|
||||
RenewalOption,
|
||||
TrafficPackage,
|
||||
TrialInfo,
|
||||
PurchaseOptions,
|
||||
PurchaseSelection,
|
||||
PurchasePreview,
|
||||
AppConfig,
|
||||
} from '../types';
|
||||
|
||||
export const subscriptionApi = {
|
||||
// Get current subscription
|
||||
getSubscription: async (): Promise<Subscription> => {
|
||||
const response = await apiClient.get<Subscription>('/cabinet/subscription')
|
||||
return response.data
|
||||
const response = await apiClient.get<Subscription>('/cabinet/subscription');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get renewal options
|
||||
getRenewalOptions: async (): Promise<RenewalOption[]> => {
|
||||
const response = await apiClient.get<RenewalOption[]>('/cabinet/subscription/renewal-options')
|
||||
return response.data
|
||||
const response = await apiClient.get<RenewalOption[]>('/cabinet/subscription/renewal-options');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Renew subscription
|
||||
renewSubscription: async (periodDays: number): Promise<{
|
||||
message: string
|
||||
new_end_date: string
|
||||
amount_paid_kopeks: number
|
||||
renewSubscription: async (
|
||||
periodDays: number,
|
||||
): Promise<{
|
||||
message: string;
|
||||
new_end_date: string;
|
||||
amount_paid_kopeks: number;
|
||||
}> => {
|
||||
const response = await apiClient.post('/cabinet/subscription/renew', {
|
||||
period_days: periodDays,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get traffic packages
|
||||
getTrafficPackages: async (): Promise<TrafficPackage[]> => {
|
||||
const response = await apiClient.get<TrafficPackage[]>('/cabinet/subscription/traffic-packages')
|
||||
return response.data
|
||||
const response = await apiClient.get<TrafficPackage[]>(
|
||||
'/cabinet/subscription/traffic-packages',
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Purchase traffic
|
||||
purchaseTraffic: async (gb: number): Promise<{
|
||||
message: string
|
||||
gb_added: number
|
||||
amount_paid_kopeks: number
|
||||
purchaseTraffic: async (
|
||||
gb: number,
|
||||
): Promise<{
|
||||
message: string;
|
||||
gb_added: number;
|
||||
amount_paid_kopeks: number;
|
||||
}> => {
|
||||
const response = await apiClient.post('/cabinet/subscription/traffic', { gb })
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/subscription/traffic', { gb });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Purchase devices
|
||||
purchaseDevices: async (devices: number): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
devices_added: number
|
||||
new_device_limit: number
|
||||
price_kopeks: number
|
||||
price_label: string
|
||||
balance_kopeks: number
|
||||
balance_label: string
|
||||
purchaseDevices: async (
|
||||
devices: number,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
devices_added: number;
|
||||
new_device_limit: number;
|
||||
price_kopeks: number;
|
||||
price_label: string;
|
||||
balance_kopeks: number;
|
||||
balance_label: string;
|
||||
}> => {
|
||||
const response = await apiClient.post('/cabinet/subscription/devices/purchase', { devices })
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/subscription/devices/purchase', { devices });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get device purchase price
|
||||
getDevicePrice: async (devices: number = 1): Promise<{
|
||||
available: boolean
|
||||
reason?: string
|
||||
devices?: number
|
||||
price_per_device_kopeks?: number
|
||||
price_per_device_label?: string
|
||||
total_price_kopeks?: number
|
||||
total_price_label?: string
|
||||
current_device_limit?: number
|
||||
days_left?: number
|
||||
base_device_price_kopeks?: number
|
||||
getDevicePrice: async (
|
||||
devices: number = 1,
|
||||
): Promise<{
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
devices?: number;
|
||||
price_per_device_kopeks?: number;
|
||||
price_per_device_label?: string;
|
||||
total_price_kopeks?: number;
|
||||
total_price_label?: string;
|
||||
current_device_limit?: number;
|
||||
days_left?: number;
|
||||
base_device_price_kopeks?: number;
|
||||
}> => {
|
||||
const response = await apiClient.get('/cabinet/subscription/devices/price', { params: { devices } })
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/subscription/devices/price', {
|
||||
params: { devices },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update autopay settings
|
||||
updateAutopay: async (enabled: boolean, daysBefore?: number): Promise<{
|
||||
message: string
|
||||
autopay_enabled: boolean
|
||||
autopay_days_before: number
|
||||
updateAutopay: async (
|
||||
enabled: boolean,
|
||||
daysBefore?: number,
|
||||
): Promise<{
|
||||
message: string;
|
||||
autopay_enabled: boolean;
|
||||
autopay_days_before: number;
|
||||
}> => {
|
||||
const response = await apiClient.patch('/cabinet/subscription/autopay', {
|
||||
enabled,
|
||||
days_before: daysBefore,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get trial info
|
||||
getTrialInfo: async (): Promise<TrialInfo> => {
|
||||
const response = await apiClient.get<TrialInfo>('/cabinet/subscription/trial')
|
||||
return response.data
|
||||
const response = await apiClient.get<TrialInfo>('/cabinet/subscription/trial');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Activate trial
|
||||
activateTrial: async (): Promise<Subscription> => {
|
||||
const response = await apiClient.post<Subscription>('/cabinet/subscription/trial')
|
||||
return response.data
|
||||
const response = await apiClient.post<Subscription>('/cabinet/subscription/trial');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get purchase options (periods, servers, traffic, devices)
|
||||
getPurchaseOptions: async (): Promise<PurchaseOptions> => {
|
||||
const response = await apiClient.get<PurchaseOptions>('/cabinet/subscription/purchase-options')
|
||||
return response.data
|
||||
const response = await apiClient.get<PurchaseOptions>('/cabinet/subscription/purchase-options');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Preview purchase price
|
||||
previewPurchase: async (selection: PurchaseSelection): Promise<PurchasePreview> => {
|
||||
const response = await apiClient.post<PurchasePreview>('/cabinet/subscription/purchase-preview', {
|
||||
selection,
|
||||
})
|
||||
return response.data
|
||||
const response = await apiClient.post<PurchasePreview>(
|
||||
'/cabinet/subscription/purchase-preview',
|
||||
{
|
||||
selection,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Submit purchase
|
||||
submitPurchase: async (selection: PurchaseSelection): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
subscription: Subscription
|
||||
was_trial_conversion: boolean
|
||||
submitPurchase: async (
|
||||
selection: PurchaseSelection,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
subscription: Subscription;
|
||||
was_trial_conversion: boolean;
|
||||
}> => {
|
||||
const response = await apiClient.post('/cabinet/subscription/purchase', {
|
||||
selection,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Purchase tariff (for tariffs mode)
|
||||
purchaseTariff: async (tariffId: number, periodDays: number, trafficGb?: number): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
subscription: Subscription
|
||||
tariff_id: number
|
||||
tariff_name: string
|
||||
balance_kopeks: number
|
||||
balance_label: string
|
||||
purchaseTariff: async (
|
||||
tariffId: number,
|
||||
periodDays: number,
|
||||
trafficGb?: number,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
subscription: Subscription;
|
||||
tariff_id: number;
|
||||
tariff_name: string;
|
||||
balance_kopeks: number;
|
||||
balance_label: string;
|
||||
}> => {
|
||||
const response = await apiClient.post('/cabinet/subscription/purchase-tariff', {
|
||||
tariff_id: tariffId,
|
||||
period_days: periodDays,
|
||||
traffic_gb: trafficGb,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get app config for connection
|
||||
getAppConfig: async (): Promise<AppConfig> => {
|
||||
const response = await apiClient.get<AppConfig>('/cabinet/subscription/app-config')
|
||||
return response.data
|
||||
const response = await apiClient.get<AppConfig>('/cabinet/subscription/app-config');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get available countries/servers
|
||||
getCountries: async (): Promise<{
|
||||
countries: Array<{
|
||||
uuid: string
|
||||
name: string
|
||||
country_code: string | null
|
||||
base_price_kopeks: number
|
||||
price_kopeks: number
|
||||
price_per_month_kopeks: number
|
||||
price_rubles: number
|
||||
is_available: boolean
|
||||
is_connected: boolean
|
||||
has_discount: boolean
|
||||
discount_percent: number
|
||||
}>
|
||||
connected_count: number
|
||||
has_subscription: boolean
|
||||
days_left: number
|
||||
discount_percent: number
|
||||
uuid: string;
|
||||
name: string;
|
||||
country_code: string | null;
|
||||
base_price_kopeks: number;
|
||||
price_kopeks: number;
|
||||
price_per_month_kopeks: number;
|
||||
price_rubles: number;
|
||||
is_available: boolean;
|
||||
is_connected: boolean;
|
||||
has_discount: boolean;
|
||||
discount_percent: number;
|
||||
}>;
|
||||
connected_count: number;
|
||||
has_subscription: boolean;
|
||||
days_left: number;
|
||||
discount_percent: number;
|
||||
}> => {
|
||||
const response = await apiClient.get('/cabinet/subscription/countries')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/subscription/countries');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update countries/servers
|
||||
updateCountries: async (countries: string[]): Promise<{
|
||||
message: string
|
||||
added: string[]
|
||||
removed: string[]
|
||||
amount_paid_kopeks: number
|
||||
connected_squads: string[]
|
||||
updateCountries: async (
|
||||
countries: string[],
|
||||
): Promise<{
|
||||
message: string;
|
||||
added: string[];
|
||||
removed: string[];
|
||||
amount_paid_kopeks: number;
|
||||
connected_squads: string[];
|
||||
}> => {
|
||||
const response = await apiClient.post('/cabinet/subscription/countries', { countries })
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/subscription/countries', { countries });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get connection link and instructions
|
||||
getConnectionLink: async (): Promise<{
|
||||
subscription_url: string | null
|
||||
display_link: string | null
|
||||
happ_redirect_link: string | null
|
||||
happ_scheme_link: string | null
|
||||
connect_mode: string
|
||||
hide_link: boolean
|
||||
subscription_url: string | null;
|
||||
display_link: string | null;
|
||||
happ_redirect_link: string | null;
|
||||
happ_scheme_link: string | null;
|
||||
connect_mode: string;
|
||||
hide_link: boolean;
|
||||
instructions: {
|
||||
steps: string[]
|
||||
}
|
||||
steps: string[];
|
||||
};
|
||||
}> => {
|
||||
const response = await apiClient.get('/cabinet/subscription/connection-link')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/subscription/connection-link');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get hApp download links
|
||||
getHappDownloads: async (): Promise<{
|
||||
platforms: Record<string, {
|
||||
name: string
|
||||
icon: string
|
||||
link: string
|
||||
}>
|
||||
happ_enabled: boolean
|
||||
platforms: Record<
|
||||
string,
|
||||
{
|
||||
name: string;
|
||||
icon: string;
|
||||
link: string;
|
||||
}
|
||||
>;
|
||||
happ_enabled: boolean;
|
||||
}> => {
|
||||
const response = await apiClient.get('/cabinet/subscription/happ-downloads')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/subscription/happ-downloads');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// ============ Device Management ============
|
||||
@@ -220,130 +258,140 @@ export const subscriptionApi = {
|
||||
// Get connected devices
|
||||
getDevices: async (): Promise<{
|
||||
devices: Array<{
|
||||
hwid: string
|
||||
platform: string
|
||||
device_model: string
|
||||
created_at: string | null
|
||||
}>
|
||||
total: number
|
||||
device_limit: number
|
||||
hwid: string;
|
||||
platform: string;
|
||||
device_model: string;
|
||||
created_at: string | null;
|
||||
}>;
|
||||
total: number;
|
||||
device_limit: number;
|
||||
}> => {
|
||||
const response = await apiClient.get('/cabinet/subscription/devices')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/subscription/devices');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete a specific device
|
||||
deleteDevice: async (hwid: string): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
deleted_hwid: string
|
||||
deleteDevice: async (
|
||||
hwid: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
deleted_hwid: string;
|
||||
}> => {
|
||||
const response = await apiClient.delete(`/cabinet/subscription/devices/${encodeURIComponent(hwid)}`)
|
||||
return response.data
|
||||
const response = await apiClient.delete(
|
||||
`/cabinet/subscription/devices/${encodeURIComponent(hwid)}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete all devices
|
||||
deleteAllDevices: async (): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
deleted_count: number
|
||||
success: boolean;
|
||||
message: string;
|
||||
deleted_count: number;
|
||||
}> => {
|
||||
const response = await apiClient.delete('/cabinet/subscription/devices')
|
||||
return response.data
|
||||
const response = await apiClient.delete('/cabinet/subscription/devices');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// ============ Tariff Switch ============
|
||||
|
||||
// Preview tariff switch cost
|
||||
previewTariffSwitch: async (tariffId: number): Promise<{
|
||||
can_switch: boolean
|
||||
current_tariff_id: number | null
|
||||
current_tariff_name: string | null
|
||||
new_tariff_id: number
|
||||
new_tariff_name: string
|
||||
remaining_days: number
|
||||
upgrade_cost_kopeks: number
|
||||
upgrade_cost_label: string
|
||||
balance_kopeks: number
|
||||
balance_label: string
|
||||
has_enough_balance: boolean
|
||||
missing_amount_kopeks: number
|
||||
missing_amount_label: string
|
||||
is_upgrade: boolean
|
||||
previewTariffSwitch: async (
|
||||
tariffId: number,
|
||||
): Promise<{
|
||||
can_switch: boolean;
|
||||
current_tariff_id: number | null;
|
||||
current_tariff_name: string | null;
|
||||
new_tariff_id: number;
|
||||
new_tariff_name: string;
|
||||
remaining_days: number;
|
||||
upgrade_cost_kopeks: number;
|
||||
upgrade_cost_label: string;
|
||||
balance_kopeks: number;
|
||||
balance_label: string;
|
||||
has_enough_balance: boolean;
|
||||
missing_amount_kopeks: number;
|
||||
missing_amount_label: string;
|
||||
is_upgrade: boolean;
|
||||
}> => {
|
||||
const response = await apiClient.post('/cabinet/subscription/tariff/switch/preview', {
|
||||
tariff_id: tariffId,
|
||||
period_days: 30, // Default period for switch
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Switch to a different tariff
|
||||
switchTariff: async (tariffId: number): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
subscription: Subscription
|
||||
old_tariff_name: string
|
||||
new_tariff_id: number
|
||||
new_tariff_name: string
|
||||
charged_kopeks: number
|
||||
balance_kopeks: number
|
||||
balance_label: string
|
||||
switchTariff: async (
|
||||
tariffId: number,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
subscription: Subscription;
|
||||
old_tariff_name: string;
|
||||
new_tariff_id: number;
|
||||
new_tariff_name: string;
|
||||
charged_kopeks: number;
|
||||
balance_kopeks: number;
|
||||
balance_label: string;
|
||||
}> => {
|
||||
const response = await apiClient.post('/cabinet/subscription/tariff/switch', {
|
||||
tariff_id: tariffId,
|
||||
period_days: 30,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// ============ Subscription Pause (Daily Tariffs) ============
|
||||
|
||||
// Toggle pause/resume for daily subscription
|
||||
togglePause: async (): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
is_paused: boolean
|
||||
balance_kopeks: number
|
||||
balance_label: string
|
||||
success: boolean;
|
||||
message: string;
|
||||
is_paused: boolean;
|
||||
balance_kopeks: number;
|
||||
balance_label: string;
|
||||
}> => {
|
||||
const response = await apiClient.post('/cabinet/subscription/pause')
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/subscription/pause');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// ============ Traffic Switch ============
|
||||
|
||||
// Switch to a different traffic package
|
||||
switchTraffic: async (gb: number): Promise<{
|
||||
success: boolean
|
||||
message: string
|
||||
old_traffic_gb: number
|
||||
new_traffic_gb: number
|
||||
charged_kopeks: number
|
||||
balance_kopeks: number
|
||||
balance_label: string
|
||||
switchTraffic: async (
|
||||
gb: number,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
old_traffic_gb: number;
|
||||
new_traffic_gb: number;
|
||||
charged_kopeks: number;
|
||||
balance_kopeks: number;
|
||||
balance_label: string;
|
||||
}> => {
|
||||
const response = await apiClient.put('/cabinet/subscription/traffic', { gb })
|
||||
return response.data
|
||||
const response = await apiClient.put('/cabinet/subscription/traffic', { gb });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Refresh traffic usage from RemnaWave (rate limited: 1 per 60 seconds)
|
||||
refreshTraffic: async (): Promise<{
|
||||
success: boolean
|
||||
cached: boolean
|
||||
rate_limited?: boolean
|
||||
retry_after_seconds?: number
|
||||
source?: string
|
||||
traffic_used_bytes: number
|
||||
traffic_used_gb: number
|
||||
traffic_limit_bytes: number
|
||||
traffic_limit_gb: number
|
||||
traffic_used_percent: number
|
||||
is_unlimited: boolean
|
||||
lifetime_used_bytes?: number
|
||||
lifetime_used_gb?: number
|
||||
success: boolean;
|
||||
cached: boolean;
|
||||
rate_limited?: boolean;
|
||||
retry_after_seconds?: number;
|
||||
source?: string;
|
||||
traffic_used_bytes: number;
|
||||
traffic_used_gb: number;
|
||||
traffic_limit_bytes: number;
|
||||
traffic_limit_gb: number;
|
||||
traffic_used_percent: number;
|
||||
is_unlimited: boolean;
|
||||
lifetime_used_bytes?: number;
|
||||
lifetime_used_gb?: number;
|
||||
}> => {
|
||||
const response = await apiClient.post('/cabinet/subscription/refresh-traffic')
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/subscription/refresh-traffic');
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,239 +1,239 @@
|
||||
import apiClient from './client'
|
||||
import apiClient from './client';
|
||||
|
||||
// Types
|
||||
export interface PeriodPrice {
|
||||
days: number
|
||||
price_kopeks: number
|
||||
price_rubles?: number
|
||||
days: number;
|
||||
price_kopeks: number;
|
||||
price_rubles?: number;
|
||||
}
|
||||
|
||||
export interface ServerTrafficLimit {
|
||||
traffic_limit_gb: number
|
||||
traffic_limit_gb: number;
|
||||
}
|
||||
|
||||
export interface ServerInfo {
|
||||
id: number
|
||||
squad_uuid: string
|
||||
display_name: string
|
||||
country_code: string | null
|
||||
is_selected: boolean
|
||||
traffic_limit_gb?: number | null
|
||||
id: number;
|
||||
squad_uuid: string;
|
||||
display_name: string;
|
||||
country_code: string | null;
|
||||
is_selected: boolean;
|
||||
traffic_limit_gb?: number | null;
|
||||
}
|
||||
|
||||
export interface PromoGroupInfo {
|
||||
id: number
|
||||
name: string
|
||||
is_selected: boolean
|
||||
id: number;
|
||||
name: string;
|
||||
is_selected: boolean;
|
||||
}
|
||||
|
||||
export interface TariffListItem {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
is_active: boolean
|
||||
is_trial_available: boolean
|
||||
is_daily: boolean
|
||||
daily_price_kopeks: number
|
||||
traffic_limit_gb: number
|
||||
device_limit: number
|
||||
tier_level: number
|
||||
display_order: number
|
||||
servers_count: number
|
||||
subscriptions_count: number
|
||||
created_at: string
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
is_trial_available: boolean;
|
||||
is_daily: boolean;
|
||||
daily_price_kopeks: number;
|
||||
traffic_limit_gb: number;
|
||||
device_limit: number;
|
||||
tier_level: number;
|
||||
display_order: number;
|
||||
servers_count: number;
|
||||
subscriptions_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface TariffListResponse {
|
||||
tariffs: TariffListItem[]
|
||||
total: number
|
||||
tariffs: TariffListItem[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TariffDetail {
|
||||
id: number
|
||||
name: string
|
||||
description: string | null
|
||||
is_active: boolean
|
||||
is_trial_available: boolean
|
||||
traffic_limit_gb: number
|
||||
device_limit: number
|
||||
device_price_kopeks: number | null
|
||||
max_device_limit: number | null
|
||||
tier_level: number
|
||||
display_order: number
|
||||
period_prices: PeriodPrice[]
|
||||
allowed_squads: string[]
|
||||
server_traffic_limits: Record<string, ServerTrafficLimit>
|
||||
servers: ServerInfo[]
|
||||
promo_groups: PromoGroupInfo[]
|
||||
subscriptions_count: number
|
||||
id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_active: boolean;
|
||||
is_trial_available: boolean;
|
||||
traffic_limit_gb: number;
|
||||
device_limit: number;
|
||||
device_price_kopeks: number | null;
|
||||
max_device_limit: number | null;
|
||||
tier_level: number;
|
||||
display_order: number;
|
||||
period_prices: PeriodPrice[];
|
||||
allowed_squads: string[];
|
||||
server_traffic_limits: Record<string, ServerTrafficLimit>;
|
||||
servers: ServerInfo[];
|
||||
promo_groups: PromoGroupInfo[];
|
||||
subscriptions_count: number;
|
||||
// Произвольное количество дней
|
||||
custom_days_enabled: boolean
|
||||
price_per_day_kopeks: number
|
||||
min_days: number
|
||||
max_days: number
|
||||
custom_days_enabled: boolean;
|
||||
price_per_day_kopeks: number;
|
||||
min_days: number;
|
||||
max_days: number;
|
||||
// Произвольный трафик при покупке
|
||||
custom_traffic_enabled: boolean
|
||||
traffic_price_per_gb_kopeks: number
|
||||
min_traffic_gb: number
|
||||
max_traffic_gb: number
|
||||
custom_traffic_enabled: boolean;
|
||||
traffic_price_per_gb_kopeks: number;
|
||||
min_traffic_gb: number;
|
||||
max_traffic_gb: number;
|
||||
// Докупка трафика
|
||||
traffic_topup_enabled: boolean
|
||||
traffic_topup_packages: Record<string, number>
|
||||
max_topup_traffic_gb: number
|
||||
traffic_topup_enabled: boolean;
|
||||
traffic_topup_packages: Record<string, number>;
|
||||
max_topup_traffic_gb: number;
|
||||
// Дневной тариф
|
||||
is_daily: boolean
|
||||
daily_price_kopeks: number
|
||||
is_daily: boolean;
|
||||
daily_price_kopeks: number;
|
||||
// Режим сброса трафика
|
||||
traffic_reset_mode: string | null // 'DAY', 'WEEK', 'MONTH', 'NO_RESET', null = глобальная настройка
|
||||
created_at: string
|
||||
updated_at: string | null
|
||||
traffic_reset_mode: string | null; // 'DAY', 'WEEK', 'MONTH', 'NO_RESET', null = глобальная настройка
|
||||
created_at: string;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface TariffCreateRequest {
|
||||
name: string
|
||||
description?: string
|
||||
is_active?: boolean
|
||||
traffic_limit_gb?: number
|
||||
device_limit?: number
|
||||
device_price_kopeks?: number
|
||||
max_device_limit?: number
|
||||
tier_level?: number
|
||||
period_prices?: PeriodPrice[]
|
||||
allowed_squads?: string[]
|
||||
server_traffic_limits?: Record<string, ServerTrafficLimit>
|
||||
promo_group_ids?: number[]
|
||||
name: string;
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
traffic_limit_gb?: number;
|
||||
device_limit?: number;
|
||||
device_price_kopeks?: number;
|
||||
max_device_limit?: number;
|
||||
tier_level?: number;
|
||||
period_prices?: PeriodPrice[];
|
||||
allowed_squads?: string[];
|
||||
server_traffic_limits?: Record<string, ServerTrafficLimit>;
|
||||
promo_group_ids?: number[];
|
||||
// Произвольное количество дней
|
||||
custom_days_enabled?: boolean
|
||||
price_per_day_kopeks?: number
|
||||
min_days?: number
|
||||
max_days?: number
|
||||
custom_days_enabled?: boolean;
|
||||
price_per_day_kopeks?: number;
|
||||
min_days?: number;
|
||||
max_days?: number;
|
||||
// Произвольный трафик при покупке
|
||||
custom_traffic_enabled?: boolean
|
||||
traffic_price_per_gb_kopeks?: number
|
||||
min_traffic_gb?: number
|
||||
max_traffic_gb?: number
|
||||
custom_traffic_enabled?: boolean;
|
||||
traffic_price_per_gb_kopeks?: number;
|
||||
min_traffic_gb?: number;
|
||||
max_traffic_gb?: number;
|
||||
// Докупка трафика
|
||||
traffic_topup_enabled?: boolean
|
||||
traffic_topup_packages?: Record<string, number>
|
||||
max_topup_traffic_gb?: number
|
||||
traffic_topup_enabled?: boolean;
|
||||
traffic_topup_packages?: Record<string, number>;
|
||||
max_topup_traffic_gb?: number;
|
||||
// Дневной тариф
|
||||
is_daily?: boolean
|
||||
daily_price_kopeks?: number
|
||||
is_daily?: boolean;
|
||||
daily_price_kopeks?: number;
|
||||
// Режим сброса трафика
|
||||
traffic_reset_mode?: string | null
|
||||
traffic_reset_mode?: string | null;
|
||||
}
|
||||
|
||||
export interface TariffUpdateRequest {
|
||||
name?: string
|
||||
description?: string
|
||||
is_active?: boolean
|
||||
traffic_limit_gb?: number
|
||||
device_limit?: number
|
||||
device_price_kopeks?: number
|
||||
max_device_limit?: number
|
||||
tier_level?: number
|
||||
display_order?: number
|
||||
period_prices?: PeriodPrice[]
|
||||
allowed_squads?: string[]
|
||||
server_traffic_limits?: Record<string, ServerTrafficLimit>
|
||||
promo_group_ids?: number[]
|
||||
name?: string;
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
traffic_limit_gb?: number;
|
||||
device_limit?: number;
|
||||
device_price_kopeks?: number;
|
||||
max_device_limit?: number;
|
||||
tier_level?: number;
|
||||
display_order?: number;
|
||||
period_prices?: PeriodPrice[];
|
||||
allowed_squads?: string[];
|
||||
server_traffic_limits?: Record<string, ServerTrafficLimit>;
|
||||
promo_group_ids?: number[];
|
||||
// Произвольное количество дней
|
||||
custom_days_enabled?: boolean
|
||||
price_per_day_kopeks?: number
|
||||
min_days?: number
|
||||
max_days?: number
|
||||
custom_days_enabled?: boolean;
|
||||
price_per_day_kopeks?: number;
|
||||
min_days?: number;
|
||||
max_days?: number;
|
||||
// Произвольный трафик при покупке
|
||||
custom_traffic_enabled?: boolean
|
||||
traffic_price_per_gb_kopeks?: number
|
||||
min_traffic_gb?: number
|
||||
max_traffic_gb?: number
|
||||
custom_traffic_enabled?: boolean;
|
||||
traffic_price_per_gb_kopeks?: number;
|
||||
min_traffic_gb?: number;
|
||||
max_traffic_gb?: number;
|
||||
// Докупка трафика
|
||||
traffic_topup_enabled?: boolean
|
||||
traffic_topup_packages?: Record<string, number>
|
||||
max_topup_traffic_gb?: number
|
||||
traffic_topup_enabled?: boolean;
|
||||
traffic_topup_packages?: Record<string, number>;
|
||||
max_topup_traffic_gb?: number;
|
||||
// Дневной тариф
|
||||
is_daily?: boolean
|
||||
daily_price_kopeks?: number
|
||||
is_daily?: boolean;
|
||||
daily_price_kopeks?: number;
|
||||
// Режим сброса трафика
|
||||
traffic_reset_mode?: string | null
|
||||
traffic_reset_mode?: string | null;
|
||||
}
|
||||
|
||||
export interface TariffToggleResponse {
|
||||
id: number
|
||||
is_active: boolean
|
||||
message: string
|
||||
id: number;
|
||||
is_active: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TariffTrialResponse {
|
||||
id: number
|
||||
is_trial_available: boolean
|
||||
message: string
|
||||
id: number;
|
||||
is_trial_available: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TariffStats {
|
||||
id: number
|
||||
name: string
|
||||
subscriptions_count: number
|
||||
active_subscriptions: number
|
||||
trial_subscriptions: number
|
||||
revenue_kopeks: number
|
||||
revenue_rubles: number
|
||||
id: number;
|
||||
name: string;
|
||||
subscriptions_count: number;
|
||||
active_subscriptions: number;
|
||||
trial_subscriptions: number;
|
||||
revenue_kopeks: number;
|
||||
revenue_rubles: number;
|
||||
}
|
||||
|
||||
export const tariffsApi = {
|
||||
// Get all tariffs
|
||||
getTariffs: async (includeInactive = true): Promise<TariffListResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/tariffs', {
|
||||
params: { include_inactive: includeInactive }
|
||||
})
|
||||
return response.data
|
||||
params: { include_inactive: includeInactive },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get single tariff
|
||||
getTariff: async (tariffId: number): Promise<TariffDetail> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/tariffs/${tariffId}`)
|
||||
return response.data
|
||||
const response = await apiClient.get(`/cabinet/admin/tariffs/${tariffId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create new tariff
|
||||
createTariff: async (data: TariffCreateRequest): Promise<TariffDetail> => {
|
||||
const response = await apiClient.post('/cabinet/admin/tariffs', data)
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/admin/tariffs', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update tariff
|
||||
updateTariff: async (tariffId: number, data: TariffUpdateRequest): Promise<TariffDetail> => {
|
||||
const response = await apiClient.put(`/cabinet/admin/tariffs/${tariffId}`, data)
|
||||
return response.data
|
||||
const response = await apiClient.put(`/cabinet/admin/tariffs/${tariffId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete tariff
|
||||
deleteTariff: async (tariffId: number): Promise<{ message: string }> => {
|
||||
const response = await apiClient.delete(`/cabinet/admin/tariffs/${tariffId}`)
|
||||
return response.data
|
||||
const response = await apiClient.delete(`/cabinet/admin/tariffs/${tariffId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Toggle tariff active status
|
||||
toggleTariff: async (tariffId: number): Promise<TariffToggleResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/tariffs/${tariffId}/toggle`)
|
||||
return response.data
|
||||
const response = await apiClient.post(`/cabinet/admin/tariffs/${tariffId}/toggle`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Toggle trial status
|
||||
toggleTrial: async (tariffId: number): Promise<TariffTrialResponse> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/tariffs/${tariffId}/trial`)
|
||||
return response.data
|
||||
const response = await apiClient.post(`/cabinet/admin/tariffs/${tariffId}/trial`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get tariff stats
|
||||
getTariffStats: async (tariffId: number): Promise<TariffStats> => {
|
||||
const response = await apiClient.get(`/cabinet/admin/tariffs/${tariffId}/stats`)
|
||||
return response.data
|
||||
const response = await apiClient.get(`/cabinet/admin/tariffs/${tariffId}/stats`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get available servers for selection
|
||||
getAvailableServers: async (): Promise<ServerInfo[]> => {
|
||||
const response = await apiClient.get('/cabinet/admin/tariffs/available-servers')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/tariffs/available-servers');
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,43 +1,48 @@
|
||||
import apiClient from './client'
|
||||
import { ThemeSettings, DEFAULT_THEME_COLORS, EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme'
|
||||
import apiClient from './client';
|
||||
import {
|
||||
ThemeSettings,
|
||||
DEFAULT_THEME_COLORS,
|
||||
EnabledThemes,
|
||||
DEFAULT_ENABLED_THEMES,
|
||||
} from '../types/theme';
|
||||
|
||||
export const themeColorsApi = {
|
||||
// Get current theme colors (public, no auth required)
|
||||
getColors: async (): Promise<ThemeSettings> => {
|
||||
try {
|
||||
const response = await apiClient.get<ThemeSettings>('/cabinet/branding/colors')
|
||||
return response.data
|
||||
const response = await apiClient.get<ThemeSettings>('/cabinet/branding/colors');
|
||||
return response.data;
|
||||
} catch {
|
||||
// Return default colors if endpoint not available
|
||||
return DEFAULT_THEME_COLORS
|
||||
return DEFAULT_THEME_COLORS;
|
||||
}
|
||||
},
|
||||
|
||||
// Update theme colors (admin only)
|
||||
updateColors: async (colors: Partial<ThemeSettings>): Promise<ThemeSettings> => {
|
||||
const response = await apiClient.patch<ThemeSettings>('/cabinet/branding/colors', colors)
|
||||
return response.data
|
||||
const response = await apiClient.patch<ThemeSettings>('/cabinet/branding/colors', colors);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Reset to default colors (admin only)
|
||||
resetColors: async (): Promise<ThemeSettings> => {
|
||||
const response = await apiClient.post<ThemeSettings>('/cabinet/branding/colors/reset')
|
||||
return response.data
|
||||
const response = await apiClient.post<ThemeSettings>('/cabinet/branding/colors/reset');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get enabled themes (public, no auth required)
|
||||
getEnabledThemes: async (): Promise<EnabledThemes> => {
|
||||
try {
|
||||
const response = await apiClient.get<EnabledThemes>('/cabinet/branding/themes')
|
||||
return response.data
|
||||
const response = await apiClient.get<EnabledThemes>('/cabinet/branding/themes');
|
||||
return response.data;
|
||||
} catch {
|
||||
return DEFAULT_ENABLED_THEMES
|
||||
return DEFAULT_ENABLED_THEMES;
|
||||
}
|
||||
},
|
||||
|
||||
// Update enabled themes (admin only)
|
||||
updateEnabledThemes: async (themes: Partial<EnabledThemes>): Promise<EnabledThemes> => {
|
||||
const response = await apiClient.patch<EnabledThemes>('/cabinet/branding/themes', themes)
|
||||
return response.data
|
||||
const response = await apiClient.patch<EnabledThemes>('/cabinet/branding/themes', themes);
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,64 +1,80 @@
|
||||
import apiClient from './client'
|
||||
import type { TicketNotificationList, UnreadCountResponse } from '../types'
|
||||
import apiClient from './client';
|
||||
import type { TicketNotificationList, UnreadCountResponse } from '../types';
|
||||
|
||||
export const ticketNotificationsApi = {
|
||||
// User notifications
|
||||
getNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise<TicketNotificationList> => {
|
||||
getNotifications: async (
|
||||
unreadOnly = false,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
): Promise<TicketNotificationList> => {
|
||||
const response = await apiClient.get('/cabinet/tickets/notifications', {
|
||||
params: { unread_only: unreadOnly, limit, offset }
|
||||
})
|
||||
return response.data
|
||||
params: { unread_only: unreadOnly, limit, offset },
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getUnreadCount: async (): Promise<UnreadCountResponse> => {
|
||||
const response = await apiClient.get('/cabinet/tickets/notifications/unread-count')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/tickets/notifications/unread-count');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markAsRead: async (notificationId: number): Promise<{ success: boolean }> => {
|
||||
const response = await apiClient.post(`/cabinet/tickets/notifications/${notificationId}/read`)
|
||||
return response.data
|
||||
const response = await apiClient.post(`/cabinet/tickets/notifications/${notificationId}/read`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markAllAsRead: async (): Promise<{ success: boolean; marked_count: number }> => {
|
||||
const response = await apiClient.post('/cabinet/tickets/notifications/read-all')
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/tickets/notifications/read-all');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markTicketAsRead: async (ticketId: number): Promise<{ success: boolean; marked_count: number }> => {
|
||||
const response = await apiClient.post(`/cabinet/tickets/notifications/ticket/${ticketId}/read`)
|
||||
return response.data
|
||||
markTicketAsRead: async (
|
||||
ticketId: number,
|
||||
): Promise<{ success: boolean; marked_count: number }> => {
|
||||
const response = await apiClient.post(`/cabinet/tickets/notifications/ticket/${ticketId}/read`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Admin notifications
|
||||
getAdminNotifications: async (unreadOnly = false, limit = 50, offset = 0): Promise<TicketNotificationList> => {
|
||||
const params: Record<string, unknown> = { limit, offset }
|
||||
getAdminNotifications: async (
|
||||
unreadOnly = false,
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
): Promise<TicketNotificationList> => {
|
||||
const params: Record<string, unknown> = { limit, offset };
|
||||
if (unreadOnly) {
|
||||
params.unread_only = true
|
||||
params.unread_only = true;
|
||||
}
|
||||
const response = await apiClient.get('/cabinet/admin/tickets/notifications', { params })
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/tickets/notifications', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getAdminUnreadCount: async (): Promise<UnreadCountResponse> => {
|
||||
const response = await apiClient.get('/cabinet/admin/tickets/notifications/unread-count')
|
||||
return response.data
|
||||
const response = await apiClient.get('/cabinet/admin/tickets/notifications/unread-count');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markAdminAsRead: async (notificationId: number): Promise<{ success: boolean }> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/tickets/notifications/${notificationId}/read`)
|
||||
return response.data
|
||||
const response = await apiClient.post(
|
||||
`/cabinet/admin/tickets/notifications/${notificationId}/read`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markAllAdminAsRead: async (): Promise<{ success: boolean; marked_count: number }> => {
|
||||
const response = await apiClient.post('/cabinet/admin/tickets/notifications/read-all')
|
||||
return response.data
|
||||
const response = await apiClient.post('/cabinet/admin/tickets/notifications/read-all');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
markAdminTicketAsRead: async (ticketId: number): Promise<{ success: boolean; marked_count: number }> => {
|
||||
const response = await apiClient.post(`/cabinet/admin/tickets/notifications/ticket/${ticketId}/read`)
|
||||
return response.data
|
||||
markAdminTicketAsRead: async (
|
||||
ticketId: number,
|
||||
): Promise<{ success: boolean; marked_count: number }> => {
|
||||
const response = await apiClient.post(
|
||||
`/cabinet/admin/tickets/notifications/ticket/${ticketId}/read`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
export default ticketNotificationsApi
|
||||
export default ticketNotificationsApi;
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
import apiClient from './client'
|
||||
import type { Ticket, TicketDetail, TicketMessage, PaginatedResponse } from '../types'
|
||||
import apiClient from './client';
|
||||
import type { Ticket, TicketDetail, TicketMessage, PaginatedResponse } from '../types';
|
||||
|
||||
// Media upload response type
|
||||
interface MediaUploadResponse {
|
||||
media_type: string
|
||||
file_id: string
|
||||
file_unique_id: string | null
|
||||
media_url: string
|
||||
media_type: string;
|
||||
file_id: string;
|
||||
file_unique_id: string | null;
|
||||
media_url: string;
|
||||
}
|
||||
|
||||
// Media parameters for ticket messages
|
||||
interface MediaParams {
|
||||
media_type?: string
|
||||
media_file_id?: string
|
||||
media_caption?: string
|
||||
media_type?: string;
|
||||
media_file_id?: string;
|
||||
media_caption?: string;
|
||||
}
|
||||
|
||||
export const ticketsApi = {
|
||||
// Get tickets list
|
||||
getTickets: async (params?: {
|
||||
page?: number
|
||||
per_page?: number
|
||||
status?: string
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
status?: string;
|
||||
}): Promise<PaginatedResponse<Ticket>> => {
|
||||
const response = await apiClient.get<PaginatedResponse<Ticket>>('/cabinet/tickets', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create ticket with optional media
|
||||
createTicket: async (
|
||||
title: string,
|
||||
message: string,
|
||||
media?: MediaParams
|
||||
media?: MediaParams,
|
||||
): Promise<TicketDetail> => {
|
||||
const response = await apiClient.post<TicketDetail>('/cabinet/tickets', {
|
||||
title,
|
||||
message,
|
||||
...media,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get ticket detail
|
||||
getTicket: async (ticketId: number): Promise<TicketDetail> => {
|
||||
const response = await apiClient.get<TicketDetail>(`/cabinet/tickets/${ticketId}`)
|
||||
return response.data
|
||||
const response = await apiClient.get<TicketDetail>(`/cabinet/tickets/${ticketId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Add message to ticket with optional media
|
||||
addMessage: async (
|
||||
ticketId: number,
|
||||
message: string,
|
||||
media?: MediaParams
|
||||
media?: MediaParams,
|
||||
): Promise<TicketMessage> => {
|
||||
const response = await apiClient.post<TicketMessage>(`/cabinet/tickets/${ticketId}/messages`, {
|
||||
message,
|
||||
...media,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Upload media file for tickets
|
||||
uploadMedia: async (file: File, mediaType: string = 'photo'): Promise<MediaUploadResponse> => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('media_type', mediaType)
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('media_type', mediaType);
|
||||
|
||||
const response = await apiClient.post<MediaUploadResponse>('/cabinet/media/upload', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get media URL for display
|
||||
getMediaUrl: (fileId: string): string => {
|
||||
const baseUrl = import.meta.env.VITE_API_URL || ''
|
||||
return `${baseUrl}/cabinet/media/${fileId}`
|
||||
const baseUrl = import.meta.env.VITE_API_URL || '';
|
||||
return `${baseUrl}/cabinet/media/${fileId}`;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
344
src/api/wheel.ts
344
src/api/wheel.ts
@@ -1,182 +1,182 @@
|
||||
import apiClient from './client'
|
||||
import apiClient from './client';
|
||||
|
||||
// ==================== TYPES ====================
|
||||
|
||||
export interface WheelPrize {
|
||||
id: number
|
||||
display_name: string
|
||||
emoji: string
|
||||
color: string
|
||||
prize_type: string
|
||||
id: number;
|
||||
display_name: string;
|
||||
emoji: string;
|
||||
color: string;
|
||||
prize_type: string;
|
||||
}
|
||||
|
||||
export interface WheelConfig {
|
||||
is_enabled: boolean
|
||||
name: string
|
||||
spin_cost_stars: number | null
|
||||
spin_cost_days: number | null
|
||||
spin_cost_stars_enabled: boolean
|
||||
spin_cost_days_enabled: boolean
|
||||
prizes: WheelPrize[]
|
||||
daily_limit: number
|
||||
user_spins_today: number
|
||||
can_spin: boolean
|
||||
can_spin_reason: string | null
|
||||
can_pay_stars: boolean
|
||||
can_pay_days: boolean
|
||||
user_balance_kopeks: number
|
||||
required_balance_kopeks: number
|
||||
is_enabled: boolean;
|
||||
name: string;
|
||||
spin_cost_stars: number | null;
|
||||
spin_cost_days: number | null;
|
||||
spin_cost_stars_enabled: boolean;
|
||||
spin_cost_days_enabled: boolean;
|
||||
prizes: WheelPrize[];
|
||||
daily_limit: number;
|
||||
user_spins_today: number;
|
||||
can_spin: boolean;
|
||||
can_spin_reason: string | null;
|
||||
can_pay_stars: boolean;
|
||||
can_pay_days: boolean;
|
||||
user_balance_kopeks: number;
|
||||
required_balance_kopeks: number;
|
||||
}
|
||||
|
||||
export interface SpinAvailability {
|
||||
can_spin: boolean
|
||||
reason: string | null
|
||||
spins_remaining_today: number
|
||||
can_pay_stars: boolean
|
||||
can_pay_days: boolean
|
||||
min_subscription_days: number
|
||||
user_subscription_days: number
|
||||
can_spin: boolean;
|
||||
reason: string | null;
|
||||
spins_remaining_today: number;
|
||||
can_pay_stars: boolean;
|
||||
can_pay_days: boolean;
|
||||
min_subscription_days: number;
|
||||
user_subscription_days: number;
|
||||
}
|
||||
|
||||
export interface SpinResult {
|
||||
success: boolean
|
||||
prize_id: number | null
|
||||
prize_type: string | null
|
||||
prize_value: number
|
||||
prize_display_name: string
|
||||
emoji: string
|
||||
color: string
|
||||
rotation_degrees: number
|
||||
message: string
|
||||
promocode: string | null
|
||||
error: string | null
|
||||
success: boolean;
|
||||
prize_id: number | null;
|
||||
prize_type: string | null;
|
||||
prize_value: number;
|
||||
prize_display_name: string;
|
||||
emoji: string;
|
||||
color: string;
|
||||
rotation_degrees: number;
|
||||
message: string;
|
||||
promocode: string | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface SpinHistoryItem {
|
||||
id: number
|
||||
payment_type: string
|
||||
payment_amount: number
|
||||
prize_type: string
|
||||
prize_value: number
|
||||
prize_display_name: string
|
||||
emoji: string
|
||||
color: string
|
||||
prize_value_kopeks: number
|
||||
created_at: string
|
||||
id: number;
|
||||
payment_type: string;
|
||||
payment_amount: number;
|
||||
prize_type: string;
|
||||
prize_value: number;
|
||||
prize_display_name: string;
|
||||
emoji: string;
|
||||
color: string;
|
||||
prize_value_kopeks: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SpinHistoryResponse {
|
||||
items: SpinHistoryItem[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
pages: number
|
||||
items: SpinHistoryItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
export interface StarsInvoiceResponse {
|
||||
invoice_url: string
|
||||
stars_amount: number
|
||||
invoice_url: string;
|
||||
stars_amount: number;
|
||||
}
|
||||
|
||||
// Admin types
|
||||
export interface WheelPrizeAdmin {
|
||||
id: number
|
||||
config_id: number
|
||||
prize_type: string
|
||||
prize_value: number
|
||||
display_name: string
|
||||
emoji: string
|
||||
color: string
|
||||
prize_value_kopeks: number
|
||||
sort_order: number
|
||||
manual_probability: number | null
|
||||
is_active: boolean
|
||||
promo_balance_bonus_kopeks: number
|
||||
promo_subscription_days: number
|
||||
promo_traffic_gb: number
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
id: number;
|
||||
config_id: number;
|
||||
prize_type: string;
|
||||
prize_value: number;
|
||||
display_name: string;
|
||||
emoji: string;
|
||||
color: string;
|
||||
prize_value_kopeks: number;
|
||||
sort_order: number;
|
||||
manual_probability: number | null;
|
||||
is_active: boolean;
|
||||
promo_balance_bonus_kopeks: number;
|
||||
promo_subscription_days: number;
|
||||
promo_traffic_gb: number;
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
// Type for creating a new prize (excludes id, config_id which are auto-generated)
|
||||
export interface CreateWheelPrizeData {
|
||||
prize_type: string
|
||||
prize_value: number
|
||||
display_name: string
|
||||
emoji?: string
|
||||
color?: string
|
||||
prize_value_kopeks: number
|
||||
sort_order?: number
|
||||
manual_probability?: number | null
|
||||
is_active?: boolean
|
||||
promo_balance_bonus_kopeks?: number
|
||||
promo_subscription_days?: number
|
||||
promo_traffic_gb?: number
|
||||
prize_type: string;
|
||||
prize_value: number;
|
||||
display_name: string;
|
||||
emoji?: string;
|
||||
color?: string;
|
||||
prize_value_kopeks: number;
|
||||
sort_order?: number;
|
||||
manual_probability?: number | null;
|
||||
is_active?: boolean;
|
||||
promo_balance_bonus_kopeks?: number;
|
||||
promo_subscription_days?: number;
|
||||
promo_traffic_gb?: number;
|
||||
}
|
||||
|
||||
export interface AdminWheelConfig {
|
||||
id: number
|
||||
is_enabled: boolean
|
||||
name: string
|
||||
spin_cost_stars: number
|
||||
spin_cost_days: number
|
||||
spin_cost_stars_enabled: boolean
|
||||
spin_cost_days_enabled: boolean
|
||||
rtp_percent: number
|
||||
daily_spin_limit: number
|
||||
min_subscription_days_for_day_payment: number
|
||||
promo_prefix: string
|
||||
promo_validity_days: number
|
||||
prizes: WheelPrizeAdmin[]
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
id: number;
|
||||
is_enabled: boolean;
|
||||
name: string;
|
||||
spin_cost_stars: number;
|
||||
spin_cost_days: number;
|
||||
spin_cost_stars_enabled: boolean;
|
||||
spin_cost_days_enabled: boolean;
|
||||
rtp_percent: number;
|
||||
daily_spin_limit: number;
|
||||
min_subscription_days_for_day_payment: number;
|
||||
promo_prefix: string;
|
||||
promo_validity_days: number;
|
||||
prizes: WheelPrizeAdmin[];
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
export interface WheelStatistics {
|
||||
total_spins: number
|
||||
total_revenue_kopeks: number
|
||||
total_payout_kopeks: number
|
||||
actual_rtp_percent: number
|
||||
configured_rtp_percent: number
|
||||
spins_by_payment_type: Record<string, { count: number; total_kopeks: number }>
|
||||
total_spins: number;
|
||||
total_revenue_kopeks: number;
|
||||
total_payout_kopeks: number;
|
||||
actual_rtp_percent: number;
|
||||
configured_rtp_percent: number;
|
||||
spins_by_payment_type: Record<string, { count: number; total_kopeks: number }>;
|
||||
prizes_distribution: Array<{
|
||||
prize_type: string
|
||||
display_name: string
|
||||
count: number
|
||||
total_kopeks: number
|
||||
}>
|
||||
prize_type: string;
|
||||
display_name: string;
|
||||
count: number;
|
||||
total_kopeks: number;
|
||||
}>;
|
||||
top_wins: Array<{
|
||||
user_id: number
|
||||
username: string | null
|
||||
prize_display_name: string
|
||||
prize_value_kopeks: number
|
||||
created_at: string | null
|
||||
}>
|
||||
period_from: string | null
|
||||
period_to: string | null
|
||||
user_id: number;
|
||||
username: string | null;
|
||||
prize_display_name: string;
|
||||
prize_value_kopeks: number;
|
||||
created_at: string | null;
|
||||
}>;
|
||||
period_from: string | null;
|
||||
period_to: string | null;
|
||||
}
|
||||
|
||||
export interface AdminSpinItem {
|
||||
id: number
|
||||
user_id: number
|
||||
username: string | null
|
||||
payment_type: string
|
||||
payment_amount: number
|
||||
payment_value_kopeks: number
|
||||
prize_type: string
|
||||
prize_value: number
|
||||
prize_display_name: string
|
||||
prize_value_kopeks: number
|
||||
is_applied: boolean
|
||||
created_at: string
|
||||
id: number;
|
||||
user_id: number;
|
||||
username: string | null;
|
||||
payment_type: string;
|
||||
payment_amount: number;
|
||||
payment_value_kopeks: number;
|
||||
prize_type: string;
|
||||
prize_value: number;
|
||||
prize_display_name: string;
|
||||
prize_value_kopeks: number;
|
||||
is_applied: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdminSpinsResponse {
|
||||
items: AdminSpinItem[]
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
pages: number
|
||||
items: AdminSpinItem[];
|
||||
total: number;
|
||||
page: number;
|
||||
per_page: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
// ==================== USER API ====================
|
||||
@@ -184,101 +184,107 @@ export interface AdminSpinsResponse {
|
||||
export const wheelApi = {
|
||||
// Get wheel config
|
||||
getConfig: async (): Promise<WheelConfig> => {
|
||||
const response = await apiClient.get<WheelConfig>('/cabinet/wheel/config')
|
||||
return response.data
|
||||
const response = await apiClient.get<WheelConfig>('/cabinet/wheel/config');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Check spin availability
|
||||
checkAvailability: async (): Promise<SpinAvailability> => {
|
||||
const response = await apiClient.get<SpinAvailability>('/cabinet/wheel/availability')
|
||||
return response.data
|
||||
const response = await apiClient.get<SpinAvailability>('/cabinet/wheel/availability');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Spin the wheel
|
||||
spin: async (paymentType: 'telegram_stars' | 'subscription_days'): Promise<SpinResult> => {
|
||||
const response = await apiClient.post<SpinResult>('/cabinet/wheel/spin', {
|
||||
payment_type: paymentType,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get spin history
|
||||
getHistory: async (page = 1, perPage = 20): Promise<SpinHistoryResponse> => {
|
||||
const response = await apiClient.get<SpinHistoryResponse>('/cabinet/wheel/history', {
|
||||
params: { page, per_page: perPage },
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create Stars invoice for Mini App payment
|
||||
createStarsInvoice: async (): Promise<StarsInvoiceResponse> => {
|
||||
const response = await apiClient.post<StarsInvoiceResponse>('/cabinet/wheel/stars-invoice')
|
||||
return response.data
|
||||
const response = await apiClient.post<StarsInvoiceResponse>('/cabinet/wheel/stars-invoice');
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== ADMIN API ====================
|
||||
|
||||
export const adminWheelApi = {
|
||||
// Get full config
|
||||
getConfig: async (): Promise<AdminWheelConfig> => {
|
||||
const response = await apiClient.get<AdminWheelConfig>('/cabinet/admin/wheel/config')
|
||||
return response.data
|
||||
const response = await apiClient.get<AdminWheelConfig>('/cabinet/admin/wheel/config');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update config
|
||||
updateConfig: async (data: Partial<AdminWheelConfig>): Promise<AdminWheelConfig> => {
|
||||
const response = await apiClient.put<AdminWheelConfig>('/cabinet/admin/wheel/config', data)
|
||||
return response.data
|
||||
const response = await apiClient.put<AdminWheelConfig>('/cabinet/admin/wheel/config', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get prizes
|
||||
getPrizes: async (): Promise<WheelPrizeAdmin[]> => {
|
||||
const response = await apiClient.get<WheelPrizeAdmin[]>('/cabinet/admin/wheel/prizes')
|
||||
return response.data
|
||||
const response = await apiClient.get<WheelPrizeAdmin[]>('/cabinet/admin/wheel/prizes');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Create prize
|
||||
createPrize: async (data: CreateWheelPrizeData): Promise<WheelPrizeAdmin> => {
|
||||
const response = await apiClient.post<WheelPrizeAdmin>('/cabinet/admin/wheel/prizes', data)
|
||||
return response.data
|
||||
const response = await apiClient.post<WheelPrizeAdmin>('/cabinet/admin/wheel/prizes', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Update prize
|
||||
updatePrize: async (prizeId: number, data: Partial<WheelPrizeAdmin>): Promise<WheelPrizeAdmin> => {
|
||||
const response = await apiClient.put<WheelPrizeAdmin>(`/cabinet/admin/wheel/prizes/${prizeId}`, data)
|
||||
return response.data
|
||||
updatePrize: async (
|
||||
prizeId: number,
|
||||
data: Partial<WheelPrizeAdmin>,
|
||||
): Promise<WheelPrizeAdmin> => {
|
||||
const response = await apiClient.put<WheelPrizeAdmin>(
|
||||
`/cabinet/admin/wheel/prizes/${prizeId}`,
|
||||
data,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Delete prize
|
||||
deletePrize: async (prizeId: number): Promise<void> => {
|
||||
await apiClient.delete(`/cabinet/admin/wheel/prizes/${prizeId}`)
|
||||
await apiClient.delete(`/cabinet/admin/wheel/prizes/${prizeId}`);
|
||||
},
|
||||
|
||||
// Reorder prizes
|
||||
reorderPrizes: async (prizeIds: number[]): Promise<void> => {
|
||||
await apiClient.post('/cabinet/admin/wheel/prizes/reorder', { prize_ids: prizeIds })
|
||||
await apiClient.post('/cabinet/admin/wheel/prizes/reorder', { prize_ids: prizeIds });
|
||||
},
|
||||
|
||||
// Get statistics
|
||||
getStatistics: async (dateFrom?: string, dateTo?: string): Promise<WheelStatistics> => {
|
||||
const response = await apiClient.get<WheelStatistics>('/cabinet/admin/wheel/statistics', {
|
||||
params: { date_from: dateFrom, date_to: dateTo },
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Get all spins
|
||||
getSpins: async (params?: {
|
||||
user_id?: number
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
page?: number
|
||||
per_page?: number
|
||||
user_id?: number;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
}): Promise<AdminSpinsResponse> => {
|
||||
const response = await apiClient.get<AdminSpinsResponse>('/cabinet/admin/wheel/spins', {
|
||||
params,
|
||||
})
|
||||
return response.data
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,43 +1,44 @@
|
||||
import { useEffect, useState, memo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { brandingApi } from '../api/branding'
|
||||
import { useEffect, useState, memo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { brandingApi } from '../api/branding';
|
||||
|
||||
const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled'
|
||||
const ANIMATION_CACHE_KEY = 'cabinet_animation_enabled';
|
||||
|
||||
// Detect if user prefers reduced motion
|
||||
const isLowPerformance = (): boolean => {
|
||||
// Only check for reduced motion preference - let animation run everywhere else
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
return prefersReducedMotion
|
||||
}
|
||||
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
return prefersReducedMotion;
|
||||
};
|
||||
|
||||
// Get cached value from localStorage
|
||||
const getCachedAnimationEnabled = (): boolean | null => {
|
||||
try {
|
||||
const cached = localStorage.getItem(ANIMATION_CACHE_KEY)
|
||||
const cached = localStorage.getItem(ANIMATION_CACHE_KEY);
|
||||
if (cached !== null) {
|
||||
return cached === 'true'
|
||||
return cached === 'true';
|
||||
}
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Update cache in localStorage
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export const setCachedAnimationEnabled = (enabled: boolean) => {
|
||||
try {
|
||||
localStorage.setItem(ANIMATION_CACHE_KEY, String(enabled))
|
||||
localStorage.setItem(ANIMATION_CACHE_KEY, String(enabled));
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Memoized background component to prevent re-renders
|
||||
const AnimatedBackground = memo(function AnimatedBackground() {
|
||||
// Start with cached value (null means unknown yet)
|
||||
const [isEnabled, setIsEnabled] = useState<boolean | null>(() => getCachedAnimationEnabled())
|
||||
const [isLowPerf] = useState(() => isLowPerformance())
|
||||
const [isEnabled, setIsEnabled] = useState<boolean | null>(() => getCachedAnimationEnabled());
|
||||
const [isLowPerf] = useState(() => isLowPerformance());
|
||||
|
||||
const { data: animationSettings } = useQuery({
|
||||
queryKey: ['animation-enabled'],
|
||||
@@ -45,24 +46,24 @@ const AnimatedBackground = memo(function AnimatedBackground() {
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes - reduce API calls
|
||||
refetchOnWindowFocus: false, // Don't refetch on focus - save resources
|
||||
retry: false,
|
||||
})
|
||||
});
|
||||
|
||||
// Update state and cache when data arrives
|
||||
useEffect(() => {
|
||||
if (animationSettings !== undefined) {
|
||||
const enabled = animationSettings.enabled
|
||||
setIsEnabled(enabled)
|
||||
setCachedAnimationEnabled(enabled)
|
||||
const enabled = animationSettings.enabled;
|
||||
setIsEnabled(enabled);
|
||||
setCachedAnimationEnabled(enabled);
|
||||
}
|
||||
}, [animationSettings])
|
||||
}, [animationSettings]);
|
||||
|
||||
// Don't render if disabled or on low-performance devices
|
||||
if (isEnabled !== true || isLowPerf) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render only 2 blobs on mobile for better performance
|
||||
const isMobile = window.innerWidth < 768
|
||||
const isMobile = window.innerWidth < 768;
|
||||
|
||||
return (
|
||||
<div className="wave-bg-container">
|
||||
@@ -75,7 +76,7 @@ const AnimatedBackground = memo(function AnimatedBackground() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
export default AnimatedBackground
|
||||
export default AnimatedBackground;
|
||||
|
||||
@@ -1,238 +1,286 @@
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface ColorPickerProps {
|
||||
value: string
|
||||
onChange: (color: string) => void
|
||||
label: string
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
label: string;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Check if running in Telegram WebApp
|
||||
const isTelegramWebApp = (): boolean => {
|
||||
return !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp
|
||||
}
|
||||
return !!(window as unknown as { Telegram?: { WebApp?: unknown } }).Telegram?.WebApp;
|
||||
};
|
||||
|
||||
// Convert hex to RGB
|
||||
const hexToRgb = (hex: string): { r: number; g: number; b: number } => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: { r: 0, g: 0, b: 0 }
|
||||
}
|
||||
: { r: 0, g: 0, b: 0 };
|
||||
};
|
||||
|
||||
// Convert RGB to hex
|
||||
const rgbToHex = (r: number, g: number, b: number): string => {
|
||||
return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
return '#' + [r, g, b].map((x) => x.toString(16).padStart(2, '0')).join('');
|
||||
};
|
||||
|
||||
// Convert RGB to HSL
|
||||
const rgbToHsl = (r: number, g: number, b: number): { h: number; s: number; l: number } => {
|
||||
r /= 255; g /= 255; b /= 255
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b)
|
||||
let h = 0, s = 0
|
||||
const l = (max + min) / 2
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
const max = Math.max(r, g, b),
|
||||
min = Math.min(r, g, b);
|
||||
let h = 0,
|
||||
s = 0;
|
||||
const l = (max + min) / 2;
|
||||
if (max !== min) {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break
|
||||
case g: h = ((b - r) / d + 2) / 6; break
|
||||
case b: h = ((r - g) / d + 4) / 6; break
|
||||
case r:
|
||||
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
|
||||
break;
|
||||
case g:
|
||||
h = ((b - r) / d + 2) / 6;
|
||||
break;
|
||||
case b:
|
||||
h = ((r - g) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) }
|
||||
}
|
||||
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
|
||||
};
|
||||
|
||||
// Convert HSL to RGB
|
||||
const hslToRgb = (h: number, s: number, l: number): { r: number; g: number; b: number } => {
|
||||
h /= 360; s /= 100; l /= 100
|
||||
let r, g, b
|
||||
h /= 360;
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
let r, g, b;
|
||||
if (s === 0) {
|
||||
r = g = b = l
|
||||
r = g = b = l;
|
||||
} else {
|
||||
const hue2rgb = (p: number, q: number, t: number) => {
|
||||
if (t < 0) t += 1
|
||||
if (t > 1) t -= 1
|
||||
if (t < 1/6) return p + (q - p) * 6 * t
|
||||
if (t < 1/2) return q
|
||||
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6
|
||||
return p
|
||||
}
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
|
||||
const p = 2 * l - q
|
||||
r = hue2rgb(p, q, h + 1/3)
|
||||
g = hue2rgb(p, q, h)
|
||||
b = hue2rgb(p, q, h - 1/3)
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) }
|
||||
}
|
||||
return { r: Math.round(r * 255), g: Math.round(g * 255), b: Math.round(b * 255) };
|
||||
};
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#3b82f6', '#ef4444', '#22c55e', '#f59e0b',
|
||||
'#8b5cf6', '#ec4899', '#06b6d4', '#14b8a6',
|
||||
'#84cc16', '#f97316', '#6366f1', '#a855f7',
|
||||
]
|
||||
'#3b82f6',
|
||||
'#ef4444',
|
||||
'#22c55e',
|
||||
'#f59e0b',
|
||||
'#8b5cf6',
|
||||
'#ec4899',
|
||||
'#06b6d4',
|
||||
'#14b8a6',
|
||||
'#84cc16',
|
||||
'#f97316',
|
||||
'#6366f1',
|
||||
'#a855f7',
|
||||
];
|
||||
|
||||
export function ColorPicker({ value, onChange, label, description, disabled }: ColorPickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const [hsl, setHsl] = useState(() => {
|
||||
const rgb = hexToRgb(value)
|
||||
return rgbToHsl(rgb.r, rgb.g, rgb.b)
|
||||
})
|
||||
const [pickerPosition, setPickerPosition] = useState<{ top: number; left: number; openUp: boolean }>({ top: 0, left: 0, openUp: false })
|
||||
const rgb = hexToRgb(value);
|
||||
return rgbToHsl(rgb.r, rgb.g, rgb.b);
|
||||
});
|
||||
const [pickerPosition, setPickerPosition] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
openUp: boolean;
|
||||
}>({ top: 0, left: 0, openUp: false });
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
const pickerRef = useRef<HTMLDivElement>(null)
|
||||
const colorInputRef = useRef<HTMLInputElement>(null)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const pickerRef = useRef<HTMLDivElement>(null);
|
||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isTelegram = useMemo(() => isTelegramWebApp(), [])
|
||||
const isTelegram = useMemo(() => isTelegramWebApp(), []);
|
||||
|
||||
// Sync with external value
|
||||
useEffect(() => {
|
||||
setLocalValue(value)
|
||||
const rgb = hexToRgb(value)
|
||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
|
||||
}, [value])
|
||||
setLocalValue(value);
|
||||
const rgb = hexToRgb(value);
|
||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
|
||||
}, [value]);
|
||||
|
||||
// Calculate picker position
|
||||
const updatePosition = useCallback(() => {
|
||||
if (!buttonRef.current) return
|
||||
if (!buttonRef.current) return;
|
||||
|
||||
const rect = buttonRef.current.getBoundingClientRect()
|
||||
const pickerHeight = 320
|
||||
const pickerWidth = 280
|
||||
const padding = 12
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const pickerHeight = 320;
|
||||
const pickerWidth = 280;
|
||||
const padding = 12;
|
||||
|
||||
// Check if there's space below
|
||||
const spaceBelow = window.innerHeight - rect.bottom
|
||||
const spaceAbove = rect.top
|
||||
const openUp = spaceBelow < pickerHeight + padding && spaceAbove > spaceBelow
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
const openUp = spaceBelow < pickerHeight + padding && spaceAbove > spaceBelow;
|
||||
|
||||
// Calculate left position (ensure it stays in viewport)
|
||||
let left = rect.left
|
||||
let left = rect.left;
|
||||
if (left + pickerWidth > window.innerWidth - padding) {
|
||||
left = window.innerWidth - pickerWidth - padding
|
||||
left = window.innerWidth - pickerWidth - padding;
|
||||
}
|
||||
if (left < padding) left = padding
|
||||
if (left < padding) left = padding;
|
||||
|
||||
setPickerPosition({
|
||||
top: openUp ? rect.top - pickerHeight - 8 : rect.bottom + 8,
|
||||
left,
|
||||
openUp
|
||||
})
|
||||
}, [])
|
||||
openUp,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Open picker
|
||||
const handleOpen = useCallback(() => {
|
||||
if (disabled) return
|
||||
updatePosition()
|
||||
setIsOpen(true)
|
||||
}, [disabled, updatePosition])
|
||||
if (disabled) return;
|
||||
updatePosition();
|
||||
setIsOpen(true);
|
||||
}, [disabled, updatePosition]);
|
||||
|
||||
// Close picker
|
||||
const handleClose = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
}, [])
|
||||
setIsOpen(false);
|
||||
}, []);
|
||||
|
||||
// Handle click outside
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (
|
||||
pickerRef.current && !pickerRef.current.contains(e.target as Node) &&
|
||||
buttonRef.current && !buttonRef.current.contains(e.target as Node)
|
||||
pickerRef.current &&
|
||||
!pickerRef.current.contains(e.target as Node) &&
|
||||
buttonRef.current &&
|
||||
!buttonRef.current.contains(e.target as Node)
|
||||
) {
|
||||
handleClose()
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = () => handleClose()
|
||||
const handleResize = () => updatePosition()
|
||||
const handleScroll = () => handleClose();
|
||||
const handleResize = () => updatePosition();
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('touchstart', handleClickOutside as EventListener)
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
window.addEventListener('resize', handleResize)
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('touchstart', handleClickOutside as EventListener);
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('touchstart', handleClickOutside as EventListener)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [isOpen, handleClose, updatePosition])
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('touchstart', handleClickOutside as EventListener);
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, [isOpen, handleClose, updatePosition]);
|
||||
|
||||
// Update color from HSL
|
||||
const updateColorFromHsl = useCallback((newHsl: { h: number; s: number; l: number }) => {
|
||||
const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l)
|
||||
const hex = rgbToHex(rgb.r, rgb.g, rgb.b)
|
||||
setHsl(newHsl)
|
||||
setLocalValue(hex)
|
||||
onChange(hex)
|
||||
}, [onChange])
|
||||
const updateColorFromHsl = useCallback(
|
||||
(newHsl: { h: number; s: number; l: number }) => {
|
||||
const rgb = hslToRgb(newHsl.h, newHsl.s, newHsl.l);
|
||||
const hex = rgbToHex(rgb.r, rgb.g, rgb.b);
|
||||
setHsl(newHsl);
|
||||
setLocalValue(hex);
|
||||
onChange(hex);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Handle hue change
|
||||
const handleHueChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateColorFromHsl({ ...hsl, h: parseInt(e.target.value) })
|
||||
}, [hsl, updateColorFromHsl])
|
||||
const handleHueChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateColorFromHsl({ ...hsl, h: parseInt(e.target.value) });
|
||||
},
|
||||
[hsl, updateColorFromHsl],
|
||||
);
|
||||
|
||||
// Handle saturation change
|
||||
const handleSaturationChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateColorFromHsl({ ...hsl, s: parseInt(e.target.value) })
|
||||
}, [hsl, updateColorFromHsl])
|
||||
const handleSaturationChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateColorFromHsl({ ...hsl, s: parseInt(e.target.value) });
|
||||
},
|
||||
[hsl, updateColorFromHsl],
|
||||
);
|
||||
|
||||
// Handle lightness change
|
||||
const handleLightnessChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateColorFromHsl({ ...hsl, l: parseInt(e.target.value) })
|
||||
}, [hsl, updateColorFromHsl])
|
||||
const handleLightnessChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateColorFromHsl({ ...hsl, l: parseInt(e.target.value) });
|
||||
},
|
||||
[hsl, updateColorFromHsl],
|
||||
);
|
||||
|
||||
// Handle native color input
|
||||
const handleColorInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newColor = e.target.value
|
||||
setLocalValue(newColor)
|
||||
const rgb = hexToRgb(newColor)
|
||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
|
||||
onChange(newColor)
|
||||
}, [onChange])
|
||||
const handleColorInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newColor = e.target.value;
|
||||
setLocalValue(newColor);
|
||||
const rgb = hexToRgb(newColor);
|
||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
|
||||
onChange(newColor);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Handle hex input
|
||||
const handleHexInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let newValue = e.target.value
|
||||
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)
|
||||
const handleHexInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let newValue = e.target.value;
|
||||
if (newValue && !newValue.startsWith('#')) {
|
||||
newValue = '#' + newValue;
|
||||
}
|
||||
}
|
||||
}, [onChange])
|
||||
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);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Handle preset click
|
||||
const handlePresetClick = useCallback((color: string) => {
|
||||
setLocalValue(color)
|
||||
const rgb = hexToRgb(color)
|
||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b))
|
||||
onChange(color)
|
||||
handleClose()
|
||||
}, [onChange, handleClose])
|
||||
const handlePresetClick = useCallback(
|
||||
(color: string) => {
|
||||
setLocalValue(color);
|
||||
const rgb = hexToRgb(color);
|
||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
|
||||
onChange(color);
|
||||
handleClose();
|
||||
},
|
||||
[onChange, handleClose],
|
||||
);
|
||||
|
||||
// Picker content
|
||||
const pickerContent = isOpen ? (
|
||||
<div
|
||||
ref={pickerRef}
|
||||
className="fixed z-[9999] w-[280px] bg-dark-900 rounded-2xl border border-dark-700 shadow-2xl overflow-hidden"
|
||||
className="fixed z-[9999] w-[280px] overflow-hidden rounded-2xl border border-dark-700 bg-dark-900 shadow-2xl"
|
||||
style={{
|
||||
top: pickerPosition.top,
|
||||
left: pickerPosition.left,
|
||||
@@ -240,13 +288,10 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Color preview header */}
|
||||
<div
|
||||
className="h-16 w-full"
|
||||
style={{ backgroundColor: localValue || '#000000' }}
|
||||
/>
|
||||
<div className="h-16 w-full" style={{ backgroundColor: localValue || '#000000' }} />
|
||||
|
||||
{/* Controls */}
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* Hue slider */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -259,9 +304,10 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
max="360"
|
||||
value={hsl.h}
|
||||
onChange={handleHueChange}
|
||||
className="w-full h-3 rounded-full appearance-none cursor-pointer"
|
||||
className="h-3 w-full cursor-pointer appearance-none rounded-full"
|
||||
style={{
|
||||
background: 'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)',
|
||||
background:
|
||||
'linear-gradient(to right, #ff0000, #ffff00, #00ff00, #00ffff, #0000ff, #ff00ff, #ff0000)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -278,7 +324,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
max="100"
|
||||
value={hsl.s}
|
||||
onChange={handleSaturationChange}
|
||||
className="w-full h-3 rounded-full appearance-none cursor-pointer"
|
||||
className="h-3 w-full cursor-pointer appearance-none rounded-full"
|
||||
style={{
|
||||
background: `linear-gradient(to right, hsl(${hsl.h}, 0%, ${hsl.l}%), hsl(${hsl.h}, 100%, ${hsl.l}%))`,
|
||||
}}
|
||||
@@ -297,7 +343,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
max="100"
|
||||
value={hsl.l}
|
||||
onChange={handleLightnessChange}
|
||||
className="w-full h-3 rounded-full appearance-none cursor-pointer"
|
||||
className="h-3 w-full cursor-pointer appearance-none rounded-full"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #000000, hsl(${hsl.h}, ${hsl.s}%, 50%), #ffffff)`,
|
||||
}}
|
||||
@@ -311,21 +357,21 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={handleHexInputChange}
|
||||
className="flex-1 h-9 px-3 text-sm font-mono uppercase bg-dark-800 border border-dark-700 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
className="h-9 flex-1 rounded-lg border border-dark-700 bg-dark-800 px-3 font-mono text-sm uppercase text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
placeholder="#000000"
|
||||
maxLength={7}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Presets */}
|
||||
<div className="pt-2 border-t border-dark-700">
|
||||
<span className="text-xs font-medium text-dark-400 block mb-2">Presets</span>
|
||||
<div className="border-t border-dark-700 pt-2">
|
||||
<span className="mb-2 block text-xs font-medium text-dark-400">Presets</span>
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{PRESET_COLORS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
className={`w-full aspect-square rounded-lg transition-transform hover:scale-110 active:scale-95 ${
|
||||
className={`aspect-square w-full rounded-lg transition-transform hover:scale-110 active:scale-95 ${
|
||||
localValue.toLowerCase() === preset.toLowerCase()
|
||||
? 'ring-2 ring-white ring-offset-2 ring-offset-dark-900'
|
||||
: ''
|
||||
@@ -338,12 +384,12 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="relative min-w-0 overflow-hidden">
|
||||
<label className="block text-sm font-medium text-dark-200 mb-1 truncate">{label}</label>
|
||||
{description && <p className="text-xs text-dark-500 mb-2 truncate">{description}</p>}
|
||||
<label className="mb-1 block truncate text-sm font-medium text-dark-200">{label}</label>
|
||||
{description && <p className="mb-2 truncate text-xs text-dark-500">{description}</p>}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Color preview button */}
|
||||
@@ -352,7 +398,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
disabled={disabled}
|
||||
className="w-10 h-10 rounded-xl border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0"
|
||||
className="h-10 w-10 flex-shrink-0 rounded-xl border-2 border-dark-700 shadow-inner transition-all hover:scale-105 hover:border-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style={{ backgroundColor: localValue || '#000000' }}
|
||||
title={localValue}
|
||||
/>
|
||||
@@ -363,7 +409,7 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
value={localValue}
|
||||
onChange={handleHexInputChange}
|
||||
disabled={disabled}
|
||||
className="flex-1 min-w-0 h-10 px-2 font-mono text-sm uppercase bg-dark-800 border border-dark-700 rounded-xl text-dark-100 focus:outline-none focus:border-accent-500 disabled:opacity-50"
|
||||
className="h-10 min-w-0 flex-1 rounded-xl border border-dark-700 bg-dark-800 px-2 font-mono text-sm uppercase text-dark-100 focus:border-accent-500 focus:outline-none disabled:opacity-50"
|
||||
placeholder="#000000"
|
||||
maxLength={7}
|
||||
/>
|
||||
@@ -383,11 +429,21 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
type="button"
|
||||
onClick={() => colorInputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50 flex-shrink-0"
|
||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
|
||||
title="System color picker"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z" />
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
@@ -397,5 +453,5 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
||||
{/* Render picker in portal */}
|
||||
{typeof document !== 'undefined' && createPortal(pickerContent, document.body)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,20 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { balanceApi } from '../api/balance'
|
||||
import { useCurrency } from '../hooks/useCurrency'
|
||||
import TopUpModal from './TopUpModal'
|
||||
import type { PaymentMethod } from '../types'
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { balanceApi } from '../api/balance';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import TopUpModal from './TopUpModal';
|
||||
import type { PaymentMethod } from '../types';
|
||||
|
||||
interface InsufficientBalancePromptProps {
|
||||
/** Amount missing in kopeks */
|
||||
missingAmountKopeks: number
|
||||
missingAmountKopeks: number;
|
||||
/** Optional custom message */
|
||||
message?: string
|
||||
message?: string;
|
||||
/** Compact mode for inline use */
|
||||
compact?: boolean
|
||||
compact?: boolean;
|
||||
/** Additional className */
|
||||
className?: string
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function InsufficientBalancePrompt({
|
||||
@@ -23,40 +23,55 @@ export default function InsufficientBalancePrompt({
|
||||
compact = false,
|
||||
className = '',
|
||||
}: InsufficientBalancePromptProps) {
|
||||
const { t } = useTranslation()
|
||||
const { formatAmount, currencySymbol } = useCurrency()
|
||||
const [showMethodSelect, setShowMethodSelect] = useState(false)
|
||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null)
|
||||
const { t } = useTranslation();
|
||||
const { formatAmount, currencySymbol } = useCurrency();
|
||||
const [showMethodSelect, setShowMethodSelect] = useState(false);
|
||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null);
|
||||
|
||||
const { data: paymentMethods } = useQuery({
|
||||
queryKey: ['payment-methods'],
|
||||
queryFn: balanceApi.getPaymentMethods,
|
||||
enabled: showMethodSelect,
|
||||
})
|
||||
});
|
||||
|
||||
const missingRubles = missingAmountKopeks / 100
|
||||
const displayAmount = formatAmount(missingRubles)
|
||||
const missingRubles = missingAmountKopeks / 100;
|
||||
const displayAmount = formatAmount(missingRubles);
|
||||
|
||||
const handleMethodSelect = (method: PaymentMethod) => {
|
||||
setSelectedMethod(method)
|
||||
setShowMethodSelect(false)
|
||||
}
|
||||
setSelectedMethod(method);
|
||||
setShowMethodSelect(false);
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<>
|
||||
<div className={`flex items-center justify-between gap-3 p-3 bg-error-500/10 border border-error-500/30 rounded-xl ${className}`}>
|
||||
<div
|
||||
className={`flex items-center justify-between gap-3 rounded-xl border border-error-500/30 bg-error-500/10 p-3 ${className}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm text-error-400">
|
||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
<svg
|
||||
className="h-4 w-4 flex-shrink-0"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
{message || t('balance.insufficientFunds')}: <span className="font-semibold">{displayAmount} {currencySymbol}</span>
|
||||
{message || t('balance.insufficientFunds')}:{' '}
|
||||
<span className="font-semibold">
|
||||
{displayAmount} {currencySymbol}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowMethodSelect(true)}
|
||||
className="btn-primary text-xs py-1.5 px-3 whitespace-nowrap"
|
||||
className="btn-primary whitespace-nowrap px-3 py-1.5 text-xs"
|
||||
>
|
||||
{t('balance.topUp')}
|
||||
</button>
|
||||
@@ -78,37 +93,54 @@ export default function InsufficientBalancePrompt({
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`p-4 bg-gradient-to-br from-error-500/10 to-warning-500/5 border border-error-500/30 rounded-xl ${className}`}>
|
||||
<div
|
||||
className={`rounded-xl border border-error-500/30 bg-gradient-to-br from-error-500/10 to-warning-500/5 p-4 ${className}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-error-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-error-500/20">
|
||||
<svg
|
||||
className="h-5 w-5 text-error-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-error-400 font-medium mb-1">
|
||||
{t('balance.insufficientFunds')}
|
||||
</div>
|
||||
<div className="text-dark-300 text-sm">
|
||||
{message || t('balance.topUpToComplete')}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 font-medium text-error-400">{t('balance.insufficientFunds')}</div>
|
||||
<div className="text-sm text-dark-300">{message || t('balance.topUpToComplete')}</div>
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<div className="text-lg font-bold text-dark-100">
|
||||
{t('balance.missing')}: <span className="text-error-400">{displayAmount} {currencySymbol}</span>
|
||||
{t('balance.missing')}:{' '}
|
||||
<span className="text-error-400">
|
||||
{displayAmount} {currencySymbol}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowMethodSelect(true)}
|
||||
className="btn-primary w-full mt-4 py-2.5 flex items-center justify-center gap-2"
|
||||
className="btn-primary mt-4 flex w-full items-center justify-center gap-2 py-2.5"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
{t('balance.topUpBalance')}
|
||||
@@ -131,80 +163,115 @@ export default function InsufficientBalancePrompt({
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
interface PaymentMethodModalProps {
|
||||
paymentMethods: PaymentMethod[] | undefined
|
||||
onSelect: (method: PaymentMethod) => void
|
||||
onClose: () => void
|
||||
paymentMethods: PaymentMethod[] | undefined;
|
||||
onSelect: (method: PaymentMethod) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethodModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const { formatAmount, currencySymbol } = useCurrency()
|
||||
const { t } = useTranslation();
|
||||
const { formatAmount, currencySymbol } = useCurrency();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-sm z-[60] flex items-center justify-center px-4 pt-14 pb-28 sm:pt-0 sm:pb-0">
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 px-4 pb-28 pt-14 backdrop-blur-sm sm:pb-0 sm:pt-0">
|
||||
<div className="absolute inset-0" onClick={onClose} />
|
||||
|
||||
<div className="relative w-full max-w-sm bg-dark-900/95 backdrop-blur-xl rounded-3xl border border-dark-700/50 shadow-2xl overflow-hidden max-h-full flex flex-col">
|
||||
<div className="relative flex max-h-full w-full max-w-sm flex-col overflow-hidden rounded-3xl border border-dark-700/50 bg-dark-900/95 shadow-2xl backdrop-blur-xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 bg-dark-800/50">
|
||||
<div className="flex items-center justify-between bg-dark-800/50 px-4 py-3">
|
||||
<span className="font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</span>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-dark-700 text-dark-400" aria-label="Close">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-1.5 text-dark-400 hover:bg-dark-700"
|
||||
aria-label="Close"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
<div className="flex-1 space-y-2 overflow-y-auto p-3">
|
||||
{!paymentMethods ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : paymentMethods.length === 0 ? (
|
||||
<div className="text-center py-6 text-dark-400 text-sm">
|
||||
<div className="py-6 text-center text-sm text-dark-400">
|
||||
{t('balance.noPaymentMethods')}
|
||||
</div>
|
||||
) : (
|
||||
paymentMethods.map((method) => {
|
||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_')
|
||||
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' })
|
||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
|
||||
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, {
|
||||
defaultValue: '',
|
||||
});
|
||||
|
||||
return (
|
||||
<button
|
||||
key={method.id}
|
||||
disabled={!method.is_available}
|
||||
onClick={() => method.is_available && onSelect(method)}
|
||||
className={`w-full p-3 rounded-xl text-left flex items-center gap-3 ${
|
||||
className={`flex w-full items-center gap-3 rounded-xl p-3 text-left ${
|
||||
method.is_available
|
||||
? 'bg-dark-800 hover:bg-dark-700 active:bg-dark-600'
|
||||
: 'bg-dark-800/50 opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded-lg bg-accent-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-4 h-4 text-accent-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
||||
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-accent-500/20">
|
||||
<svg
|
||||
className="h-4 w-4 text-accent-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-dark-100 text-sm">{translatedName || method.name}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-dark-100">
|
||||
{translatedName || method.name}
|
||||
</div>
|
||||
<div className="text-xs text-dark-500">
|
||||
{formatAmount(method.min_amount_kopeks / 100, 0)} – {formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
|
||||
{formatAmount(method.min_amount_kopeks / 100, 0)} –{' '}
|
||||
{formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
|
||||
</div>
|
||||
</div>
|
||||
<svg className="w-4 h-4 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
<svg
|
||||
className="h-4 w-4 text-dark-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
|
||||
const languages = [
|
||||
{ code: 'ru', name: 'RU', flag: '🇷🇺', fullName: 'Русский' },
|
||||
{ code: 'en', name: 'EN', flag: '🇬🇧', fullName: 'English' },
|
||||
{ code: 'zh', name: 'ZH', flag: '🇨🇳', fullName: '中文' },
|
||||
{ code: 'fa', name: 'FA', flag: '🇮🇷', fullName: 'فارسی' },
|
||||
]
|
||||
];
|
||||
|
||||
export default function LanguageSwitcher() {
|
||||
const { i18n } = useTranslation()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const { i18n } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const currentLang = languages.find((l) => l.code === i18n.language) || languages[0]
|
||||
const currentLang = languages.find((l) => l.code === i18n.language) || languages[0];
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const changeLanguage = (code: string) => {
|
||||
i18n.changeLanguage(code)
|
||||
i18n.changeLanguage(code);
|
||||
// Set document direction for RTL languages
|
||||
document.documentElement.dir = code === 'fa' ? 'rtl' : 'ltr'
|
||||
setIsOpen(false)
|
||||
}
|
||||
document.documentElement.dir = code === 'fa' ? 'rtl' : 'ltr';
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// Set initial direction on mount
|
||||
useEffect(() => {
|
||||
document.documentElement.dir = i18n.language === 'fa' ? 'rtl' : 'ltr'
|
||||
}, [i18n.language])
|
||||
document.documentElement.dir = i18n.language === 'fa' ? 'rtl' : 'ltr';
|
||||
}, [i18n.language]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-1.5 px-2.5 py-2 rounded-xl border border-dark-700/50 hover:border-dark-600 bg-dark-800/50 hover:bg-dark-700 transition-all text-sm"
|
||||
className="flex items-center gap-1.5 rounded-xl border border-dark-700/50 bg-dark-800/50 px-2.5 py-2 text-sm transition-all hover:border-dark-600 hover:bg-dark-700"
|
||||
aria-label="Change language"
|
||||
>
|
||||
<span>{currentLang.flag}</span>
|
||||
<span className="font-medium text-dark-200">{currentLang.name}</span>
|
||||
<svg
|
||||
className={`w-3.5 h-3.5 text-dark-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
className={`h-3.5 w-3.5 text-dark-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -57,12 +57,12 @@ export default function LanguageSwitcher() {
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-40 bg-dark-800 rounded-xl shadow-lg border border-dark-700/50 py-1 z-50 animate-fade-in">
|
||||
<div className="absolute right-0 z-50 mt-2 w-40 animate-fade-in rounded-xl border border-dark-700/50 bg-dark-800 py-1 shadow-lg">
|
||||
{languages.map((lang) => (
|
||||
<button
|
||||
key={lang.code}
|
||||
onClick={() => changeLanguage(lang.code)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||
className={`flex w-full items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||
lang.code === i18n.language
|
||||
? 'bg-accent-500/10 text-accent-400'
|
||||
: 'text-dark-300 hover:bg-dark-700/50'
|
||||
@@ -75,5 +75,5 @@ export default function LanguageSwitcher() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,146 +1,147 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface OnboardingStep {
|
||||
target: string // data-onboarding attribute value
|
||||
title: string
|
||||
description: string
|
||||
placement: 'top' | 'bottom' | 'left' | 'right'
|
||||
target: string; // data-onboarding attribute value
|
||||
title: string;
|
||||
description: string;
|
||||
placement: 'top' | 'bottom' | 'left' | 'right';
|
||||
}
|
||||
|
||||
interface OnboardingProps {
|
||||
steps: OnboardingStep[]
|
||||
onComplete: () => void
|
||||
onSkip: () => void
|
||||
steps: OnboardingStep[];
|
||||
onComplete: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'onboarding_completed'
|
||||
const STORAGE_KEY = 'onboarding_completed';
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useOnboarding() {
|
||||
const [isCompleted, setIsCompleted] = useState(() => {
|
||||
return localStorage.getItem(STORAGE_KEY) === 'true'
|
||||
})
|
||||
return localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
});
|
||||
|
||||
const complete = useCallback(() => {
|
||||
localStorage.setItem(STORAGE_KEY, 'true')
|
||||
setIsCompleted(true)
|
||||
}, [])
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
setIsCompleted(true);
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
setIsCompleted(false)
|
||||
}, [])
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setIsCompleted(false);
|
||||
}, []);
|
||||
|
||||
return { isCompleted, complete, reset }
|
||||
return { isCompleted, complete, reset };
|
||||
}
|
||||
|
||||
export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProps) {
|
||||
const { t } = useTranslation()
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [targetRect, setTargetRect] = useState<DOMRect | null>(null)
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const tooltipRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [targetRect, setTargetRect] = useState<DOMRect | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const step = steps[currentStep]
|
||||
const step = steps[currentStep];
|
||||
|
||||
// Find and highlight target element
|
||||
useEffect(() => {
|
||||
const findTarget = () => {
|
||||
const target = document.querySelector(`[data-onboarding="${step.target}"]`)
|
||||
const target = document.querySelector(`[data-onboarding="${step.target}"]`);
|
||||
if (target) {
|
||||
const rect = target.getBoundingClientRect()
|
||||
setTargetRect(rect)
|
||||
const rect = target.getBoundingClientRect();
|
||||
setTargetRect(rect);
|
||||
|
||||
// Scroll element into view if needed
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
// Delay visibility for smooth animation
|
||||
setTimeout(() => setIsVisible(true), 100)
|
||||
setTimeout(() => setIsVisible(true), 100);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setIsVisible(false)
|
||||
const timer = setTimeout(findTarget, 300)
|
||||
return () => clearTimeout(timer)
|
||||
}, [step.target])
|
||||
setIsVisible(false);
|
||||
const timer = setTimeout(findTarget, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [step.target]);
|
||||
|
||||
// Recalculate position on resize/scroll
|
||||
useEffect(() => {
|
||||
const updatePosition = () => {
|
||||
const target = document.querySelector(`[data-onboarding="${step.target}"]`)
|
||||
const target = document.querySelector(`[data-onboarding="${step.target}"]`);
|
||||
if (target) {
|
||||
setTargetRect(target.getBoundingClientRect())
|
||||
setTargetRect(target.getBoundingClientRect());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('resize', updatePosition)
|
||||
window.addEventListener('scroll', updatePosition, true)
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition)
|
||||
window.removeEventListener('scroll', updatePosition, true)
|
||||
}
|
||||
}, [step.target])
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
};
|
||||
}, [step.target]);
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setCurrentStep(currentStep + 1)
|
||||
setCurrentStep(currentStep + 1);
|
||||
} else {
|
||||
onComplete()
|
||||
onComplete();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1)
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
onSkip()
|
||||
}
|
||||
onSkip();
|
||||
};
|
||||
|
||||
// Calculate tooltip position
|
||||
const getTooltipStyle = (): React.CSSProperties => {
|
||||
if (!targetRect) return { opacity: 0 }
|
||||
if (!targetRect) return { opacity: 0 };
|
||||
|
||||
const padding = 16
|
||||
const tooltipWidth = 320
|
||||
const tooltipHeight = tooltipRef.current?.offsetHeight || 150
|
||||
const padding = 16;
|
||||
const tooltipWidth = 320;
|
||||
const tooltipHeight = tooltipRef.current?.offsetHeight || 150;
|
||||
|
||||
let top = 0
|
||||
let left = 0
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
|
||||
switch (step.placement) {
|
||||
case 'bottom':
|
||||
top = targetRect.bottom + padding
|
||||
left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2
|
||||
break
|
||||
top = targetRect.bottom + padding;
|
||||
left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2;
|
||||
break;
|
||||
case 'top':
|
||||
top = targetRect.top - tooltipHeight - padding
|
||||
left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2
|
||||
break
|
||||
top = targetRect.top - tooltipHeight - padding;
|
||||
left = targetRect.left + targetRect.width / 2 - tooltipWidth / 2;
|
||||
break;
|
||||
case 'left':
|
||||
top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2
|
||||
left = targetRect.left - tooltipWidth - padding
|
||||
break
|
||||
top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2;
|
||||
left = targetRect.left - tooltipWidth - padding;
|
||||
break;
|
||||
case 'right':
|
||||
top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2
|
||||
left = targetRect.right + padding
|
||||
break
|
||||
top = targetRect.top + targetRect.height / 2 - tooltipHeight / 2;
|
||||
left = targetRect.right + padding;
|
||||
break;
|
||||
}
|
||||
|
||||
// Keep within viewport
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
if (left < padding) left = padding
|
||||
if (left < padding) left = padding;
|
||||
if (left + tooltipWidth > viewportWidth - padding) {
|
||||
left = viewportWidth - tooltipWidth - padding
|
||||
left = viewportWidth - tooltipWidth - padding;
|
||||
}
|
||||
if (top < padding) top = padding
|
||||
if (top < padding) top = padding;
|
||||
if (top + tooltipHeight > viewportHeight - padding) {
|
||||
top = viewportHeight - tooltipHeight - padding
|
||||
top = viewportHeight - tooltipHeight - padding;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -149,22 +150,22 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
|
||||
width: tooltipWidth,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? 'scale(1)' : 'scale(0.95)',
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Spotlight style
|
||||
const getSpotlightStyle = (): React.CSSProperties => {
|
||||
if (!targetRect) return { opacity: 0 }
|
||||
if (!targetRect) return { opacity: 0 };
|
||||
|
||||
const padding = 8
|
||||
const padding = 8;
|
||||
return {
|
||||
top: targetRect.top - padding,
|
||||
left: targetRect.left - padding,
|
||||
width: targetRect.width + padding * 2,
|
||||
height: targetRect.height + padding * 2,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="onboarding-overlay" style={{ opacity: isVisible ? 1 : 0 }}>
|
||||
@@ -178,7 +179,7 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
|
||||
style={getTooltipStyle()}
|
||||
>
|
||||
{/* Progress indicator */}
|
||||
<div className="flex items-center gap-1.5 mb-4">
|
||||
<div className="mb-4 flex items-center gap-1.5">
|
||||
{steps.map((s, index) => (
|
||||
<div
|
||||
key={s.target}
|
||||
@@ -186,33 +187,33 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
|
||||
index === currentStep
|
||||
? 'w-6 bg-accent-500'
|
||||
: index < currentStep
|
||||
? 'w-2 bg-accent-500/50'
|
||||
: 'w-2 bg-dark-700'
|
||||
? 'w-2 bg-accent-500/50'
|
||||
: 'w-2 bg-dark-700'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h3 className="text-lg font-semibold text-dark-50 mb-2">{step.title}</h3>
|
||||
<p className="text-dark-400 text-sm mb-5">{step.description}</p>
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-50">{step.title}</h3>
|
||||
<p className="mb-5 text-sm text-dark-400">{step.description}</p>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="text-dark-500 hover:text-dark-300 text-sm transition-colors"
|
||||
className="text-sm text-dark-500 transition-colors hover:text-dark-300"
|
||||
>
|
||||
{t('onboarding.skip', 'Skip')}
|
||||
</button>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{currentStep > 0 && (
|
||||
<button onClick={handlePrev} className="btn-ghost text-sm px-3 py-1.5">
|
||||
<button onClick={handlePrev} className="btn-ghost px-3 py-1.5 text-sm">
|
||||
{t('common.back', 'Back')}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={handleNext} className="btn-primary text-sm px-4 py-1.5">
|
||||
<button onClick={handleNext} className="btn-primary px-4 py-1.5 text-sm">
|
||||
{currentStep === steps.length - 1
|
||||
? t('onboarding.finish', 'Finish')
|
||||
: t('common.next', 'Next')}
|
||||
@@ -235,6 +236,6 @@ export default function Onboarding({ steps, onComplete, onSkip }: OnboardingProp
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,97 +1,111 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { promoApi } from '../api/promo'
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { promoApi } from '../api/promo';
|
||||
|
||||
const SparklesIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ClockIcon = () => (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const formatTimeLeft = (expiresAt: string, t: (key: string) => string): string => {
|
||||
const now = new Date()
|
||||
const now = new Date();
|
||||
// Ensure UTC parsing - if no timezone specified, assume UTC
|
||||
let expires: Date
|
||||
let expires: Date;
|
||||
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
|
||||
expires = new Date(expiresAt)
|
||||
expires = new Date(expiresAt);
|
||||
} else {
|
||||
// No timezone - treat as UTC
|
||||
expires = new Date(expiresAt + 'Z')
|
||||
expires = new Date(expiresAt + 'Z');
|
||||
}
|
||||
const diffMs = expires.getTime() - now.getTime()
|
||||
const diffMs = expires.getTime() - now.getTime();
|
||||
|
||||
if (diffMs <= 0) return ''
|
||||
if (diffMs <= 0) return '';
|
||||
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 24) {
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days}${t('promo.time.days')}`
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}${t('promo.time.days')}`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}${t('promo.time.hours')} ${minutes}${t('promo.time.minutes')}`
|
||||
return `${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() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: activeDiscount } = useQuery({
|
||||
queryKey: ['active-discount'],
|
||||
queryFn: promoApi.getActiveDiscount,
|
||||
staleTime: 30000,
|
||||
refetchInterval: 60000, // Refresh every minute
|
||||
})
|
||||
});
|
||||
|
||||
// Close dropdown on click outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Don't render if no active discount
|
||||
if (!activeDiscount || !activeDiscount.is_active || !activeDiscount.discount_percent) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeLeft = activeDiscount.expires_at ? formatTimeLeft(activeDiscount.expires_at, t) : null
|
||||
const timeLeft = activeDiscount.expires_at ? formatTimeLeft(activeDiscount.expires_at, t) : null;
|
||||
|
||||
const handleClick = () => {
|
||||
setIsOpen(!isOpen)
|
||||
}
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const handleGoToSubscription = () => {
|
||||
setIsOpen(false)
|
||||
navigate('/subscription')
|
||||
}
|
||||
setIsOpen(false);
|
||||
navigate('/subscription');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Badge button */}
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className="relative flex items-center gap-1 px-2.5 py-1.5 rounded-lg bg-success-500/15 hover:bg-success-500/25 transition-all group"
|
||||
className="group relative flex items-center gap-1 rounded-lg bg-success-500/15 px-2.5 py-1.5 transition-all hover:bg-success-500/25"
|
||||
title={t('promo.activeDiscount', 'Active discount')}
|
||||
>
|
||||
<span className="font-bold text-success-400 text-sm">
|
||||
<span className="text-sm font-bold text-success-400">
|
||||
-{activeDiscount.discount_percent}%
|
||||
</span>
|
||||
</button>
|
||||
@@ -101,21 +115,19 @@ export default function PromoDiscountBadge() {
|
||||
<>
|
||||
{/* Mobile backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/30 z-40 sm:hidden"
|
||||
className="fixed inset-0 z-40 bg-black/30 sm:hidden"
|
||||
onClick={() => setIsOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="fixed left-4 right-4 top-20 sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-72 bg-dark-800 rounded-xl shadow-xl border border-dark-700/50 z-50 animate-fade-in overflow-hidden">
|
||||
<div className="fixed left-4 right-4 top-20 z-50 animate-fade-in overflow-hidden rounded-xl border border-dark-700/50 bg-dark-800 shadow-xl sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-72">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-success-500/20 to-accent-500/20 p-4 border-b border-dark-700/50">
|
||||
<div className="border-b border-dark-700/50 bg-gradient-to-r from-success-500/20 to-accent-500/20 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-success-500/20 flex items-center justify-center text-success-400">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-success-500/20 text-success-400">
|
||||
<SparklesIcon />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold text-dark-100">
|
||||
{t('promo.discountActive')}
|
||||
</div>
|
||||
<div className="font-semibold text-dark-100">{t('promo.discountActive')}</div>
|
||||
<div className="text-2xl font-bold text-success-400">
|
||||
-{activeDiscount.discount_percent}%
|
||||
</div>
|
||||
@@ -124,23 +136,24 @@ export default function PromoDiscountBadge() {
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-3">
|
||||
<p className="text-sm text-dark-300">
|
||||
{t('promo.discountDescription')}
|
||||
</p>
|
||||
<div className="space-y-3 p-4">
|
||||
<p className="text-sm text-dark-300">{t('promo.discountDescription')}</p>
|
||||
|
||||
{/* Time remaining */}
|
||||
{timeLeft && (
|
||||
<div className="flex items-center gap-2 text-sm text-dark-400 bg-dark-900/50 px-3 py-2 rounded-lg">
|
||||
<div className="flex items-center gap-2 rounded-lg bg-dark-900/50 px-3 py-2 text-sm text-dark-400">
|
||||
<ClockIcon />
|
||||
<span>{t('promo.expiresIn')}: <span className="text-warning-400 font-medium">{timeLeft}</span></span>
|
||||
<span>
|
||||
{t('promo.expiresIn')}:{' '}
|
||||
<span className="font-medium text-warning-400">{timeLeft}</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA Button */}
|
||||
<button
|
||||
onClick={handleGoToSubscription}
|
||||
className="w-full btn-primary py-2.5 text-sm font-medium"
|
||||
className="btn-primary w-full py-2.5 text-sm font-medium"
|
||||
>
|
||||
{t('promo.useNow')}
|
||||
</button>
|
||||
@@ -149,5 +162,5 @@ export default function PromoDiscountBadge() {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,144 +1,165 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { promoApi, PromoOffer } from '../api/promo'
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { promoApi, PromoOffer } from '../api/promo';
|
||||
|
||||
// Icons
|
||||
const GiftIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ClockIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const SparklesIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ServerIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
// Helper functions
|
||||
const formatTimeLeft = (expiresAt: string): string => {
|
||||
const now = new Date()
|
||||
const now = new Date();
|
||||
// Ensure UTC parsing - if no timezone specified, assume UTC
|
||||
let expires: Date
|
||||
let expires: Date;
|
||||
if (expiresAt.includes('Z') || expiresAt.includes('+') || expiresAt.includes('-', 10)) {
|
||||
expires = new Date(expiresAt)
|
||||
expires = new Date(expiresAt);
|
||||
} else {
|
||||
// No timezone - treat as UTC
|
||||
expires = new Date(expiresAt + 'Z')
|
||||
expires = new Date(expiresAt + 'Z');
|
||||
}
|
||||
const diffMs = expires.getTime() - now.getTime()
|
||||
const diffMs = expires.getTime() - now.getTime();
|
||||
|
||||
if (diffMs <= 0) return 'Истекло'
|
||||
if (diffMs <= 0) return 'Истекло';
|
||||
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60))
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60));
|
||||
|
||||
if (hours > 24) {
|
||||
const days = Math.floor(hours / 24)
|
||||
return `${days} дн.`
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} дн.`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}ч ${minutes}м`
|
||||
return `${hours}ч ${minutes}м`;
|
||||
}
|
||||
return `${minutes}м`
|
||||
}
|
||||
return `${minutes}м`;
|
||||
};
|
||||
|
||||
const getOfferIcon = (effectType: string) => {
|
||||
if (effectType === 'test_access') return <ServerIcon />
|
||||
return <SparklesIcon />
|
||||
}
|
||||
if (effectType === 'test_access') return <ServerIcon />;
|
||||
return <SparklesIcon />;
|
||||
};
|
||||
|
||||
const getOfferTitle = (offer: PromoOffer): string => {
|
||||
if (offer.effect_type === 'test_access') {
|
||||
return 'Тестовый доступ'
|
||||
return 'Тестовый доступ';
|
||||
}
|
||||
if (offer.discount_percent) {
|
||||
return `Скидка ${offer.discount_percent}%`
|
||||
return `Скидка ${offer.discount_percent}%`;
|
||||
}
|
||||
return 'Специальное предложение'
|
||||
}
|
||||
return 'Специальное предложение';
|
||||
};
|
||||
|
||||
const getOfferDescription = (offer: PromoOffer): string => {
|
||||
if (offer.effect_type === 'test_access') {
|
||||
const squads = offer.extra_data?.test_squad_uuids?.length || 0
|
||||
return squads > 0 ? `Доступ к ${squads} серверам` : 'Доступ к дополнительным серверам'
|
||||
const squads = offer.extra_data?.test_squad_uuids?.length || 0;
|
||||
return squads > 0 ? `Доступ к ${squads} серверам` : 'Доступ к дополнительным серверам';
|
||||
}
|
||||
return 'Активируйте скидку на покупку подписки'
|
||||
}
|
||||
return 'Активируйте скидку на покупку подписки';
|
||||
};
|
||||
|
||||
interface PromoOffersSectionProps {
|
||||
className?: string
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function PromoOffersSection({ className = '' }: PromoOffersSectionProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [claimingId, setClaimingId] = useState<number | null>(null)
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null)
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const queryClient = useQueryClient();
|
||||
const [claimingId, setClaimingId] = useState<number | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Fetch available offers
|
||||
const { data: offers = [], isLoading: offersLoading } = useQuery({
|
||||
queryKey: ['promo-offers'],
|
||||
queryFn: promoApi.getOffers,
|
||||
staleTime: 30000,
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch active discount
|
||||
const { data: activeDiscount } = useQuery({
|
||||
queryKey: ['active-discount'],
|
||||
queryFn: promoApi.getActiveDiscount,
|
||||
staleTime: 30000,
|
||||
})
|
||||
});
|
||||
|
||||
// Claim offer mutation
|
||||
const claimMutation = useMutation({
|
||||
mutationFn: promoApi.claimOffer,
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['promo-offers'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['active-discount'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] })
|
||||
setSuccessMessage(result.message)
|
||||
setClaimingId(null)
|
||||
setTimeout(() => setSuccessMessage(null), 5000)
|
||||
queryClient.invalidateQueries({ queryKey: ['promo-offers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['active-discount'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription'] });
|
||||
setSuccessMessage(result.message);
|
||||
setClaimingId(null);
|
||||
setTimeout(() => setSuccessMessage(null), 5000);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setErrorMessage(error.response?.data?.detail || 'Не удалось активировать предложение')
|
||||
setClaimingId(null)
|
||||
setTimeout(() => setErrorMessage(null), 5000)
|
||||
onError: (error: unknown) => {
|
||||
const axiosErr = error as { response?: { data?: { detail?: string } } };
|
||||
setErrorMessage(axiosErr.response?.data?.detail || 'Не удалось активировать предложение');
|
||||
setClaimingId(null);
|
||||
setTimeout(() => setErrorMessage(null), 5000);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleClaim = (offerId: number) => {
|
||||
setClaimingId(offerId)
|
||||
setErrorMessage(null)
|
||||
setSuccessMessage(null)
|
||||
claimMutation.mutate(offerId)
|
||||
}
|
||||
setClaimingId(offerId);
|
||||
setErrorMessage(null);
|
||||
setSuccessMessage(null);
|
||||
claimMutation.mutate(offerId);
|
||||
};
|
||||
|
||||
// Filter unclaimed and active offers
|
||||
const availableOffers = offers.filter(o => o.is_active && !o.is_claimed)
|
||||
const availableOffers = offers.filter((o) => o.is_active && !o.is_claimed);
|
||||
|
||||
// Don't render if no offers and no active discount
|
||||
if (!offersLoading && availableOffers.length === 0 && (!activeDiscount || !activeDiscount.is_active)) {
|
||||
return null
|
||||
if (
|
||||
!offersLoading &&
|
||||
availableOffers.length === 0 &&
|
||||
(!activeDiscount || !activeDiscount.is_active)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -147,15 +168,15 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
||||
{activeDiscount && activeDiscount.is_active && activeDiscount.discount_percent > 0 && (
|
||||
<div className="card border-accent-500/30 bg-gradient-to-br from-accent-500/10 to-transparent">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-accent-500/20 flex items-center justify-center flex-shrink-0 text-accent-400">
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-accent-500/20 text-accent-400">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h3 className="font-semibold text-dark-100">
|
||||
Скидка {activeDiscount.discount_percent}% активна
|
||||
</h3>
|
||||
<span className="px-2 py-0.5 text-xs bg-accent-500/20 text-accent-400 rounded">
|
||||
<span className="rounded bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
|
||||
Действует
|
||||
</span>
|
||||
</div>
|
||||
@@ -174,14 +195,14 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
{successMessage && (
|
||||
<div className="p-4 bg-success-500/10 border border-success-500/30 text-success-400 rounded-xl flex items-center gap-3">
|
||||
<div className="flex items-center gap-3 rounded-xl border border-success-500/30 bg-success-500/10 p-4 text-success-400">
|
||||
<CheckIcon />
|
||||
<span>{successMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{errorMessage && (
|
||||
<div className="p-4 bg-error-500/10 border border-error-500/30 text-error-400 rounded-xl">
|
||||
<div className="rounded-xl border border-error-500/30 bg-error-500/10 p-4 text-error-400">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
@@ -192,26 +213,22 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
||||
{availableOffers.map((offer) => (
|
||||
<div
|
||||
key={offer.id}
|
||||
className="card border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-transparent hover:border-orange-500/50 transition-colors"
|
||||
className="card border-orange-500/30 bg-gradient-to-br from-orange-500/5 to-transparent transition-colors hover:border-orange-500/50"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-orange-500/20 flex items-center justify-center flex-shrink-0 text-orange-400">
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl bg-orange-500/20 text-orange-400">
|
||||
{getOfferIcon(offer.effect_type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-semibold text-dark-100">
|
||||
{getOfferTitle(offer)}
|
||||
</h3>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<h3 className="font-semibold text-dark-100">{getOfferTitle(offer)}</h3>
|
||||
{offer.effect_type === 'test_access' && (
|
||||
<span className="px-2 py-0.5 text-xs bg-purple-500/20 text-purple-400 rounded">
|
||||
<span className="rounded bg-purple-500/20 px-2 py-0.5 text-xs text-purple-400">
|
||||
Тест
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-dark-400 mb-3">
|
||||
{getOfferDescription(offer)}
|
||||
</p>
|
||||
<p className="mb-3 text-sm text-dark-400">{getOfferDescription(offer)}</p>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-1 text-xs text-dark-500">
|
||||
<ClockIcon />
|
||||
@@ -220,11 +237,11 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
||||
<button
|
||||
onClick={() => handleClaim(offer.id)}
|
||||
disabled={claimingId === offer.id}
|
||||
className="px-4 py-2 bg-orange-500 text-white text-sm font-medium rounded-lg hover:bg-orange-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
className="flex items-center gap-2 rounded-lg bg-orange-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-orange-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{claimingId === offer.id ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
<span>Активация...</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -246,14 +263,14 @@ export default function PromoOffersSection({ className = '' }: PromoOffersSectio
|
||||
{offersLoading && (
|
||||
<div className="card">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-dark-700 animate-pulse" />
|
||||
<div className="h-12 w-12 animate-pulse rounded-xl bg-dark-700" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-5 w-32 bg-dark-700 rounded animate-pulse" />
|
||||
<div className="h-4 w-48 bg-dark-700 rounded animate-pulse" />
|
||||
<div className="h-5 w-32 animate-pulse rounded bg-dark-700" />
|
||||
<div className="h-4 w-48 animate-pulse rounded bg-dark-700" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,45 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface TelegramLoginButtonProps {
|
||||
botUsername: string
|
||||
botUsername: string;
|
||||
}
|
||||
|
||||
export default function TelegramLoginButton({
|
||||
botUsername,
|
||||
}: TelegramLoginButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
export default function TelegramLoginButton({ botUsername }: TelegramLoginButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load widget script
|
||||
useEffect(() => {
|
||||
if (!containerRef.current || !botUsername) return
|
||||
if (!containerRef.current || !botUsername) return;
|
||||
|
||||
// Clear previous widget using safe DOM API
|
||||
while (containerRef.current.firstChild) {
|
||||
containerRef.current.removeChild(containerRef.current.firstChild)
|
||||
containerRef.current.removeChild(containerRef.current.firstChild);
|
||||
}
|
||||
|
||||
// Get current URL for redirect
|
||||
const redirectUrl = `${window.location.origin}/auth/telegram/callback`
|
||||
const redirectUrl = `${window.location.origin}/auth/telegram/callback`;
|
||||
|
||||
// Create script element for Telegram Login Widget
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://telegram.org/js/telegram-widget.js?22'
|
||||
script.setAttribute('data-telegram-login', botUsername)
|
||||
script.setAttribute('data-size', 'large')
|
||||
script.setAttribute('data-radius', '8')
|
||||
script.setAttribute('data-auth-url', redirectUrl)
|
||||
script.setAttribute('data-request-access', 'write')
|
||||
script.async = true
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://telegram.org/js/telegram-widget.js?22';
|
||||
script.setAttribute('data-telegram-login', botUsername);
|
||||
script.setAttribute('data-size', 'large');
|
||||
script.setAttribute('data-radius', '8');
|
||||
script.setAttribute('data-auth-url', redirectUrl);
|
||||
script.setAttribute('data-request-access', 'write');
|
||||
script.async = true;
|
||||
|
||||
containerRef.current.appendChild(script)
|
||||
}, [botUsername])
|
||||
containerRef.current.appendChild(script);
|
||||
}, [botUsername]);
|
||||
|
||||
if (!botUsername || botUsername === 'your_bot') {
|
||||
return (
|
||||
<div className="text-center text-gray-500 text-sm py-4">
|
||||
<div className="py-4 text-center text-sm text-gray-500">
|
||||
{t('auth.telegramNotConfigured')}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -51,19 +49,19 @@ export default function TelegramLoginButton({
|
||||
|
||||
{/* Fallback link for mobile */}
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500 mb-2">{t('auth.orOpenInApp')}</p>
|
||||
<p className="mb-2 text-xs text-gray-500">{t('auth.orOpenInApp')}</p>
|
||||
<a
|
||||
href={`https://t.me/${botUsername}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-sm text-telegram-blue hover:underline"
|
||||
className="text-telegram-blue inline-flex items-center text-sm hover:underline"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-1" viewBox="0 0 24 24" fill="currentColor">
|
||||
<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 className="mr-1 h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
@{botUsername}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,106 +1,122 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { COLOR_PRESETS, ColorPreset } from '../data/colorPresets'
|
||||
import { hexToHsl, hslToHex, isValidHex, HSLColor } from '../utils/colorConversion'
|
||||
import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme'
|
||||
import { applyThemeColors } from '../hooks/useThemeColors'
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { COLOR_PRESETS, ColorPreset } from '../data/colorPresets';
|
||||
import { hexToHsl, hslToHex, isValidHex, HSLColor } from '../utils/colorConversion';
|
||||
import { ThemeColors, DEFAULT_THEME_COLORS } from '../types/theme';
|
||||
import { applyThemeColors } from '../hooks/useThemeColors';
|
||||
|
||||
interface ThemeBentoPickerProps {
|
||||
currentColors: ThemeColors
|
||||
onColorsChange: (colors: ThemeColors) => void
|
||||
onSave: () => void
|
||||
isSaving: boolean
|
||||
currentColors: ThemeColors;
|
||||
onColorsChange: (colors: ThemeColors) => void;
|
||||
onSave: () => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ChevronDownIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const MoonIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const SunIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const StatusIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9 5.25h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const PaletteIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
function PresetCard({
|
||||
preset,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
preset: ColorPreset
|
||||
isSelected: boolean
|
||||
onClick: () => void
|
||||
preset: ColorPreset;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { i18n } = useTranslation()
|
||||
const isRu = i18n.language === 'ru'
|
||||
const { i18n } = useTranslation();
|
||||
const isRu = i18n.language === 'ru';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`relative h-full w-full p-3 text-left transition-all duration-200 group rounded-2xl flex flex-col ${
|
||||
className={`group relative flex h-full w-full flex-col rounded-2xl p-3 text-left transition-all duration-200 ${
|
||||
isSelected
|
||||
? 'bg-dark-800/90 border-2 border-accent-500 shadow-lg shadow-accent-500/20 scale-[1.02] z-10'
|
||||
: 'bg-dark-900/60 border border-dark-700/50 hover:bg-dark-800/70 hover:border-dark-600/60 hover:scale-[1.01]'
|
||||
? 'z-10 scale-[1.02] border-2 border-accent-500 bg-dark-800/90 shadow-lg shadow-accent-500/20'
|
||||
: 'border border-dark-700/50 bg-dark-900/60 hover:scale-[1.01] hover:border-dark-600/60 hover:bg-dark-800/70'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="w-full h-12 rounded-xl mb-2.5 relative overflow-hidden shrink-0"
|
||||
className="relative mb-2.5 h-12 w-full shrink-0 overflow-hidden rounded-xl"
|
||||
style={{ backgroundColor: preset.preview.background }}
|
||||
>
|
||||
<div
|
||||
className="absolute bottom-1.5 left-1.5 w-6 h-6 rounded-lg shadow-md"
|
||||
className="absolute bottom-1.5 left-1.5 h-6 w-6 rounded-lg shadow-md"
|
||||
style={{ backgroundColor: preset.preview.accent }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-2.5 right-2 w-10 h-1 rounded-full opacity-60"
|
||||
className="absolute bottom-2.5 right-2 h-1 w-10 rounded-full opacity-60"
|
||||
style={{ backgroundColor: preset.preview.text }}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-5 right-2 w-7 h-1 rounded-full opacity-40"
|
||||
className="absolute bottom-5 right-2 h-1 w-7 rounded-full opacity-40"
|
||||
style={{ backgroundColor: preset.preview.text }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-xs font-semibold text-dark-100 truncate">
|
||||
<h4 className="truncate text-xs font-semibold text-dark-100">
|
||||
{isRu ? preset.nameRu : preset.name}
|
||||
</h4>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="w-5 h-5 rounded-full bg-accent-500 flex items-center justify-center text-white shrink-0">
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent-500 text-white">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function HSLSlider({
|
||||
@@ -111,18 +127,18 @@ function HSLSlider({
|
||||
gradient,
|
||||
suffix = '',
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (value: number) => void
|
||||
max: number
|
||||
gradient: string
|
||||
suffix?: string
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (value: number) => void;
|
||||
max: number;
|
||||
gradient: string;
|
||||
suffix?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-medium text-dark-300">{label}</label>
|
||||
<span className="text-xs text-dark-500 font-mono tabular-nums">
|
||||
<span className="font-mono text-xs tabular-nums text-dark-500">
|
||||
{value}
|
||||
{suffix}
|
||||
</span>
|
||||
@@ -133,11 +149,11 @@ function HSLSlider({
|
||||
max={max}
|
||||
value={value}
|
||||
onChange={(e) => onChange(parseInt(e.target.value))}
|
||||
className="w-full h-2.5 rounded-full appearance-none cursor-pointer"
|
||||
className="h-2.5 w-full cursor-pointer appearance-none rounded-full"
|
||||
style={{ background: gradient }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CompactColorInput({
|
||||
@@ -145,45 +161,45 @@ function CompactColorInput({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
onChange: (color: string) => void
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
}) {
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value)
|
||||
}, [value])
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
let formatted = newValue.toUpperCase()
|
||||
let formatted = newValue.toUpperCase();
|
||||
if (!formatted.startsWith('#')) {
|
||||
formatted = '#' + formatted
|
||||
formatted = '#' + formatted;
|
||||
}
|
||||
setLocalValue(formatted)
|
||||
setLocalValue(formatted);
|
||||
if (isValidHex(formatted)) {
|
||||
onChange(formatted)
|
||||
onChange(formatted);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsEditing(false)
|
||||
setIsEditing(false);
|
||||
if (!isValidHex(localValue)) {
|
||||
setLocalValue(value)
|
||||
setLocalValue(value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-2 rounded-xl bg-dark-800/40 hover:bg-dark-800/60 transition-colors group">
|
||||
<div className="group flex items-center gap-2 rounded-xl bg-dark-800/40 p-2 transition-colors hover:bg-dark-800/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="w-8 h-8 rounded-lg border border-dark-600/50 shadow-inner shrink-0 transition-transform hover:scale-105"
|
||||
className="h-8 w-8 shrink-0 rounded-lg border border-dark-600/50 shadow-inner transition-transform hover:scale-105"
|
||||
style={{ backgroundColor: value }}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<label className="text-[10px] uppercase tracking-wide text-dark-500 block leading-none mb-0.5">
|
||||
<div className="min-w-0 flex-1">
|
||||
<label className="mb-0.5 block text-[10px] uppercase leading-none tracking-wide text-dark-500">
|
||||
{label}
|
||||
</label>
|
||||
{isEditing ? (
|
||||
@@ -194,21 +210,21 @@ function CompactColorInput({
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleBlur()}
|
||||
autoFocus
|
||||
className="bg-transparent text-xs font-mono text-dark-200 w-full outline-none"
|
||||
className="w-full bg-transparent font-mono text-xs text-dark-200 outline-none"
|
||||
maxLength={7}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="text-xs font-mono text-dark-300 hover:text-dark-100 transition-colors text-left"
|
||||
className="text-left font-mono text-xs text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
{value.toUpperCase()}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleSection({
|
||||
@@ -219,24 +235,24 @@ function CollapsibleSection({
|
||||
children,
|
||||
badge,
|
||||
}: {
|
||||
title: string
|
||||
icon: React.ReactNode
|
||||
isOpen: boolean
|
||||
onToggle: () => void
|
||||
children: React.ReactNode
|
||||
badge?: string
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
badge?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl bg-dark-900/50 border border-dark-700/40 overflow-hidden">
|
||||
<div className="overflow-hidden rounded-2xl border border-dark-700/40 bg-dark-900/50">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-dark-800/30 transition-colors"
|
||||
className="flex w-full items-center justify-between px-4 py-3 transition-colors hover:bg-dark-800/30"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="text-dark-400">{icon}</div>
|
||||
<span className="text-sm font-medium text-dark-200">{title}</span>
|
||||
{badge && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-md bg-dark-700/50 text-dark-400 font-mono">
|
||||
<span className="rounded-md bg-dark-700/50 px-1.5 py-0.5 font-mono text-[10px] text-dark-400">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
@@ -254,11 +270,11 @@ function CollapsibleSection({
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="px-4 pb-4 pt-1 border-t border-dark-700/30">{children}</div>
|
||||
<div className="border-t border-dark-700/30 px-4 pb-4 pt-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function ThemeBentoPicker({
|
||||
@@ -267,67 +283,67 @@ export function ThemeBentoPicker({
|
||||
onSave,
|
||||
isSaving,
|
||||
}: ThemeBentoPickerProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [hsl, setHsl] = useState<HSLColor>(() => hexToHsl(currentColors.accent))
|
||||
const [hexInput, setHexInput] = useState(currentColors.accent)
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
const [hsl, setHsl] = useState<HSLColor>(() => hexToHsl(currentColors.accent));
|
||||
const [hexInput, setHexInput] = useState(currentColors.accent);
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
||||
const [isPresetsOpen, setIsPresetsOpen] = useState(false)
|
||||
const [isAccentOpen, setIsAccentOpen] = useState(false)
|
||||
const [isDarkOpen, setIsDarkOpen] = useState(false)
|
||||
const [isLightOpen, setIsLightOpen] = useState(false)
|
||||
const [isStatusOpen, setIsStatusOpen] = useState(false)
|
||||
const [isPresetsOpen, setIsPresetsOpen] = useState(false);
|
||||
const [isAccentOpen, setIsAccentOpen] = useState(false);
|
||||
const [isDarkOpen, setIsDarkOpen] = useState(false);
|
||||
const [isLightOpen, setIsLightOpen] = useState(false);
|
||||
const [isStatusOpen, setIsStatusOpen] = useState(false);
|
||||
|
||||
const selectedPresetId = useMemo(() => {
|
||||
const match = COLOR_PRESETS.find(
|
||||
(p) =>
|
||||
p.colors.accent.toLowerCase() === currentColors.accent.toLowerCase() &&
|
||||
p.colors.darkBackground.toLowerCase() === currentColors.darkBackground.toLowerCase() &&
|
||||
p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase()
|
||||
)
|
||||
return match?.id ?? null
|
||||
}, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground])
|
||||
p.colors.lightBackground.toLowerCase() === currentColors.lightBackground.toLowerCase(),
|
||||
);
|
||||
return match?.id ?? null;
|
||||
}, [currentColors.accent, currentColors.darkBackground, currentColors.lightBackground]);
|
||||
|
||||
useEffect(() => {
|
||||
setHsl(hexToHsl(currentColors.accent))
|
||||
setHexInput(currentColors.accent)
|
||||
}, [currentColors.accent])
|
||||
setHsl(hexToHsl(currentColors.accent));
|
||||
setHexInput(currentColors.accent);
|
||||
}, [currentColors.accent]);
|
||||
|
||||
const updateColor = useCallback(
|
||||
(key: keyof ThemeColors, value: string) => {
|
||||
const newColors = { ...currentColors, [key]: value }
|
||||
onColorsChange(newColors)
|
||||
applyThemeColors(newColors)
|
||||
setHasChanges(true)
|
||||
const newColors = { ...currentColors, [key]: value };
|
||||
onColorsChange(newColors);
|
||||
applyThemeColors(newColors);
|
||||
setHasChanges(true);
|
||||
},
|
||||
[currentColors, onColorsChange]
|
||||
)
|
||||
[currentColors, onColorsChange],
|
||||
);
|
||||
|
||||
const updateAccentFromHsl = useCallback(
|
||||
(newHsl: HSLColor) => {
|
||||
setHsl(newHsl)
|
||||
const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l)
|
||||
setHexInput(newHex)
|
||||
updateColor('accent', newHex)
|
||||
setHsl(newHsl);
|
||||
const newHex = hslToHex(newHsl.h, newHsl.s, newHsl.l);
|
||||
setHexInput(newHex);
|
||||
updateColor('accent', newHex);
|
||||
},
|
||||
[updateColor]
|
||||
)
|
||||
[updateColor],
|
||||
);
|
||||
|
||||
const handleHexInputChange = (value: string) => {
|
||||
setHexInput(value)
|
||||
setHexInput(value);
|
||||
if (isValidHex(value)) {
|
||||
const newHsl = hexToHsl(value)
|
||||
setHsl(newHsl)
|
||||
updateColor('accent', value)
|
||||
const newHsl = hexToHsl(value);
|
||||
setHsl(newHsl);
|
||||
updateColor('accent', value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePresetSelect = (preset: ColorPreset) => {
|
||||
onColorsChange(preset.colors)
|
||||
applyThemeColors(preset.colors)
|
||||
setHasChanges(true)
|
||||
}
|
||||
onColorsChange(preset.colors);
|
||||
applyThemeColors(preset.colors);
|
||||
setHasChanges(true);
|
||||
};
|
||||
|
||||
const hueGradient = useMemo(() => {
|
||||
return `linear-gradient(to right,
|
||||
@@ -338,23 +354,23 @@ export function ThemeBentoPicker({
|
||||
hsl(240, ${hsl.s}%, ${hsl.l}%),
|
||||
hsl(300, ${hsl.s}%, ${hsl.l}%),
|
||||
hsl(360, ${hsl.s}%, ${hsl.l}%)
|
||||
)`
|
||||
}, [hsl.s, hsl.l])
|
||||
)`;
|
||||
}, [hsl.s, hsl.l]);
|
||||
|
||||
const saturationGradient = useMemo(() => {
|
||||
return `linear-gradient(to right,
|
||||
hsl(${hsl.h}, 0%, ${hsl.l}%),
|
||||
hsl(${hsl.h}, 100%, ${hsl.l}%)
|
||||
)`
|
||||
}, [hsl.h, hsl.l])
|
||||
)`;
|
||||
}, [hsl.h, hsl.l]);
|
||||
|
||||
const lightnessGradient = useMemo(() => {
|
||||
return `linear-gradient(to right,
|
||||
hsl(${hsl.h}, ${hsl.s}%, 0%),
|
||||
hsl(${hsl.h}, ${hsl.s}%, 50%),
|
||||
hsl(${hsl.h}, ${hsl.s}%, 100%)
|
||||
)`
|
||||
}, [hsl.h, hsl.s])
|
||||
)`;
|
||||
}, [hsl.h, hsl.s]);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -365,9 +381,13 @@ export function ThemeBentoPicker({
|
||||
isOpen={isPresetsOpen}
|
||||
onToggle={() => setIsPresetsOpen(!isPresetsOpen)}
|
||||
>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 auto-rows-fr gap-4 p-1">
|
||||
<div className="grid auto-rows-fr grid-cols-2 gap-4 p-1 sm:grid-cols-3">
|
||||
{COLOR_PRESETS.map((preset, index) => (
|
||||
<div key={preset.id} className="min-h-[100px]" style={{ '--stagger': index } as React.CSSProperties}>
|
||||
<div
|
||||
key={preset.id}
|
||||
className="min-h-[100px]"
|
||||
style={{ '--stagger': index } as React.CSSProperties}
|
||||
>
|
||||
<PresetCard
|
||||
preset={preset}
|
||||
isSelected={selectedPresetId === preset.id}
|
||||
@@ -379,7 +399,7 @@ export function ThemeBentoPicker({
|
||||
</CollapsibleSection>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-medium text-dark-400 uppercase tracking-wide">
|
||||
<h3 className="text-xs font-medium uppercase tracking-wide text-dark-400">
|
||||
{t('admin.theme.customizeColors', 'Customize Colors')}
|
||||
</h3>
|
||||
|
||||
@@ -392,13 +412,13 @@ export function ThemeBentoPicker({
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className="w-full h-14 rounded-xl shadow-inner relative overflow-hidden"
|
||||
className="relative h-14 w-full overflow-hidden rounded-xl shadow-inner"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${hexInput} 0%, ${hslToHex(hsl.h, hsl.s, Math.max(20, hsl.l - 20))} 100%)`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent" />
|
||||
<div className="absolute bottom-2 right-3 text-white/80 text-xs font-mono drop-shadow">
|
||||
<div className="absolute bottom-2 right-3 font-mono text-xs text-white/80 drop-shadow">
|
||||
{hexInput.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -431,7 +451,7 @@ export function ThemeBentoPicker({
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-medium text-dark-300 mb-1.5 block">
|
||||
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||
{t('admin.theme.hexCode', 'HEX Code')}
|
||||
</label>
|
||||
<input
|
||||
@@ -440,7 +460,7 @@ export function ThemeBentoPicker({
|
||||
onChange={(e) => handleHexInputChange(e.target.value)}
|
||||
placeholder="#3b82f6"
|
||||
maxLength={7}
|
||||
className="input w-full text-sm font-mono uppercase"
|
||||
className="input w-full font-mono text-sm uppercase"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -532,8 +552,8 @@ export function ThemeBentoPicker({
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl bg-dark-900/50 border border-dark-700/40 p-4">
|
||||
<h4 className="text-xs font-medium text-dark-400 uppercase tracking-wide mb-3">
|
||||
<div className="rounded-2xl border border-dark-700/40 bg-dark-900/50 p-4">
|
||||
<h4 className="mb-3 text-xs font-medium uppercase tracking-wide text-dark-400">
|
||||
{t('theme.preview', 'Preview')}
|
||||
</h4>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -548,12 +568,12 @@ export function ThemeBentoPicker({
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<div className="flex justify-end animate-fade-in">
|
||||
<div className="flex animate-fade-in justify-end">
|
||||
<button onClick={onSave} disabled={isSaving} className="btn-primary">
|
||||
{isSaving ? t('common.saving', 'Saving...') : t('common.save', 'Save')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,209 +1,252 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ticketNotificationsApi } from '../api/ticketNotifications'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
import { useToast } from './Toast'
|
||||
import { useWebSocket, WSMessage } from '../hooks/useWebSocket'
|
||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp'
|
||||
import type { TicketNotification } from '../types'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ticketNotificationsApi } from '../api/ticketNotifications';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
import { useToast } from './Toast';
|
||||
import { useWebSocket, WSMessage } from '../hooks/useWebSocket';
|
||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
||||
import type { TicketNotification } from '../types';
|
||||
|
||||
const BellIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.857 17.082a23.848 23.848 0 005.454-1.31A8.967 8.967 0 0118 9.75v-.7V9A6 6 0 006 9v.75a8.967 8.967 0 01-2.312 6.022c1.733.64 3.56 1.085 5.455 1.31m5.714 0a24.255 24.255 0 01-5.714 0m5.714 0a3 3 0 11-5.714 0"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
interface TicketNotificationBellProps {
|
||||
isAdmin?: boolean
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export default function TicketNotificationBell({ isAdmin = false }: TicketNotificationBellProps) {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
const { isAuthenticated } = useAuthStore()
|
||||
const { showToast } = useToast()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp()
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const { showToast } = useToast();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
|
||||
|
||||
// Calculate dropdown top position (account for fullscreen safe area + TG buttons)
|
||||
const dropdownTop = isFullscreen
|
||||
? Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45 + 64 // safe area + TG buttons + header
|
||||
: 64 // default header height
|
||||
: 64; // default header height
|
||||
|
||||
// Show toast for WebSocket notification
|
||||
const showWSNotificationToast = useCallback((message: WSMessage) => {
|
||||
const isNewTicket = message.type === 'ticket.new'
|
||||
const isAdminReply = message.type === 'ticket.admin_reply'
|
||||
const isUserReply = message.type === 'ticket.user_reply'
|
||||
const showWSNotificationToast = useCallback(
|
||||
(message: WSMessage) => {
|
||||
const isNewTicket = message.type === 'ticket.new';
|
||||
const isAdminReply = message.type === 'ticket.admin_reply';
|
||||
const isUserReply = message.type === 'ticket.user_reply';
|
||||
|
||||
const icon = isNewTicket ? (
|
||||
<span className="text-lg">🎫</span>
|
||||
) : isAdminReply ? (
|
||||
<span className="text-lg">💬</span>
|
||||
) : (
|
||||
<span className="text-lg">📨</span>
|
||||
)
|
||||
const icon = isNewTicket ? (
|
||||
<span className="text-lg">🎫</span>
|
||||
) : isAdminReply ? (
|
||||
<span className="text-lg">💬</span>
|
||||
) : (
|
||||
<span className="text-lg">📨</span>
|
||||
);
|
||||
|
||||
const ticketTitle = message.title || ''
|
||||
const ticketTitle = message.title || '';
|
||||
|
||||
let toastTitle: string
|
||||
let toastMessage: string
|
||||
let toastTitle: string;
|
||||
let toastMessage: string;
|
||||
|
||||
if (isNewTicket) {
|
||||
toastTitle = t('notifications.newTicketTitle', 'New Ticket')
|
||||
toastMessage = message.message || t('notifications.newTicket', 'New ticket: {{title}}', { title: ticketTitle })
|
||||
} else if (isUserReply) {
|
||||
toastTitle = t('notifications.newUserReplyTitle', 'User Reply')
|
||||
toastMessage = message.message || t('notifications.newUserReply', 'User replied in ticket: {{title}}', { title: ticketTitle })
|
||||
} else {
|
||||
toastTitle = t('notifications.newReplyTitle', 'New Reply')
|
||||
toastMessage = message.message || t('notifications.newReply', 'New reply in ticket: {{title}}', { title: ticketTitle })
|
||||
}
|
||||
if (isNewTicket) {
|
||||
toastTitle = t('notifications.newTicketTitle', 'New Ticket');
|
||||
toastMessage =
|
||||
message.message ||
|
||||
t('notifications.newTicket', 'New ticket: {{title}}', { title: ticketTitle });
|
||||
} else if (isUserReply) {
|
||||
toastTitle = t('notifications.newUserReplyTitle', 'User Reply');
|
||||
toastMessage =
|
||||
message.message ||
|
||||
t('notifications.newUserReply', 'User replied in ticket: {{title}}', {
|
||||
title: ticketTitle,
|
||||
});
|
||||
} else {
|
||||
toastTitle = t('notifications.newReplyTitle', 'New Reply');
|
||||
toastMessage =
|
||||
message.message ||
|
||||
t('notifications.newReply', 'New reply in ticket: {{title}}', { title: ticketTitle });
|
||||
}
|
||||
|
||||
showToast({
|
||||
type: 'info',
|
||||
title: toastTitle,
|
||||
message: toastMessage,
|
||||
icon,
|
||||
onClick: () => {
|
||||
navigate(isAdmin ? `/admin/tickets?ticket=${message.ticket_id}` : `/support?ticket=${message.ticket_id}`)
|
||||
},
|
||||
duration: 8000,
|
||||
})
|
||||
}, [showToast, navigate, isAdmin, t])
|
||||
showToast({
|
||||
type: 'info',
|
||||
title: toastTitle,
|
||||
message: toastMessage,
|
||||
icon,
|
||||
onClick: () => {
|
||||
navigate(
|
||||
isAdmin
|
||||
? `/admin/tickets?ticket=${message.ticket_id}`
|
||||
: `/support?ticket=${message.ticket_id}`,
|
||||
);
|
||||
},
|
||||
duration: 8000,
|
||||
});
|
||||
},
|
||||
[showToast, navigate, isAdmin, t],
|
||||
);
|
||||
|
||||
// Handle WebSocket message
|
||||
const handleWSMessage = useCallback((message: WSMessage) => {
|
||||
// Check if this notification is relevant for this user type
|
||||
const isAdminNotification = message.type === 'ticket.new' || message.type === 'ticket.user_reply'
|
||||
const isUserNotification = message.type === 'ticket.admin_reply'
|
||||
const handleWSMessage = useCallback(
|
||||
(message: WSMessage) => {
|
||||
// Check if this notification is relevant for this user type
|
||||
const isAdminNotification =
|
||||
message.type === 'ticket.new' || message.type === 'ticket.user_reply';
|
||||
const isUserNotification = message.type === 'ticket.admin_reply';
|
||||
|
||||
if ((isAdmin && isAdminNotification) || (!isAdmin && isUserNotification)) {
|
||||
// Show toast
|
||||
showWSNotificationToast(message)
|
||||
if ((isAdmin && isAdminNotification) || (!isAdmin && isUserNotification)) {
|
||||
// Show toast
|
||||
showWSNotificationToast(message);
|
||||
|
||||
// Invalidate queries to refresh count and list
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count']
|
||||
})
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications']
|
||||
})
|
||||
}
|
||||
}, [isAdmin, showWSNotificationToast, queryClient])
|
||||
// Invalidate queries to refresh count and list
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
|
||||
});
|
||||
}
|
||||
},
|
||||
[isAdmin, showWSNotificationToast, queryClient],
|
||||
);
|
||||
|
||||
// WebSocket connection
|
||||
useWebSocket({
|
||||
onMessage: handleWSMessage,
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch unread count (with slower polling as fallback when WS disconnects)
|
||||
const { data: unreadData } = useQuery({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
|
||||
queryFn: isAdmin ? ticketNotificationsApi.getAdminUnreadCount : ticketNotificationsApi.getUnreadCount,
|
||||
queryFn: isAdmin
|
||||
? ticketNotificationsApi.getAdminUnreadCount
|
||||
: ticketNotificationsApi.getUnreadCount,
|
||||
enabled: isAuthenticated,
|
||||
refetchInterval: 60000, // Poll every 60 seconds as fallback
|
||||
staleTime: 30000,
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch notifications when dropdown is open
|
||||
const { data: notificationsData, isLoading } = useQuery({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
|
||||
queryFn: () => isAdmin
|
||||
? ticketNotificationsApi.getAdminNotifications(false, 10)
|
||||
: ticketNotificationsApi.getNotifications(false, 10),
|
||||
queryFn: () =>
|
||||
isAdmin
|
||||
? ticketNotificationsApi.getAdminNotifications(false, 10)
|
||||
: ticketNotificationsApi.getNotifications(false, 10),
|
||||
enabled: isAuthenticated && isOpen,
|
||||
staleTime: 5000,
|
||||
})
|
||||
});
|
||||
|
||||
// Mark all as read mutation
|
||||
const markAllReadMutation = useMutation({
|
||||
mutationFn: isAdmin ? ticketNotificationsApi.markAllAdminAsRead : ticketNotificationsApi.markAllAsRead,
|
||||
mutationFn: isAdmin
|
||||
? ticketNotificationsApi.markAllAdminAsRead
|
||||
: ticketNotificationsApi.markAllAsRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] })
|
||||
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
|
||||
});
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Mark single as read mutation
|
||||
const markReadMutation = useMutation({
|
||||
mutationFn: isAdmin ? ticketNotificationsApi.markAdminAsRead : ticketNotificationsApi.markAsRead,
|
||||
mutationFn: isAdmin
|
||||
? ticketNotificationsApi.markAdminAsRead
|
||||
: ticketNotificationsApi.markAsRead,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'] })
|
||||
queryClient.invalidateQueries({ queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'] })
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications'] : ['ticket-notifications'],
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: isAdmin ? ['admin-ticket-notifications-count'] : ['ticket-notifications-count'],
|
||||
});
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleNotificationClick = (notification: TicketNotification) => {
|
||||
if (!notification.is_read) {
|
||||
markReadMutation.mutate(notification.id)
|
||||
markReadMutation.mutate(notification.id);
|
||||
}
|
||||
setIsOpen(false)
|
||||
navigate(isAdmin ? `/admin/tickets?ticket=${notification.ticket_id}` : `/support?ticket=${notification.ticket_id}`)
|
||||
}
|
||||
setIsOpen(false);
|
||||
navigate(
|
||||
isAdmin
|
||||
? `/admin/tickets?ticket=${notification.ticket_id}`
|
||||
: `/support?ticket=${notification.ticket_id}`,
|
||||
);
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return t('notifications.justNow', 'Just now')
|
||||
if (diffMins < 60) return t('notifications.minutesAgo', '{{count}} min ago', { count: diffMins })
|
||||
if (diffHours < 24) return t('notifications.hoursAgo', '{{count}} h ago', { count: diffHours })
|
||||
return t('notifications.daysAgo', '{{count}} d ago', { count: diffDays })
|
||||
}
|
||||
if (diffMins < 1) return t('notifications.justNow', 'Just now');
|
||||
if (diffMins < 60)
|
||||
return t('notifications.minutesAgo', '{{count}} min ago', { count: diffMins });
|
||||
if (diffHours < 24) return t('notifications.hoursAgo', '{{count}} h ago', { count: diffHours });
|
||||
return t('notifications.daysAgo', '{{count}} d ago', { count: diffDays });
|
||||
};
|
||||
|
||||
const getNotificationIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'new_ticket':
|
||||
return <span className="text-lg">🎫</span>
|
||||
return <span className="text-lg">🎫</span>;
|
||||
case 'admin_reply':
|
||||
return <span className="text-lg">💬</span>
|
||||
return <span className="text-lg">💬</span>;
|
||||
case 'user_reply':
|
||||
return <span className="text-lg">📨</span>
|
||||
return <span className="text-lg">📨</span>;
|
||||
default:
|
||||
return <span className="text-lg">🔔</span>
|
||||
return <span className="text-lg">🔔</span>;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const unreadCount = unreadData?.unread_count || 0
|
||||
const unreadCount = unreadData?.unread_count || 0;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
{/* Bell button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="relative p-2 rounded-xl transition-all duration-200 bg-dark-800/50 hover:bg-dark-700 border border-dark-700/50 text-dark-400 hover:text-accent-400"
|
||||
className="relative rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-dark-400 transition-all duration-200 hover:bg-dark-700 hover:text-accent-400"
|
||||
title={t('notifications.ticketNotifications', 'Ticket notifications')}
|
||||
>
|
||||
<BellIcon />
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-0.5 -right-0.5 min-w-[18px] h-[18px] flex items-center justify-center text-xs font-bold text-white bg-error-500 rounded-full px-1 animate-scale-in-bounce">
|
||||
<span className="absolute -right-0.5 -top-0.5 flex h-[18px] min-w-[18px] animate-scale-in-bounce items-center justify-center rounded-full bg-error-500 px-1 text-xs font-bold text-white">
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -212,13 +255,13 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
||||
{/* Dropdown */}
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`fixed sm:absolute sm:top-auto right-4 sm:right-0 left-4 sm:left-auto mt-0 sm:mt-2 w-auto sm:w-96 bg-dark-900/95 backdrop-blur-xl border border-dark-700/50 rounded-2xl shadow-2xl shadow-black/30 overflow-hidden z-50 animate-scale-in ${
|
||||
className={`fixed left-4 right-4 z-50 mt-0 w-auto animate-scale-in overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900/95 shadow-2xl shadow-black/30 backdrop-blur-xl sm:absolute sm:left-auto sm:right-0 sm:top-auto sm:mt-2 sm:w-96 ${
|
||||
!isFullscreen ? 'top-16' : ''
|
||||
}`}
|
||||
style={isFullscreen ? { top: `${dropdownTop}px` } : undefined}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-700/50 bg-dark-800/30">
|
||||
<div className="flex items-center justify-between border-b border-dark-700/50 bg-dark-800/30 px-4 py-3">
|
||||
<h3 className="text-sm font-semibold text-dark-100">
|
||||
{t('notifications.ticketNotifications', 'Ticket Notifications')}
|
||||
</h3>
|
||||
@@ -226,7 +269,7 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
||||
<button
|
||||
onClick={() => markAllReadMutation.mutate()}
|
||||
disabled={markAllReadMutation.isPending}
|
||||
className="flex items-center gap-1.5 text-xs text-accent-400 hover:text-accent-300 disabled:opacity-50 transition-colors"
|
||||
className="flex items-center gap-1.5 text-xs text-accent-400 transition-colors hover:text-accent-300 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
{t('notifications.markAllRead', 'Mark all read')}
|
||||
@@ -238,32 +281,34 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-dark-500">
|
||||
<div className="animate-spin w-6 h-6 border-2 border-accent-500 border-t-transparent rounded-full mx-auto"></div>
|
||||
<div className="mx-auto h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent"></div>
|
||||
</div>
|
||||
) : notificationsData?.items && notificationsData.items.length > 0 ? (
|
||||
notificationsData.items.map((notification: TicketNotification) => (
|
||||
<button
|
||||
key={notification.id}
|
||||
onClick={() => handleNotificationClick(notification)}
|
||||
className={`w-full text-left px-4 py-3 border-b border-dark-800/50 last:border-b-0 hover:bg-dark-800/50 transition-all duration-200 ${
|
||||
className={`w-full border-b border-dark-800/50 px-4 py-3 text-left transition-all duration-200 last:border-b-0 hover:bg-dark-800/50 ${
|
||||
!notification.is_read ? 'bg-accent-500/5' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-dark-800/50 flex items-center justify-center">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-dark-800/50">
|
||||
{getNotificationIcon(notification.notification_type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm leading-relaxed ${!notification.is_read ? 'text-dark-100 font-medium' : 'text-dark-300'}`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className={`text-sm leading-relaxed ${!notification.is_read ? 'font-medium text-dark-100' : 'text-dark-300'}`}
|
||||
>
|
||||
{notification.message}
|
||||
</p>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{formatTime(notification.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
{!notification.is_read && (
|
||||
<div className="flex-shrink-0 pt-1">
|
||||
<span className="w-2.5 h-2.5 bg-accent-500 rounded-full block shadow-lg shadow-accent-500/50"></span>
|
||||
<span className="block h-2.5 w-2.5 rounded-full bg-accent-500 shadow-lg shadow-accent-500/50"></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -271,23 +316,25 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
||||
))
|
||||
) : (
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-12 h-12 rounded-2xl bg-dark-800/50 flex items-center justify-center mx-auto mb-3 text-dark-500">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-2xl bg-dark-800/50 text-dark-500">
|
||||
<BellIcon />
|
||||
</div>
|
||||
<p className="text-sm text-dark-500">{t('notifications.noNotifications', 'No notifications')}</p>
|
||||
<p className="text-sm text-dark-500">
|
||||
{t('notifications.noNotifications', 'No notifications')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{notificationsData?.items && notificationsData.items.length > 0 && (
|
||||
<div className="px-4 py-3 border-t border-dark-700/50 bg-dark-800/30">
|
||||
<div className="border-t border-dark-700/50 bg-dark-800/30 px-4 py-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsOpen(false)
|
||||
navigate(isAdmin ? '/admin/tickets' : '/support')
|
||||
setIsOpen(false);
|
||||
navigate(isAdmin ? '/admin/tickets' : '/support');
|
||||
}}
|
||||
className="w-full text-center text-sm text-accent-400 hover:text-accent-300 py-1 transition-colors"
|
||||
className="w-full py-1 text-center text-sm text-accent-400 transition-colors hover:text-accent-300"
|
||||
>
|
||||
{t('notifications.viewAll', 'View all tickets')}
|
||||
</button>
|
||||
@@ -296,5 +343,5 @@ export default function TicketNotificationBell({ isAdmin = false }: TicketNotifi
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,94 +1,99 @@
|
||||
import { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useEffect,
|
||||
ReactNode,
|
||||
} from 'react';
|
||||
|
||||
interface ToastOptions {
|
||||
type?: 'success' | 'error' | 'info' | 'warning'
|
||||
message: string
|
||||
title?: string
|
||||
icon?: ReactNode
|
||||
duration?: number
|
||||
onClick?: () => void
|
||||
type?: 'success' | 'error' | 'info' | 'warning';
|
||||
message: string;
|
||||
title?: string;
|
||||
icon?: ReactNode;
|
||||
duration?: number;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface Toast extends ToastOptions {
|
||||
id: number
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (options: ToastOptions) => void
|
||||
showToast: (options: ToastOptions) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | null>(null)
|
||||
const ToastContext = createContext<ToastContextType | null>(null);
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider')
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return context
|
||||
return context;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map())
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
const showToast = useCallback((options: ToastOptions) => {
|
||||
const id = Date.now() + Math.random() // Avoid ID collision
|
||||
const toast: Toast = { id, duration: 5000, type: 'info', ...options }
|
||||
const id = Date.now() + Math.random(); // Avoid ID collision
|
||||
const toast: Toast = { id, duration: 5000, type: 'info', ...options };
|
||||
|
||||
setToasts(prev => [...prev, toast])
|
||||
setToasts((prev) => [...prev, toast]);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
timersRef.current.delete(id)
|
||||
}, toast.duration)
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
timersRef.current.delete(id);
|
||||
}, toast.duration);
|
||||
|
||||
timersRef.current.set(id, timer)
|
||||
}, [])
|
||||
timersRef.current.set(id, timer);
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: number) => {
|
||||
// Clear timer when manually removing
|
||||
const timer = timersRef.current.get(id)
|
||||
const timer = timersRef.current.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timersRef.current.delete(id)
|
||||
clearTimeout(timer);
|
||||
timersRef.current.delete(id);
|
||||
}
|
||||
setToasts(prev => prev.filter(t => t.id !== id))
|
||||
}, [])
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||
}, []);
|
||||
|
||||
// Cleanup all timers on unmount
|
||||
useEffect(() => {
|
||||
const timers = timersRef.current
|
||||
const timers = timersRef.current;
|
||||
return () => {
|
||||
timers.forEach(timer => clearTimeout(timer))
|
||||
timers.clear()
|
||||
}
|
||||
}, [])
|
||||
timers.forEach((timer) => clearTimeout(timer));
|
||||
timers.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast }}>
|
||||
{children}
|
||||
|
||||
{/* Toast Container */}
|
||||
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-3 pointer-events-none">
|
||||
<div className="pointer-events-none fixed right-4 top-4 z-[100] flex flex-col gap-3">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
onClose={() => removeToast(toast.id)}
|
||||
/>
|
||||
<ToastItem key={toast.id} toast={toast} onClose={() => removeToast(toast.id)} />
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
||||
const handleClick = () => {
|
||||
if (toast.onClick) {
|
||||
toast.onClick()
|
||||
onClose()
|
||||
toast.onClick();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const typeStyles = {
|
||||
success: {
|
||||
@@ -115,81 +120,105 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
||||
icon: 'text-accent-400',
|
||||
iconBg: 'bg-accent-500/20',
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
const style = typeStyles[toast.type || 'info']
|
||||
const style = typeStyles[toast.type || 'info'];
|
||||
|
||||
const defaultIcons = {
|
||||
success: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
),
|
||||
error: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
info: (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
pointer-events-auto
|
||||
w-80 sm:w-96
|
||||
${style.bg}
|
||||
backdrop-blur-xl
|
||||
border ${style.border}
|
||||
rounded-2xl
|
||||
shadow-2xl shadow-black/20
|
||||
overflow-hidden
|
||||
animate-slide-in-right
|
||||
${toast.onClick ? 'cursor-pointer hover:scale-[1.02] active:scale-[0.98]' : ''}
|
||||
transition-transform duration-200
|
||||
`}
|
||||
className={`pointer-events-auto w-80 sm:w-96 ${style.bg} border backdrop-blur-xl ${style.border} animate-slide-in-right overflow-hidden rounded-2xl shadow-2xl shadow-black/20 ${toast.onClick ? 'cursor-pointer hover:scale-[1.02] active:scale-[0.98]' : ''} transition-transform duration-200`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* Glow effect */}
|
||||
<div className={`absolute inset-0 ${style.bg} blur-xl opacity-50`} />
|
||||
<div className={`absolute inset-0 ${style.bg} opacity-50 blur-xl`} />
|
||||
|
||||
<div className="relative p-4">
|
||||
<div className="flex gap-3">
|
||||
{/* Icon */}
|
||||
<div className={`flex-shrink-0 w-10 h-10 rounded-xl ${style.iconBg} flex items-center justify-center ${style.icon}`}>
|
||||
<div
|
||||
className={`h-10 w-10 flex-shrink-0 rounded-xl ${style.iconBg} flex items-center justify-center ${style.icon}`}
|
||||
>
|
||||
{toast.icon || defaultIcons[toast.type || 'info']}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 pt-0.5">
|
||||
<div className="min-w-0 flex-1 pt-0.5">
|
||||
{toast.title && (
|
||||
<p className="text-sm font-semibold text-dark-100 mb-0.5">
|
||||
{toast.title}
|
||||
</p>
|
||||
<p className="mb-0.5 text-sm font-semibold text-dark-100">{toast.title}</p>
|
||||
)}
|
||||
<p className="text-sm text-dark-300 leading-relaxed">
|
||||
{toast.message}
|
||||
</p>
|
||||
<p className="text-sm leading-relaxed text-dark-300">{toast.message}</p>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClose()
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="flex-shrink-0 w-6 h-6 rounded-lg hover:bg-dark-700/50 flex items-center justify-center text-dark-500 hover:text-dark-300 transition-colors"
|
||||
className="flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-lg text-dark-500 transition-colors hover:bg-dark-700/50 hover:text-dark-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -206,5 +235,5 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,285 +1,345 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { balanceApi } from '../api/balance'
|
||||
import { useCurrency } from '../hooks/useCurrency'
|
||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp'
|
||||
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'
|
||||
import type { PaymentMethod } from '../types'
|
||||
import BentoCard from './ui/BentoCard'
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { balanceApi } from '../api/balance';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
||||
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
|
||||
import type { PaymentMethod } from '../types';
|
||||
import BentoCard from './ui/BentoCard';
|
||||
|
||||
// Icons
|
||||
const CloseIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const WalletIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3" />
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const StarIcon = () => (
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CardIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CryptoIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const SparklesIcon = () => (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ExternalLinkIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CopyIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
interface TopUpModalProps {
|
||||
method: PaymentMethod
|
||||
onClose: () => void
|
||||
initialAmountRubles?: number
|
||||
method: PaymentMethod;
|
||||
onClose: () => void;
|
||||
initialAmountRubles?: number;
|
||||
}
|
||||
|
||||
function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = useState(() => {
|
||||
if (typeof window === 'undefined') return false
|
||||
return window.innerWidth < 640
|
||||
})
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.innerWidth < 640;
|
||||
});
|
||||
useEffect(() => {
|
||||
const check = () => setIsMobile(window.innerWidth < 640)
|
||||
window.addEventListener('resize', check)
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [])
|
||||
return isMobile
|
||||
const check = () => setIsMobile(window.innerWidth < 640);
|
||||
window.addEventListener('resize', check);
|
||||
return () => window.removeEventListener('resize', check);
|
||||
}, []);
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
// Get method icon based on method type
|
||||
const getMethodIcon = (methodId: string) => {
|
||||
const id = methodId.toLowerCase()
|
||||
if (id.includes('stars')) return <StarIcon />
|
||||
if (id.includes('crypto') || id.includes('ton') || id.includes('usdt')) return <CryptoIcon />
|
||||
return <CardIcon />
|
||||
}
|
||||
const id = methodId.toLowerCase();
|
||||
if (id.includes('stars')) return <StarIcon />;
|
||||
if (id.includes('crypto') || id.includes('ton') || id.includes('usdt')) return <CryptoIcon />;
|
||||
return <CardIcon />;
|
||||
};
|
||||
|
||||
export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } = useCurrency()
|
||||
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp()
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const isMobileScreen = useIsMobile()
|
||||
const { t } = useTranslation();
|
||||
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } =
|
||||
useCurrency();
|
||||
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset, webApp } = useTelegramWebApp();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const isMobileScreen = useIsMobile();
|
||||
|
||||
const safeBottom = isTelegramWebApp ? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom) : 0
|
||||
const safeBottom = isTelegramWebApp
|
||||
? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom)
|
||||
: 0;
|
||||
|
||||
const getInitialAmount = (): string => {
|
||||
if (!initialAmountRubles || initialAmountRubles <= 0) return ''
|
||||
const converted = convertAmount(initialAmountRubles)
|
||||
return (targetCurrency === 'IRR' || targetCurrency === 'RUB')
|
||||
if (!initialAmountRubles || initialAmountRubles <= 0) return '';
|
||||
const converted = convertAmount(initialAmountRubles);
|
||||
return targetCurrency === 'IRR' || targetCurrency === 'RUB'
|
||||
? Math.ceil(converted).toString()
|
||||
: converted.toFixed(2)
|
||||
}
|
||||
: converted.toFixed(2);
|
||||
};
|
||||
|
||||
const [amount, setAmount] = useState(getInitialAmount)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [amount, setAmount] = useState(getInitialAmount);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedOption, setSelectedOption] = useState<string | null>(
|
||||
method.options && method.options.length > 0 ? method.options[0].id : null
|
||||
)
|
||||
const [paymentUrl, setPaymentUrl] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [isInputFocused, setIsInputFocused] = useState(false)
|
||||
method.options && method.options.length > 0 ? method.options[0].id : null,
|
||||
);
|
||||
const [paymentUrl, setPaymentUrl] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose()
|
||||
}, [onClose])
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// Keyboard: Escape to close (PC)
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
handleClose()
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [handleClose])
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleClose]);
|
||||
|
||||
// Telegram back button (Android)
|
||||
useEffect(() => {
|
||||
if (!webApp?.BackButton) return
|
||||
webApp.BackButton.show()
|
||||
webApp.BackButton.onClick(handleClose)
|
||||
if (!webApp?.BackButton) return;
|
||||
webApp.BackButton.show();
|
||||
webApp.BackButton.onClick(handleClose);
|
||||
return () => {
|
||||
webApp.BackButton.offClick(handleClose)
|
||||
webApp.BackButton.hide()
|
||||
}
|
||||
}, [webApp, handleClose])
|
||||
webApp.BackButton.offClick(handleClose);
|
||||
webApp.BackButton.hide();
|
||||
};
|
||||
}, [webApp, handleClose]);
|
||||
|
||||
// Scroll lock
|
||||
useEffect(() => {
|
||||
const scrollY = window.scrollY
|
||||
const scrollY = window.scrollY;
|
||||
const preventScroll = (e: TouchEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('[data-modal-content]')) return
|
||||
e.preventDefault()
|
||||
}
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-modal-content]')) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
const preventWheel = (e: WheelEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('[data-modal-content]')) return
|
||||
e.preventDefault()
|
||||
}
|
||||
document.addEventListener('touchmove', preventScroll, { passive: false })
|
||||
document.addEventListener('wheel', preventWheel, { passive: false })
|
||||
document.body.style.overflow = 'hidden'
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('[data-modal-content]')) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
document.addEventListener('touchmove', preventScroll, { passive: false });
|
||||
document.addEventListener('wheel', preventWheel, { passive: false });
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('touchmove', preventScroll)
|
||||
document.removeEventListener('wheel', preventWheel)
|
||||
document.body.style.overflow = ''
|
||||
window.scrollTo(0, scrollY)
|
||||
}
|
||||
}, [])
|
||||
document.removeEventListener('touchmove', preventScroll);
|
||||
document.removeEventListener('wheel', preventWheel);
|
||||
document.body.style.overflow = '';
|
||||
window.scrollTo(0, scrollY);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const hasOptions = method.options && method.options.length > 0
|
||||
const minRubles = method.min_amount_kopeks / 100
|
||||
const maxRubles = method.max_amount_kopeks / 100
|
||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_')
|
||||
const isStarsMethod = methodKey.includes('stars')
|
||||
const methodName = t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name
|
||||
const hasOptions = method.options && method.options.length > 0;
|
||||
const minRubles = method.min_amount_kopeks / 100;
|
||||
const maxRubles = method.max_amount_kopeks / 100;
|
||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
|
||||
const isStarsMethod = methodKey.includes('stars');
|
||||
const methodName =
|
||||
t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name;
|
||||
|
||||
const starsPaymentMutation = useMutation({
|
||||
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
|
||||
onSuccess: (data) => {
|
||||
const webApp = window.Telegram?.WebApp
|
||||
if (!data.invoice_url) { setError('Сервер не вернул ссылку на оплату'); return }
|
||||
if (!webApp?.openInvoice) { setError('Оплата Stars доступна только в Telegram Mini App'); return }
|
||||
const webApp = window.Telegram?.WebApp;
|
||||
if (!data.invoice_url) {
|
||||
setError('Сервер не вернул ссылку на оплату');
|
||||
return;
|
||||
}
|
||||
if (!webApp?.openInvoice) {
|
||||
setError('Оплата Stars доступна только в Telegram Mini App');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
webApp.openInvoice(data.invoice_url, (status) => {
|
||||
if (status === 'paid') { setError(null); onClose() }
|
||||
else if (status === 'failed') { setError(t('wheel.starsPaymentFailed')) }
|
||||
})
|
||||
} catch (e) { setError('Ошибка: ' + String(e)) }
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const axiosError = err as { response?: { data?: { detail?: string }, status?: number } }
|
||||
setError(`Ошибка: ${axiosError?.response?.data?.detail || 'Не удалось создать счёт'}`)
|
||||
},
|
||||
})
|
||||
|
||||
const topUpMutation = useMutation<{
|
||||
payment_id: string; payment_url?: string; invoice_url?: string
|
||||
amount_kopeks: number; amount_rubles: number; status: string; expires_at: string | null
|
||||
}, unknown, number>({
|
||||
mutationFn: (amountKopeks: number) => balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined),
|
||||
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)
|
||||
if (status === 'paid') {
|
||||
setError(null);
|
||||
onClose();
|
||||
} else if (status === 'failed') {
|
||||
setError(t('wheel.starsPaymentFailed'));
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
setError('Ошибка: ' + String(e));
|
||||
}
|
||||
},
|
||||
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 axiosError = err as { response?: { data?: { detail?: string }; status?: number } };
|
||||
setError(`Ошибка: ${axiosError?.response?.data?.detail || 'Не удалось создать счёт'}`);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const topUpMutation = useMutation<
|
||||
{
|
||||
payment_id: string;
|
||||
payment_url?: string;
|
||||
invoice_url?: string;
|
||||
amount_kopeks: number;
|
||||
amount_rubles: number;
|
||||
status: string;
|
||||
expires_at: string | null;
|
||||
},
|
||||
unknown,
|
||||
number
|
||||
>({
|
||||
mutationFn: (amountKopeks: number) =>
|
||||
balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined),
|
||||
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 = () => {
|
||||
setError(null)
|
||||
setPaymentUrl(null)
|
||||
inputRef.current?.blur()
|
||||
setError(null);
|
||||
setPaymentUrl(null);
|
||||
inputRef.current?.blur();
|
||||
|
||||
if (!checkRateLimit(RATE_LIMIT_KEYS.PAYMENT, 3, 30000)) {
|
||||
setError('Подождите ' + getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) + ' сек.')
|
||||
return
|
||||
setError('Подождите ' + getRateLimitResetTime(RATE_LIMIT_KEYS.PAYMENT) + ' сек.');
|
||||
return;
|
||||
}
|
||||
if (hasOptions && !selectedOption) { setError('Выберите способ'); return }
|
||||
const amountCurrency = parseFloat(amount)
|
||||
if (isNaN(amountCurrency) || amountCurrency <= 0) { setError('Введите сумму'); return }
|
||||
const amountRubles = convertToRub(amountCurrency)
|
||||
if (hasOptions && !selectedOption) {
|
||||
setError('Выберите способ');
|
||||
return;
|
||||
}
|
||||
const amountCurrency = parseFloat(amount);
|
||||
if (isNaN(amountCurrency) || amountCurrency <= 0) {
|
||||
setError('Введите сумму');
|
||||
return;
|
||||
}
|
||||
const amountRubles = convertToRub(amountCurrency);
|
||||
if (amountRubles < minRubles || amountRubles > maxRubles) {
|
||||
setError(`Сумма: ${minRubles} – ${maxRubles} ₽`); return
|
||||
setError(`Сумма: ${minRubles} – ${maxRubles} ₽`);
|
||||
return;
|
||||
}
|
||||
|
||||
const amountKopeks = Math.round(amountRubles * 100)
|
||||
if (isStarsMethod) { starsPaymentMutation.mutate(amountKopeks) }
|
||||
else { topUpMutation.mutate(amountKopeks) }
|
||||
}
|
||||
const amountKopeks = Math.round(amountRubles * 100);
|
||||
if (isStarsMethod) {
|
||||
starsPaymentMutation.mutate(amountKopeks);
|
||||
} else {
|
||||
topUpMutation.mutate(amountKopeks);
|
||||
}
|
||||
};
|
||||
|
||||
const quickAmounts = [100, 300, 500, 1000].filter((a) => a >= minRubles && a <= maxRubles)
|
||||
const currencyDecimals = (targetCurrency === 'IRR' || targetCurrency === 'RUB') ? 0 : 2
|
||||
const getQuickValue = (rub: number) => (targetCurrency === 'IRR')
|
||||
? Math.round(convertAmount(rub)).toString()
|
||||
: convertAmount(rub).toFixed(currencyDecimals)
|
||||
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending
|
||||
const quickAmounts = [100, 300, 500, 1000].filter((a) => a >= minRubles && a <= maxRubles);
|
||||
const currencyDecimals = targetCurrency === 'IRR' || targetCurrency === 'RUB' ? 0 : 2;
|
||||
const getQuickValue = (rub: number) =>
|
||||
targetCurrency === 'IRR'
|
||||
? Math.round(convertAmount(rub)).toString()
|
||||
: convertAmount(rub).toFixed(currencyDecimals);
|
||||
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending;
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
if (!paymentUrl) return
|
||||
if (!paymentUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(paymentUrl)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
await navigator.clipboard.writeText(paymentUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (e) {
|
||||
console.warn('Failed to copy:', e)
|
||||
console.warn('Failed to copy:', e);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
// Auto-focus input - works on mobile in Telegram WebApp
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
inputRef.current.focus();
|
||||
if (isMobileScreen) {
|
||||
inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Content JSX - shared between mobile and desktop
|
||||
const contentJSX = (
|
||||
<div className="space-y-5">
|
||||
{/* Header icon and method */}
|
||||
<div className="flex items-center gap-4 pb-1">
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center ${
|
||||
isStarsMethod
|
||||
? 'bg-gradient-to-br from-yellow-500/20 to-orange-500/20 text-yellow-400'
|
||||
: 'bg-gradient-to-br from-accent-500/20 to-accent-600/20 text-accent-400'
|
||||
}`}>
|
||||
<div className="w-7 h-7 flex items-center justify-center">
|
||||
{getMethodIcon(method.id)}
|
||||
</div>
|
||||
<div
|
||||
className={`flex h-14 w-14 items-center justify-center rounded-2xl ${
|
||||
isStarsMethod
|
||||
? 'bg-gradient-to-br from-yellow-500/20 to-orange-500/20 text-yellow-400'
|
||||
: 'bg-gradient-to-br from-accent-500/20 to-accent-600/20 text-accent-400'
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-7 w-7 items-center justify-center">{getMethodIcon(method.id)}</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-bold text-dark-100">{methodName}</h3>
|
||||
@@ -299,16 +359,16 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedOption(opt.id)}
|
||||
className={`relative py-3 px-4 rounded-xl text-sm font-semibold transition-all duration-200 ${
|
||||
className={`relative rounded-xl px-4 py-3 text-sm font-semibold transition-all duration-200 ${
|
||||
selectedOption === opt.id
|
||||
? 'bg-accent-500/15 text-accent-400 ring-2 ring-accent-500/40'
|
||||
: 'bg-dark-800/70 text-dark-300 hover:bg-dark-700/70 border border-dark-700/50'
|
||||
: 'border border-dark-700/50 bg-dark-800/70 text-dark-300 hover:bg-dark-700/70'
|
||||
}`}
|
||||
>
|
||||
{opt.name}
|
||||
{selectedOption === opt.id && (
|
||||
<span className="absolute top-1.5 right-1.5">
|
||||
<span className="w-2 h-2 rounded-full bg-accent-500 block" />
|
||||
<span className="absolute right-1.5 top-1.5">
|
||||
<span className="block h-2 w-2 rounded-full bg-accent-500" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -321,11 +381,13 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-dark-400">{t('balance.enterAmount')}</label>
|
||||
<div className="flex gap-2">
|
||||
<div className={`relative flex-1 rounded-2xl transition-all duration-200 ${
|
||||
isInputFocused
|
||||
? 'ring-2 ring-accent-500/50 bg-dark-800'
|
||||
: 'bg-dark-800/70 border border-dark-700/50'
|
||||
}`}>
|
||||
<div
|
||||
className={`relative flex-1 rounded-2xl transition-all duration-200 ${
|
||||
isInputFocused
|
||||
? 'bg-dark-800 ring-2 ring-accent-500/50'
|
||||
: 'border border-dark-700/50 bg-dark-800/70'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
@@ -335,9 +397,14 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
onFocus={() => setIsInputFocused(true)}
|
||||
onBlur={() => setIsInputFocused(false)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleSubmit() } }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="0"
|
||||
className="w-full h-14 px-4 pr-12 text-xl font-bold bg-transparent text-dark-100 placeholder:text-dark-600 focus:outline-none"
|
||||
className="h-14 w-full bg-transparent px-4 pr-12 text-xl font-bold text-dark-100 placeholder:text-dark-600 focus:outline-none"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
@@ -349,16 +416,16 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={isPending || !amount || parseFloat(amount) <= 0}
|
||||
className={`shrink-0 h-14 px-6 rounded-2xl text-base font-bold transition-colors duration-200 overflow-hidden flex items-center justify-center gap-2 ${
|
||||
className={`flex h-14 shrink-0 items-center justify-center gap-2 overflow-hidden rounded-2xl px-6 text-base font-bold transition-colors duration-200 ${
|
||||
isPending || !amount || parseFloat(amount) <= 0
|
||||
? 'bg-dark-700 text-dark-500 cursor-not-allowed'
|
||||
? 'cursor-not-allowed bg-dark-700 text-dark-500'
|
||||
: isStarsMethod
|
||||
? 'bg-gradient-to-r from-yellow-500 to-orange-500 text-white shadow-lg shadow-yellow-500/25 hover:from-yellow-400 hover:to-orange-400 active:from-yellow-600 active:to-orange-600'
|
||||
: 'bg-gradient-to-r from-accent-500 to-accent-600 text-white shadow-lg shadow-accent-500/25 hover:from-accent-400 hover:to-accent-500 active:from-accent-600 active:to-accent-700'
|
||||
}`}
|
||||
>
|
||||
{isPending ? (
|
||||
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
<span className="h-5 w-5 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
) : (
|
||||
<>
|
||||
<SparklesIcon />
|
||||
@@ -373,39 +440,54 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
{quickAmounts.length > 0 && (
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{quickAmounts.map((a) => {
|
||||
const val = getQuickValue(a)
|
||||
const isSelected = amount === val
|
||||
const val = getQuickValue(a);
|
||||
const isSelected = amount === val;
|
||||
return (
|
||||
<BentoCard
|
||||
key={a}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => { setAmount(val); inputRef.current?.blur() }}
|
||||
onClick={() => {
|
||||
setAmount(val);
|
||||
inputRef.current?.blur();
|
||||
}}
|
||||
hover
|
||||
glow={isSelected}
|
||||
className={`flex flex-col items-center justify-center py-3 px-2 ${
|
||||
isSelected
|
||||
? 'border-accent-500/50 bg-accent-500/10'
|
||||
: ''
|
||||
className={`flex flex-col items-center justify-center px-2 py-3 ${
|
||||
isSelected ? 'border-accent-500/50 bg-accent-500/10' : ''
|
||||
}`}
|
||||
>
|
||||
<span className={`text-base font-bold ${isSelected ? 'text-accent-400' : 'text-dark-200'}`}>
|
||||
<span
|
||||
className={`text-base font-bold ${isSelected ? 'text-accent-400' : 'text-dark-200'}`}
|
||||
>
|
||||
{formatAmount(a, 0)}
|
||||
</span>
|
||||
<span className={`text-xs mt-0.5 ${isSelected ? 'text-accent-400/70' : 'text-dark-500'}`}>
|
||||
<span
|
||||
className={`mt-0.5 text-xs ${isSelected ? 'text-accent-400/70' : 'text-dark-500'}`}
|
||||
>
|
||||
{currencySymbol}
|
||||
</span>
|
||||
</BentoCard>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-xl bg-error-500/10 border border-error-500/20">
|
||||
<svg className="w-5 h-5 text-error-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<div className="flex items-center gap-2 rounded-xl border border-error-500/20 bg-error-500/10 p-3">
|
||||
<svg
|
||||
className="h-5 w-5 shrink-0 text-error-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-error-400">{error}</span>
|
||||
</div>
|
||||
@@ -413,14 +495,19 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
|
||||
{/* Payment link display - shown when URL is received */}
|
||||
{paymentUrl && (
|
||||
<div className="space-y-3 p-4 rounded-2xl bg-success-500/10 border border-success-500/20">
|
||||
<div className="space-y-3 rounded-2xl border border-success-500/20 bg-success-500/10 p-4">
|
||||
<div className="flex items-center gap-2 text-success-400">
|
||||
<CheckIcon />
|
||||
<span className="font-semibold">{t('balance.paymentReady', 'Ссылка на оплату готова')}</span>
|
||||
<span className="font-semibold">
|
||||
{t('balance.paymentReady', 'Ссылка на оплату готова')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('balance.clickToOpenPayment', 'Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке')}
|
||||
{t(
|
||||
'balance.clickToOpenPayment',
|
||||
'Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке',
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Main open button - NO preventDefault, let <a> work natively for iOS Safari */}
|
||||
@@ -428,7 +515,7 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
href={paymentUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full h-12 rounded-xl bg-success-500 text-white font-bold hover:bg-success-400 active:bg-success-600 transition-colors"
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-xl bg-success-500 font-bold text-white transition-colors hover:bg-success-400 active:bg-success-600"
|
||||
>
|
||||
<ExternalLinkIcon />
|
||||
<span>{t('balance.openPaymentPage', 'Открыть страницу оплаты')}</span>
|
||||
@@ -436,13 +523,13 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
|
||||
{/* Copy and link display */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 min-w-0 px-3 py-2 rounded-lg bg-dark-800/70 border border-dark-700/50">
|
||||
<p className="text-xs text-dark-500 truncate">{paymentUrl}</p>
|
||||
<div className="min-w-0 flex-1 rounded-lg border border-dark-700/50 bg-dark-800/70 px-3 py-2">
|
||||
<p className="truncate text-xs text-dark-500">{paymentUrl}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyUrl}
|
||||
className={`shrink-0 p-2.5 rounded-lg transition-colors ${
|
||||
className={`shrink-0 rounded-lg p-2.5 transition-colors ${
|
||||
copied
|
||||
? 'bg-success-500/20 text-success-400'
|
||||
: 'bg-dark-800/70 text-dark-400 hover:bg-dark-700 hover:text-dark-200'
|
||||
@@ -455,49 +542,48 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
// Render modal based on screen size - NO nested components!
|
||||
const modalContent = isMobileScreen ? (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 z-[9998] bg-black/70"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
<div className="fixed inset-0 z-[9998] bg-black/70" onClick={handleClose} />
|
||||
{/* Bottom sheet */}
|
||||
<div
|
||||
data-modal-content
|
||||
className="fixed inset-x-0 bottom-0 z-[9999] bg-dark-900 rounded-t-3xl max-h-[90vh] flex flex-col overflow-hidden"
|
||||
style={{ paddingBottom: safeBottom ? `${safeBottom + 20}px` : 'max(20px, env(safe-area-inset-bottom))' }}
|
||||
className="fixed inset-x-0 bottom-0 z-[9999] flex max-h-[90vh] flex-col overflow-hidden rounded-t-3xl bg-dark-900"
|
||||
style={{
|
||||
paddingBottom: safeBottom
|
||||
? `${safeBottom + 20}px`
|
||||
: 'max(20px, env(safe-area-inset-bottom))',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Handle bar */}
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<div className="w-10 h-1 rounded-full bg-dark-600" />
|
||||
<div className="flex justify-center pb-1 pt-3">
|
||||
<div className="h-1 w-10 rounded-full bg-dark-600" />
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletIcon />
|
||||
<span className="font-bold text-dark-100 text-lg">{t('balance.topUp')}</span>
|
||||
<span className="text-lg font-bold text-dark-100">{t('balance.topUp')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 -mr-2 rounded-xl hover:bg-dark-800 text-dark-400 transition-colors"
|
||||
className="-mr-2 rounded-xl p-2 text-dark-400 transition-colors hover:bg-dark-800"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-dark-700 to-transparent mx-5" />
|
||||
<div className="mx-5 h-px bg-gradient-to-r from-transparent via-dark-700 to-transparent" />
|
||||
|
||||
{/* Content */}
|
||||
<div className="px-5 py-5 overflow-y-auto">
|
||||
{contentJSX}
|
||||
</div>
|
||||
<div className="overflow-y-auto px-5 py-5">{contentJSX}</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
@@ -507,35 +593,33 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
||||
>
|
||||
<div
|
||||
data-modal-content
|
||||
className="w-full max-w-md bg-dark-900 rounded-3xl border border-dark-700/50 shadow-2xl overflow-hidden"
|
||||
className="w-full max-w-md overflow-hidden rounded-3xl border border-dark-700/50 bg-dark-900 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 bg-gradient-to-r from-dark-800/80 to-dark-800/40 border-b border-dark-700/50">
|
||||
<div className="flex items-center justify-between border-b border-dark-700/50 bg-gradient-to-r from-dark-800/80 to-dark-800/40 px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-accent-500/10 flex items-center justify-center text-accent-400">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent-500/10 text-accent-400">
|
||||
<WalletIcon />
|
||||
</div>
|
||||
<span className="font-bold text-dark-100 text-lg">{t('balance.topUp')}</span>
|
||||
<span className="text-lg font-bold text-dark-100">{t('balance.topUp')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 -mr-1 rounded-xl hover:bg-dark-700 text-dark-400 transition-colors"
|
||||
className="-mr-1 rounded-xl p-2 text-dark-400 transition-colors hover:bg-dark-700"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6">
|
||||
{contentJSX}
|
||||
</div>
|
||||
<div className="p-6">{contentJSX}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
|
||||
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 { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { brandingApi } from '../../api/branding'
|
||||
import { CheckIcon, CloseIcon } from './icons'
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { brandingApi } from '../../api/branding';
|
||||
import { CheckIcon, CloseIcon } from './icons';
|
||||
|
||||
export function AnalyticsTab() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Editing states
|
||||
const [editingYandex, setEditingYandex] = useState(false)
|
||||
const [editingGoogleId, setEditingGoogleId] = useState(false)
|
||||
const [editingGoogleLabel, setEditingGoogleLabel] = useState(false)
|
||||
const [yandexValue, setYandexValue] = useState('')
|
||||
const [googleIdValue, setGoogleIdValue] = useState('')
|
||||
const [googleLabelValue, setGoogleLabelValue] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [editingYandex, setEditingYandex] = useState(false);
|
||||
const [editingGoogleId, setEditingGoogleId] = useState(false);
|
||||
const [editingGoogleLabel, setEditingGoogleLabel] = useState(false);
|
||||
const [yandexValue, setYandexValue] = useState('');
|
||||
const [googleIdValue, setGoogleIdValue] = useState('');
|
||||
const [googleLabelValue, setGoogleLabelValue] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Query
|
||||
const { data: analytics } = useQuery({
|
||||
queryKey: ['analytics-counters'],
|
||||
queryFn: brandingApi.getAnalyticsCounters,
|
||||
})
|
||||
});
|
||||
|
||||
// Mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: brandingApi.updateAnalyticsCounters,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['analytics-counters'] })
|
||||
setError(null)
|
||||
queryClient.invalidateQueries({ queryKey: ['analytics-counters'] });
|
||||
setError(null);
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail
|
||||
setError(detail || t('common.error'))
|
||||
const detail = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail;
|
||||
setError(detail || t('common.error'));
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleSaveYandex = () => {
|
||||
updateMutation.mutate(
|
||||
{ yandex_metrika_id: yandexValue.trim() },
|
||||
{ onSuccess: () => setEditingYandex(false) },
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveGoogleId = () => {
|
||||
updateMutation.mutate(
|
||||
{ google_ads_id: googleIdValue.trim() },
|
||||
{ onSuccess: () => setEditingGoogleId(false) },
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveGoogleLabel = () => {
|
||||
updateMutation.mutate(
|
||||
{ google_ads_label: googleLabelValue.trim() },
|
||||
{ onSuccess: () => setEditingGoogleLabel(false) },
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const yandexActive = Boolean(analytics?.yandex_metrika_id)
|
||||
const googleActive = Boolean(analytics?.google_ads_id)
|
||||
const yandexActive = Boolean(analytics?.yandex_metrika_id);
|
||||
const googleActive = Boolean(analytics?.google_ads_id);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="p-4 rounded-2xl bg-error-500/10 border border-error-500/30 text-error-400 text-sm">
|
||||
<div className="rounded-2xl border border-error-500/30 bg-error-500/10 p-4 text-sm text-error-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Yandex Metrika */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-yellow-500/20 to-red-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-yellow-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<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"/>
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-yellow-500/20 to-red-500/20">
|
||||
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.yandexMetrika')}
|
||||
</h3>
|
||||
</div>
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||
yandexActive
|
||||
? 'bg-success-500/15 text-success-400'
|
||||
: 'bg-dark-700/50 text-dark-500'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${yandexActive ? 'bg-success-400' : 'bg-dark-600'}`} />
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
|
||||
yandexActive ? 'bg-success-500/15 text-success-400' : 'bg-dark-700/50 text-dark-500'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${yandexActive ? 'bg-success-400' : 'bg-dark-600'}`}
|
||||
/>
|
||||
{yandexActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-dark-400 mb-5 ml-[52px]">
|
||||
<p className="mb-5 ml-[52px] text-sm text-dark-400">
|
||||
{t('admin.settings.yandexMetrikaDesc')}
|
||||
</p>
|
||||
|
||||
@@ -106,73 +108,84 @@ export function AnalyticsTab() {
|
||||
value={yandexValue}
|
||||
onChange={(e) => setYandexValue(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder={t('admin.settings.yandexIdPlaceholder')}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors"
|
||||
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2.5 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveYandex}
|
||||
disabled={updateMutation.isPending}
|
||||
className="px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditingYandex(false); setError(null) }}
|
||||
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
|
||||
onClick={() => {
|
||||
setEditingYandex(false);
|
||||
setError(null);
|
||||
}}
|
||||
className="rounded-xl bg-dark-700 px-4 py-2.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-base ${analytics?.yandex_metrika_id ? 'text-dark-100 font-mono' : 'text-dark-500'}`}>
|
||||
<span
|
||||
className={`text-base ${analytics?.yandex_metrika_id ? 'font-mono text-dark-100' : 'text-dark-500'}`}
|
||||
>
|
||||
{analytics?.yandex_metrika_id || t('admin.settings.notConfigured')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setYandexValue(analytics?.yandex_metrika_id || '')
|
||||
setEditingYandex(true)
|
||||
setError(null)
|
||||
setYandexValue(analytics?.yandex_metrika_id || '');
|
||||
setEditingYandex(true);
|
||||
setError(null);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
|
||||
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-dark-500">
|
||||
{t('admin.settings.yandexIdHint')}
|
||||
</p>
|
||||
<p className="text-xs text-dark-500">{t('admin.settings.yandexIdHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Google Ads */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500/20 to-green-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5 text-blue-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<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"/>
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-500/20 to-green-500/20">
|
||||
<svg className="h-5 w-5 text-blue-400" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.87 15.07l-2.54-2.51.03-.03A17.52 17.52 0 0014.07 6H17V4h-7V2H8v2H1v1.99h11.17C11.5 7.92 10.44 9.75 9 11.35 8.07 10.32 7.3 9.19 6.69 8h-2c.73 1.63 1.73 3.17 2.98 4.56l-5.09 5.02L4 19l5-5 3.11 3.11.76-2.04zM18.5 10h-2L12 22h2l1.12-3h4.75L21 22h2l-4.5-12zm-2.62 7l1.62-4.33L19.12 17h-3.24z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.googleAds')}
|
||||
</h3>
|
||||
<h3 className="text-lg font-semibold text-dark-100">{t('admin.settings.googleAds')}</h3>
|
||||
</div>
|
||||
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${
|
||||
googleActive
|
||||
? 'bg-success-500/15 text-success-400'
|
||||
: 'bg-dark-700/50 text-dark-500'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${googleActive ? 'bg-success-400' : 'bg-dark-600'}`} />
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ${
|
||||
googleActive ? 'bg-success-500/15 text-success-400' : 'bg-dark-700/50 text-dark-500'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 rounded-full ${googleActive ? 'bg-success-400' : 'bg-dark-600'}`}
|
||||
/>
|
||||
{googleActive ? t('admin.settings.counterActive') : t('admin.settings.counterInactive')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-dark-400 mb-5 ml-[52px]">
|
||||
{t('admin.settings.googleAdsDesc')}
|
||||
</p>
|
||||
<p className="mb-5 ml-[52px] text-sm text-dark-400">{t('admin.settings.googleAdsDesc')}</p>
|
||||
|
||||
<div className="space-y-5">
|
||||
{/* Conversion ID */}
|
||||
@@ -187,45 +200,58 @@ export function AnalyticsTab() {
|
||||
value={googleIdValue}
|
||||
onChange={(e) => setGoogleIdValue(e.target.value)}
|
||||
placeholder={t('admin.settings.googleIdPlaceholder')}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors"
|
||||
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2.5 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveGoogleId}
|
||||
disabled={updateMutation.isPending}
|
||||
className="px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditingGoogleId(false); setError(null) }}
|
||||
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
|
||||
onClick={() => {
|
||||
setEditingGoogleId(false);
|
||||
setError(null);
|
||||
}}
|
||||
className="rounded-xl bg-dark-700 px-4 py-2.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-base ${analytics?.google_ads_id ? 'text-dark-100 font-mono' : 'text-dark-500'}`}>
|
||||
<span
|
||||
className={`text-base ${analytics?.google_ads_id ? 'font-mono text-dark-100' : 'text-dark-500'}`}
|
||||
>
|
||||
{analytics?.google_ads_id || t('admin.settings.notConfigured')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setGoogleIdValue(analytics?.google_ads_id || '')
|
||||
setEditingGoogleId(true)
|
||||
setError(null)
|
||||
setGoogleIdValue(analytics?.google_ads_id || '');
|
||||
setEditingGoogleId(true);
|
||||
setError(null);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
|
||||
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-dark-500">
|
||||
{t('admin.settings.googleIdHint')}
|
||||
</p>
|
||||
<p className="text-xs text-dark-500">{t('admin.settings.googleIdHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Conversion Label */}
|
||||
@@ -240,55 +266,66 @@ export function AnalyticsTab() {
|
||||
value={googleLabelValue}
|
||||
onChange={(e) => setGoogleLabelValue(e.target.value)}
|
||||
placeholder={t('admin.settings.googleLabelPlaceholder')}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 transition-colors"
|
||||
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2.5 text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveGoogleLabel}
|
||||
disabled={updateMutation.isPending}
|
||||
className="px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2.5 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setEditingGoogleLabel(false); setError(null) }}
|
||||
className="px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
|
||||
onClick={() => {
|
||||
setEditingGoogleLabel(false);
|
||||
setError(null);
|
||||
}}
|
||||
className="rounded-xl bg-dark-700 px-4 py-2.5 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-base ${analytics?.google_ads_label ? 'text-dark-100 font-mono' : 'text-dark-500'}`}>
|
||||
<span
|
||||
className={`text-base ${analytics?.google_ads_label ? 'font-mono text-dark-100' : 'text-dark-500'}`}
|
||||
>
|
||||
{analytics?.google_ads_label || t('admin.settings.notConfigured')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setGoogleLabelValue(analytics?.google_ads_label || '')
|
||||
setEditingGoogleLabel(true)
|
||||
setError(null)
|
||||
setGoogleLabelValue(analytics?.google_ads_label || '');
|
||||
setEditingGoogleLabel(true);
|
||||
setError(null);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
|
||||
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-dark-500">
|
||||
{t('admin.settings.googleLabelHint')}
|
||||
</p>
|
||||
<p className="text-xs text-dark-500">{t('admin.settings.googleLabelHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info block */}
|
||||
<div className="p-4 rounded-2xl bg-dark-800/30 border border-dark-700/30">
|
||||
<p className="text-sm text-dark-500 leading-relaxed">
|
||||
{t('admin.settings.analyticsHint')}
|
||||
</p>
|
||||
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-4">
|
||||
<p className="text-sm leading-relaxed text-dark-500">{t('admin.settings.analyticsHint')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,124 +1,130 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { brandingApi, setCachedBranding } from '../../api/branding'
|
||||
import { setCachedAnimationEnabled } from '../AnimatedBackground'
|
||||
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp'
|
||||
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons'
|
||||
import { Toggle } from './Toggle'
|
||||
import { useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { brandingApi, setCachedBranding } from '../../api/branding';
|
||||
import { setCachedAnimationEnabled } from '../AnimatedBackground';
|
||||
import { setCachedFullscreenEnabled } from '../../hooks/useTelegramWebApp';
|
||||
import { UploadIcon, TrashIcon, PencilIcon, CheckIcon, CloseIcon } from './icons';
|
||||
import { Toggle } from './Toggle';
|
||||
|
||||
interface BrandingTabProps {
|
||||
accentColor?: string
|
||||
accentColor?: string;
|
||||
}
|
||||
|
||||
export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [editingName, setEditingName] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
|
||||
// Queries
|
||||
const { data: branding } = useQuery({
|
||||
queryKey: ['branding'],
|
||||
queryFn: brandingApi.getBranding,
|
||||
})
|
||||
});
|
||||
|
||||
const { data: animationSettings } = useQuery({
|
||||
queryKey: ['animation-enabled'],
|
||||
queryFn: brandingApi.getAnimationEnabled,
|
||||
})
|
||||
});
|
||||
|
||||
const { data: fullscreenSettings } = useQuery({
|
||||
queryKey: ['fullscreen-enabled'],
|
||||
queryFn: brandingApi.getFullscreenEnabled,
|
||||
})
|
||||
});
|
||||
|
||||
const { data: emailAuthSettings } = useQuery({
|
||||
queryKey: ['email-auth-enabled'],
|
||||
queryFn: brandingApi.getEmailAuthEnabled,
|
||||
})
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const updateBrandingMutation = useMutation({
|
||||
mutationFn: brandingApi.updateName,
|
||||
onSuccess: (data) => {
|
||||
setCachedBranding(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] })
|
||||
setEditingName(false)
|
||||
setCachedBranding(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] });
|
||||
setEditingName(false);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const uploadLogoMutation = useMutation({
|
||||
mutationFn: brandingApi.uploadLogo,
|
||||
onSuccess: (data) => {
|
||||
setCachedBranding(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] })
|
||||
setCachedBranding(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const deleteLogoMutation = useMutation({
|
||||
mutationFn: brandingApi.deleteLogo,
|
||||
onSuccess: (data) => {
|
||||
setCachedBranding(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] })
|
||||
setCachedBranding(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['branding'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const updateAnimationMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => brandingApi.updateAnimationEnabled(enabled),
|
||||
onSuccess: (data) => {
|
||||
setCachedAnimationEnabled(data.enabled)
|
||||
queryClient.invalidateQueries({ queryKey: ['animation-enabled'] })
|
||||
setCachedAnimationEnabled(data.enabled);
|
||||
queryClient.invalidateQueries({ queryKey: ['animation-enabled'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const updateFullscreenMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => brandingApi.updateFullscreenEnabled(enabled),
|
||||
onSuccess: (data) => {
|
||||
setCachedFullscreenEnabled(data.enabled)
|
||||
queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] })
|
||||
setCachedFullscreenEnabled(data.enabled);
|
||||
queryClient.invalidateQueries({ queryKey: ['fullscreen-enabled'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const updateEmailAuthMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => brandingApi.updateEmailAuthEnabled(enabled),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['email-auth-enabled'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['email-auth-enabled'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleLogoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
uploadLogoMutation.mutate(file)
|
||||
uploadLogoMutation.mutate(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Logo & Name */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.settings.logoAndName')}</h3>
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.logoAndName')}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-start gap-6">
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0">
|
||||
<div
|
||||
className="w-20 h-20 rounded-2xl flex items-center justify-center text-3xl font-bold text-white overflow-hidden"
|
||||
className="flex h-20 w-20 items-center justify-center overflow-hidden rounded-2xl text-3xl font-bold text-white"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}dd)`
|
||||
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}dd)`,
|
||||
}}
|
||||
>
|
||||
{branding?.has_custom_logo ? (
|
||||
<img src={brandingApi.getLogoUrl(branding) ?? undefined} alt="Logo" className="w-full h-full object-cover" />
|
||||
<img
|
||||
src={brandingApi.getLogoUrl(branding) ?? undefined}
|
||||
alt="Logo"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
branding?.logo_letter || 'V'
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mt-3">
|
||||
<div className="mt-3 flex gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@@ -129,7 +135,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploadLogoMutation.isPending}
|
||||
className="flex-1 flex items-center justify-center gap-1 px-3 py-2 rounded-xl bg-dark-700 hover:bg-dark-600 text-dark-200 text-sm transition-colors disabled:opacity-50"
|
||||
className="flex flex-1 items-center justify-center gap-1 rounded-xl bg-dark-700 px-3 py-2 text-sm text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
<UploadIcon />
|
||||
</button>
|
||||
@@ -137,7 +143,7 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
<button
|
||||
onClick={() => deleteLogoMutation.mutate()}
|
||||
disabled={deleteLogoMutation.isPending}
|
||||
className="px-3 py-2 rounded-xl bg-dark-700 hover:bg-error-500/20 text-dark-400 hover:text-error-400 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl bg-dark-700 px-3 py-2 text-dark-400 transition-colors hover:bg-error-500/20 hover:text-error-400 disabled:opacity-50"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
@@ -147,39 +153,43 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
|
||||
{/* Name */}
|
||||
<div className="flex-1">
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">{t('admin.settings.projectName')}</label>
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.settings.projectName')}
|
||||
</label>
|
||||
{editingName ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
className="flex-1 px-4 py-2 rounded-xl bg-dark-700 border border-dark-600 text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
className="flex-1 rounded-xl border border-dark-600 bg-dark-700 px-4 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
maxLength={50}
|
||||
/>
|
||||
<button
|
||||
onClick={() => updateBrandingMutation.mutate(newName)}
|
||||
disabled={updateBrandingMutation.isPending}
|
||||
className="px-4 py-2 rounded-xl bg-accent-500 text-white hover:bg-accent-600 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingName(false)}
|
||||
className="px-4 py-2 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors"
|
||||
className="rounded-xl bg-dark-700 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg text-dark-100">{branding?.name || t('admin.settings.notSpecified')}</span>
|
||||
<span className="text-lg text-dark-100">
|
||||
{branding?.name || t('admin.settings.notSpecified')}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setNewName(branding?.name ?? '')
|
||||
setEditingName(true)
|
||||
setNewName(branding?.name ?? '');
|
||||
setEditingName(true);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors"
|
||||
className="rounded-lg p-1.5 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
>
|
||||
<PencilIcon />
|
||||
</button>
|
||||
@@ -190,13 +200,17 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
</div>
|
||||
|
||||
{/* Animation & Fullscreen toggles */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.settings.interfaceOptions')}</h3>
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.interfaceOptions')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-dark-700/30">
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
|
||||
<div>
|
||||
<span className="font-medium text-dark-100">{t('admin.settings.animatedBackground')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{t('admin.settings.animatedBackground')}
|
||||
</span>
|
||||
<p className="text-sm text-dark-400">{t('admin.settings.animatedBackgroundDesc')}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
@@ -206,19 +220,23 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-dark-700/30">
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
|
||||
<div>
|
||||
<span className="font-medium text-dark-100">{t('admin.settings.autoFullscreen')}</span>
|
||||
<span className="font-medium text-dark-100">
|
||||
{t('admin.settings.autoFullscreen')}
|
||||
</span>
|
||||
<p className="text-sm text-dark-400">{t('admin.settings.autoFullscreenDesc')}</p>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={fullscreenSettings?.enabled ?? false}
|
||||
onChange={() => updateFullscreenMutation.mutate(!(fullscreenSettings?.enabled ?? false))}
|
||||
onChange={() =>
|
||||
updateFullscreenMutation.mutate(!(fullscreenSettings?.enabled ?? false))
|
||||
}
|
||||
disabled={updateFullscreenMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 rounded-xl bg-dark-700/30">
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-4">
|
||||
<div>
|
||||
<span className="font-medium text-dark-100">{t('admin.settings.emailAuth')}</span>
|
||||
<p className="text-sm text-dark-400">{t('admin.settings.emailAuthDesc')}</p>
|
||||
@@ -232,5 +250,5 @@ export function BrandingTab({ accentColor = '#3b82f6' }: BrandingTabProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,47 +1,48 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings'
|
||||
import { StarIcon } from './icons'
|
||||
import { SettingRow } from './SettingRow'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
|
||||
import { StarIcon } from './icons';
|
||||
import { SettingRow } from './SettingRow';
|
||||
|
||||
interface FavoritesTabProps {
|
||||
settings: SettingDefinition[]
|
||||
isFavorite: (key: string) => boolean
|
||||
toggleFavorite: (key: string) => void
|
||||
settings: SettingDefinition[];
|
||||
isFavorite: (key: string) => boolean;
|
||||
toggleFavorite: (key: string) => void;
|
||||
}
|
||||
|
||||
export function FavoritesTab({ settings, isFavorite, toggleFavorite }: FavoritesTabProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateSettingMutation = useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) => adminSettingsApi.updateSetting(key, value),
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||
adminSettingsApi.updateSetting(key, value),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const resetSettingMutation = useMutation({
|
||||
mutationFn: (key: string) => adminSettingsApi.resetSetting(key),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
if (settings.length === 0) {
|
||||
return (
|
||||
<div className="p-12 rounded-2xl bg-dark-800/30 border border-dark-700/30 text-center">
|
||||
<div className="flex justify-center mb-4 text-dark-500">
|
||||
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
|
||||
<div className="mb-4 flex justify-center text-dark-500">
|
||||
<StarIcon filled={false} />
|
||||
</div>
|
||||
<p className="text-dark-400">{t('admin.settings.favoritesEmpty')}</p>
|
||||
<p className="text-dark-500 text-sm mt-1">{t('admin.settings.favoritesHint')}</p>
|
||||
<p className="mt-1 text-sm text-dark-500">{t('admin.settings.favoritesHint')}</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{settings.map((setting) => (
|
||||
<SettingRow
|
||||
key={setting.key}
|
||||
@@ -55,5 +56,5 @@ export function FavoritesTab({ settings, isFavorite, toggleFavorite }: Favorites
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { SettingDefinition } from '../../api/adminSettings'
|
||||
import { CheckIcon, CloseIcon, EditIcon } from './icons'
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { SettingDefinition } from '../../api/adminSettings';
|
||||
import { CheckIcon, CloseIcon, EditIcon } from './icons';
|
||||
|
||||
interface SettingInputProps {
|
||||
setting: SettingDefinition
|
||||
onUpdate: (value: string) => void
|
||||
disabled?: boolean
|
||||
setting: SettingDefinition;
|
||||
onUpdate: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// Check if value is likely JSON or multi-line
|
||||
function isLongValue(value: string | null | undefined): boolean {
|
||||
if (!value) return false
|
||||
const str = String(value)
|
||||
return str.length > 50 || str.includes('\n') || str.startsWith('[') || str.startsWith('{')
|
||||
if (!value) return false;
|
||||
const str = String(value);
|
||||
return str.length > 50 || str.includes('\n') || str.startsWith('[') || str.startsWith('{');
|
||||
}
|
||||
|
||||
// Check if key suggests it's a list or JSON config
|
||||
function isListOrJsonKey(key: string): boolean {
|
||||
const lowerKey = key.toLowerCase()
|
||||
const lowerKey = key.toLowerCase();
|
||||
return (
|
||||
lowerKey.includes('_items') ||
|
||||
lowerKey.includes('_config') ||
|
||||
@@ -28,40 +28,40 @@ function isListOrJsonKey(key: string): boolean {
|
||||
lowerKey.includes('_periods') ||
|
||||
lowerKey.includes('_discounts') ||
|
||||
lowerKey.includes('_packages')
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps) {
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [value, setValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [value, setValue] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const currentValue = String(setting.current ?? '')
|
||||
const needsTextarea = isLongValue(currentValue) || isListOrJsonKey(setting.key)
|
||||
const currentValue = String(setting.current ?? '');
|
||||
const needsTextarea = isLongValue(currentValue) || isListOrJsonKey(setting.key);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
if (textareaRef.current && isEditing) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 300) + 'px'
|
||||
textareaRef.current.style.height = 'auto';
|
||||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 300) + 'px';
|
||||
}
|
||||
}, [value, isEditing])
|
||||
}, [value, isEditing]);
|
||||
|
||||
const handleStart = () => {
|
||||
setValue(currentValue)
|
||||
setIsEditing(true)
|
||||
}
|
||||
setValue(currentValue);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onUpdate(value)
|
||||
setIsEditing(false)
|
||||
}
|
||||
onUpdate(value);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false)
|
||||
setValue('')
|
||||
}
|
||||
setIsEditing(false);
|
||||
setValue('');
|
||||
};
|
||||
|
||||
// Dropdown for choices
|
||||
if (setting.choices && setting.choices.length > 0) {
|
||||
@@ -70,7 +70,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
value={currentValue}
|
||||
onChange={(e) => onUpdate(e.target.value)}
|
||||
disabled={disabled}
|
||||
className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm text-dark-100 focus:outline-none focus:border-accent-500 focus:ring-1 focus:ring-accent-500/30 disabled:opacity-50 min-w-[140px] cursor-pointer"
|
||||
className="min-w-[140px] cursor-pointer rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-sm text-dark-100 focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500/30 disabled:opacity-50"
|
||||
>
|
||||
{setting.choices.map((choice, idx) => (
|
||||
<option key={idx} value={String(choice.value)}>
|
||||
@@ -78,7 +78,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Editing mode - Textarea for long values
|
||||
@@ -90,26 +90,26 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') handleCancel()
|
||||
if (e.key === 'Escape') handleCancel();
|
||||
// Ctrl+Enter to save
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSave()
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) handleSave();
|
||||
}}
|
||||
autoFocus
|
||||
placeholder="Введите значение..."
|
||||
className="w-full bg-dark-700 border border-accent-500 rounded-xl px-4 py-3 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 font-mono resize-none min-h-[100px]"
|
||||
className="min-h-[100px] w-full resize-none rounded-xl border border-accent-500 bg-dark-700 px-4 py-3 font-mono text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-dark-500">Ctrl+Enter для сохранения</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-3 py-1.5 rounded-lg bg-dark-600 text-dark-300 hover:bg-dark-500 transition-colors text-sm"
|
||||
className="rounded-lg bg-dark-600 px-3 py-1.5 text-sm text-dark-300 transition-colors hover:bg-dark-500"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="px-3 py-1.5 rounded-lg bg-accent-500 text-white hover:bg-accent-600 transition-colors text-sm flex items-center gap-1.5"
|
||||
className="flex items-center gap-1.5 rounded-lg bg-accent-500 px-3 py-1.5 text-sm text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<CheckIcon />
|
||||
Сохранить
|
||||
@@ -117,7 +117,7 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Editing mode - Regular input
|
||||
@@ -130,50 +130,51 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSave()
|
||||
if (e.key === 'Escape') handleCancel()
|
||||
if (e.key === 'Enter') handleSave();
|
||||
if (e.key === 'Escape') handleCancel();
|
||||
}}
|
||||
autoFocus
|
||||
placeholder="Введите значение..."
|
||||
className="bg-dark-700 border border-accent-500 rounded-lg px-3 py-2 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 w-48 sm:w-56"
|
||||
className="w-48 rounded-lg border border-accent-500 bg-dark-700 px-3 py-2 text-sm text-dark-100 focus:outline-none focus:ring-2 focus:ring-accent-500/30 sm:w-56"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="p-2 rounded-lg bg-accent-500 text-white hover:bg-accent-600 transition-colors"
|
||||
className="rounded-lg bg-accent-500 p-2 text-white transition-colors hover:bg-accent-600"
|
||||
title="Сохранить (Enter)"
|
||||
>
|
||||
<CheckIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="p-2 rounded-lg bg-dark-600 text-dark-300 hover:bg-dark-500 transition-colors"
|
||||
className="rounded-lg bg-dark-600 p-2 text-dark-300 transition-colors hover:bg-dark-500"
|
||||
title="Отмена (Esc)"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Display mode - Long value preview
|
||||
if (needsTextarea) {
|
||||
const displayValue = currentValue || '-'
|
||||
const previewValue = displayValue.length > 60 ? displayValue.slice(0, 60) + '...' : displayValue
|
||||
const displayValue = currentValue || '-';
|
||||
const previewValue =
|
||||
displayValue.length > 60 ? displayValue.slice(0, 60) + '...' : displayValue;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={disabled}
|
||||
className="w-full bg-dark-700/50 border border-dark-600 rounded-xl px-4 py-3 text-sm text-dark-200 hover:border-dark-500 hover:bg-dark-700 transition-colors disabled:opacity-50 text-left font-mono group"
|
||||
className="group w-full rounded-xl border border-dark-600 bg-dark-700/50 px-4 py-3 text-left font-mono text-sm text-dark-200 transition-colors hover:border-dark-500 hover:bg-dark-700 disabled:opacity-50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="break-all line-clamp-2 flex-1">{previewValue}</span>
|
||||
<span className="text-dark-500 group-hover:text-accent-400 transition-colors flex-shrink-0">
|
||||
<span className="line-clamp-2 flex-1 break-all">{previewValue}</span>
|
||||
<span className="flex-shrink-0 text-dark-500 transition-colors group-hover:text-accent-400">
|
||||
<EditIcon />
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Display mode - Short value
|
||||
@@ -181,12 +182,12 @@ export function SettingInput({ setting, onUpdate, disabled }: SettingInputProps)
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={disabled}
|
||||
className="bg-dark-700 border border-dark-600 rounded-lg px-3 py-2 text-sm text-dark-200 hover:border-dark-500 hover:bg-dark-600 transition-colors disabled:opacity-50 min-w-[100px] text-left font-mono truncate max-w-[200px] flex items-center gap-2 group"
|
||||
className="group flex min-w-[100px] max-w-[200px] items-center gap-2 truncate rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-left font-mono text-sm text-dark-200 transition-colors hover:border-dark-500 hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
<span className="truncate flex-1">{currentValue || '-'}</span>
|
||||
<span className="text-dark-500 group-hover:text-accent-400 transition-colors opacity-0 group-hover:opacity-100">
|
||||
<span className="flex-1 truncate">{currentValue || '-'}</span>
|
||||
<span className="text-dark-500 opacity-0 transition-colors group-hover:text-accent-400 group-hover:opacity-100">
|
||||
<EditIcon />
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SettingDefinition } from '../../api/adminSettings'
|
||||
import { StarIcon, LockIcon, RefreshIcon } from './icons'
|
||||
import { SettingInput } from './SettingInput'
|
||||
import { Toggle } from './Toggle'
|
||||
import { formatSettingKey, stripHtml } from './utils'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SettingDefinition } from '../../api/adminSettings';
|
||||
import { StarIcon, LockIcon, RefreshIcon } from './icons';
|
||||
import { SettingInput } from './SettingInput';
|
||||
import { Toggle } from './Toggle';
|
||||
import { formatSettingKey, stripHtml } from './utils';
|
||||
|
||||
interface SettingRowProps {
|
||||
setting: SettingDefinition
|
||||
isFavorite: boolean
|
||||
onToggleFavorite: () => void
|
||||
onUpdate: (value: string) => void
|
||||
onReset: () => void
|
||||
isUpdating?: boolean
|
||||
isResetting?: boolean
|
||||
setting: SettingDefinition;
|
||||
isFavorite: boolean;
|
||||
onToggleFavorite: () => void;
|
||||
onUpdate: (value: string) => void;
|
||||
onReset: () => void;
|
||||
isUpdating?: boolean;
|
||||
isResetting?: boolean;
|
||||
}
|
||||
|
||||
export function SettingRow({
|
||||
@@ -22,18 +22,18 @@ export function SettingRow({
|
||||
onUpdate,
|
||||
onReset,
|
||||
isUpdating,
|
||||
isResetting
|
||||
isResetting,
|
||||
}: SettingRowProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
const formattedKey = formatSettingKey(setting.name || setting.key)
|
||||
const displayName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey)
|
||||
const description = setting.hint?.description ? stripHtml(setting.hint.description) : null
|
||||
const formattedKey = formatSettingKey(setting.name || setting.key);
|
||||
const displayName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
|
||||
const description = setting.hint?.description ? stripHtml(setting.hint.description) : null;
|
||||
|
||||
// Check if this is a long/complex value
|
||||
const isLongValue = (() => {
|
||||
const val = String(setting.current ?? '')
|
||||
const key = setting.key.toLowerCase()
|
||||
const val = String(setting.current ?? '');
|
||||
const key = setting.key.toLowerCase();
|
||||
return (
|
||||
val.length > 50 ||
|
||||
val.includes('\n') ||
|
||||
@@ -44,40 +44,40 @@ export function SettingRow({
|
||||
key.includes('_keywords') ||
|
||||
key.includes('_template') ||
|
||||
key.includes('_packages')
|
||||
)
|
||||
})()
|
||||
);
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="group p-4 sm:p-5 rounded-2xl bg-dark-800/40 border border-dark-700/40 hover:border-dark-600/60 hover:bg-dark-800/60 transition-all">
|
||||
<div className="group rounded-2xl border border-dark-700/40 bg-dark-800/40 p-4 transition-all hover:border-dark-600/60 hover:bg-dark-800/60 sm:p-5">
|
||||
{/* Header row - name, badges, favorite */}
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-semibold text-dark-100 text-base">{displayName}</h3>
|
||||
<div className="mb-3 flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-base font-semibold text-dark-100">{displayName}</h3>
|
||||
{setting.has_override && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-warning-500/20 text-warning-400 font-medium">
|
||||
<span className="rounded-full bg-warning-500/20 px-2 py-0.5 text-xs font-medium text-warning-400">
|
||||
{t('admin.settings.modified')}
|
||||
</span>
|
||||
)}
|
||||
{setting.read_only && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-dark-600/50 text-dark-400 font-medium flex items-center gap-1">
|
||||
<span className="flex items-center gap-1 rounded-full bg-dark-600/50 px-2 py-0.5 text-xs font-medium text-dark-400">
|
||||
<LockIcon />
|
||||
{t('admin.settings.readOnly')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{description && (
|
||||
<p className="text-sm text-dark-400 mt-1.5 leading-relaxed">{description}</p>
|
||||
<p className="mt-1.5 text-sm leading-relaxed text-dark-400">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Favorite button */}
|
||||
<button
|
||||
onClick={onToggleFavorite}
|
||||
className={`p-2 rounded-xl transition-all flex-shrink-0 ${
|
||||
className={`flex-shrink-0 rounded-xl p-2 transition-all ${
|
||||
isFavorite
|
||||
? 'text-warning-400 bg-warning-500/15 hover:bg-warning-500/25'
|
||||
: 'text-dark-500 hover:text-warning-400 hover:bg-dark-700/50 opacity-0 group-hover:opacity-100'
|
||||
? 'bg-warning-500/15 text-warning-400 hover:bg-warning-500/25'
|
||||
: 'text-dark-500 opacity-0 hover:bg-dark-700/50 hover:text-warning-400 group-hover:opacity-100'
|
||||
}`}
|
||||
title={isFavorite ? 'Убрать из избранного' : 'В избранное'}
|
||||
>
|
||||
@@ -87,17 +87,19 @@ export function SettingRow({
|
||||
|
||||
{/* Setting key (muted) */}
|
||||
<div className="mb-3">
|
||||
<code className="text-xs text-dark-500 font-mono bg-dark-900/50 px-2 py-1 rounded">
|
||||
<code className="rounded bg-dark-900/50 px-2 py-1 font-mono text-xs text-dark-500">
|
||||
{setting.key}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Control section */}
|
||||
<div className={`${isLongValue ? '' : 'flex items-center justify-between gap-3'} pt-3 border-t border-dark-700/30`}>
|
||||
<div
|
||||
className={`${isLongValue ? '' : 'flex items-center justify-between gap-3'} border-t border-dark-700/30 pt-3`}
|
||||
>
|
||||
{setting.read_only ? (
|
||||
// Read-only display
|
||||
<div className="flex items-center gap-2 text-dark-300 bg-dark-700/30 rounded-lg px-4 py-2.5">
|
||||
<span className="font-mono text-sm break-all">{String(setting.current ?? '-')}</span>
|
||||
<div className="flex items-center gap-2 rounded-lg bg-dark-700/30 px-4 py-2.5 text-dark-300">
|
||||
<span className="break-all font-mono text-sm">{String(setting.current ?? '-')}</span>
|
||||
</div>
|
||||
) : setting.type === 'bool' ? (
|
||||
// Boolean toggle
|
||||
@@ -108,7 +110,11 @@ export function SettingRow({
|
||||
<div className="flex items-center gap-2">
|
||||
<Toggle
|
||||
checked={setting.current === true || setting.current === 'true'}
|
||||
onChange={() => onUpdate(setting.current === true || setting.current === 'true' ? 'false' : 'true')}
|
||||
onChange={() =>
|
||||
onUpdate(
|
||||
setting.current === true || setting.current === 'true' ? 'false' : 'true',
|
||||
)
|
||||
}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
{/* Reset button for boolean */}
|
||||
@@ -116,7 +122,7 @@ export function SettingRow({
|
||||
<button
|
||||
onClick={onReset}
|
||||
disabled={isResetting}
|
||||
className="p-2 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50"
|
||||
className="rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
|
||||
title={t('admin.settings.reset')}
|
||||
>
|
||||
<RefreshIcon />
|
||||
@@ -126,18 +132,16 @@ export function SettingRow({
|
||||
</div>
|
||||
) : (
|
||||
// Input field
|
||||
<div className={`${isLongValue ? 'w-full' : 'flex items-center gap-2 flex-1 justify-end'}`}>
|
||||
<SettingInput
|
||||
setting={setting}
|
||||
onUpdate={onUpdate}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
<div
|
||||
className={`${isLongValue ? 'w-full' : 'flex flex-1 items-center justify-end gap-2'}`}
|
||||
>
|
||||
<SettingInput setting={setting} onUpdate={onUpdate} disabled={isUpdating} />
|
||||
{/* Reset button for non-long values */}
|
||||
{!isLongValue && setting.has_override && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
disabled={isResetting}
|
||||
className="p-2 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50 flex-shrink-0"
|
||||
className="flex-shrink-0 rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
|
||||
title={t('admin.settings.reset')}
|
||||
>
|
||||
<RefreshIcon />
|
||||
@@ -153,7 +157,7 @@ export function SettingRow({
|
||||
<button
|
||||
onClick={onReset}
|
||||
disabled={isResetting}
|
||||
className="px-3 py-1.5 rounded-lg text-dark-400 hover:text-dark-200 hover:bg-dark-700 transition-colors disabled:opacity-50 text-sm flex items-center gap-1.5"
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
|
||||
title={t('admin.settings.reset')}
|
||||
>
|
||||
<RefreshIcon />
|
||||
@@ -162,5 +166,5 @@ export function SettingRow({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SearchIcon, CloseIcon } from './icons'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SearchIcon, CloseIcon } from './icons';
|
||||
|
||||
interface SettingsSearchProps {
|
||||
searchQuery: string
|
||||
setSearchQuery: (query: string) => void
|
||||
resultsCount?: number
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
resultsCount?: number;
|
||||
}
|
||||
|
||||
export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -19,7 +19,7 @@ export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchPr
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('admin.settings.searchPlaceholder')}
|
||||
className="w-48 lg:w-64 pl-10 pr-10 py-2 rounded-xl bg-dark-800 border border-dark-700 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 text-sm"
|
||||
className="w-48 rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none lg:w-64"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
||||
<SearchIcon />
|
||||
@@ -27,18 +27,21 @@ export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchPr
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 hover:text-dark-300 transition-colors"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit<SettingsSearchProps, 'resultsCount'>) {
|
||||
const { t } = useTranslation()
|
||||
export function SettingsSearchMobile({
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
}: Omit<SettingsSearchProps, 'resultsCount'>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="relative mt-3 sm:hidden">
|
||||
@@ -47,7 +50,7 @@ export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit<Setti
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('admin.settings.searchPlaceholder')}
|
||||
className="w-full pl-10 pr-10 py-2 rounded-xl bg-dark-800 border border-dark-700 text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-500 text-sm"
|
||||
className="w-full rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
||||
<SearchIcon />
|
||||
@@ -55,26 +58,30 @@ export function SettingsSearchMobile({ searchQuery, setSearchQuery }: Omit<Setti
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 hover:text-dark-300 transition-colors"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsSearchResults({ searchQuery, resultsCount }: { searchQuery: string; resultsCount: number }) {
|
||||
if (!searchQuery.trim()) return null
|
||||
export function SettingsSearchResults({
|
||||
searchQuery,
|
||||
resultsCount,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
resultsCount: number;
|
||||
}) {
|
||||
if (!searchQuery.trim()) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex items-center gap-2 text-sm">
|
||||
<span className="text-dark-400">
|
||||
{resultsCount > 0 ? `Найдено: ${resultsCount}` : 'Ничего не найдено'}
|
||||
</span>
|
||||
{resultsCount > 0 && (
|
||||
<span className="text-dark-500">по запросу «{searchQuery}»</span>
|
||||
)}
|
||||
{resultsCount > 0 && <span className="text-dark-500">по запросу «{searchQuery}»</span>}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { BackIcon, StarIcon, CloseIcon, MENU_SECTIONS } from './index'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { BackIcon, StarIcon, CloseIcon, MENU_SECTIONS } from './index';
|
||||
|
||||
interface SettingsSidebarProps {
|
||||
activeSection: string
|
||||
setActiveSection: (section: string) => void
|
||||
mobileMenuOpen: boolean
|
||||
setMobileMenuOpen: (open: boolean) => void
|
||||
favoritesCount: number
|
||||
activeSection: string;
|
||||
setActiveSection: (section: string) => void;
|
||||
mobileMenuOpen: boolean;
|
||||
setMobileMenuOpen: (open: boolean) => void;
|
||||
favoritesCount: number;
|
||||
}
|
||||
|
||||
export function SettingsSidebar({
|
||||
@@ -15,27 +15,27 @@ export function SettingsSidebar({
|
||||
setActiveSection,
|
||||
mobileMenuOpen,
|
||||
setMobileMenuOpen,
|
||||
favoritesCount
|
||||
favoritesCount,
|
||||
}: SettingsSidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<aside className={`
|
||||
fixed lg:sticky lg:top-0 inset-y-0 left-0 z-50
|
||||
w-64 h-screen bg-dark-900 border-r border-dark-700/50 flex-shrink-0
|
||||
transform transition-transform duration-200 ease-in-out
|
||||
${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
|
||||
`}>
|
||||
<aside
|
||||
className={`fixed inset-y-0 left-0 z-50 h-screen w-64 flex-shrink-0 transform border-r border-dark-700/50 bg-dark-900 transition-transform duration-200 ease-in-out lg:sticky lg:top-0 ${mobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'} `}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-dark-700/50">
|
||||
<div className="border-b border-dark-700/50 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/admin" className="p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<h1 className="text-lg font-bold text-dark-100">{t('admin.settings.title')}</h1>
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="ml-auto p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors lg:hidden"
|
||||
className="ml-auto rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700 lg:hidden"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
@@ -43,39 +43,39 @@ export function SettingsSidebar({
|
||||
</div>
|
||||
|
||||
{/* Menu */}
|
||||
<nav className="p-2 space-y-1 overflow-y-auto max-h-[calc(100vh-80px)]">
|
||||
<nav className="max-h-[calc(100vh-80px)] space-y-1 overflow-y-auto p-2">
|
||||
{MENU_SECTIONS.map((section, sectionIdx) => (
|
||||
<div key={section.id}>
|
||||
{sectionIdx > 0 && <div className="my-3 border-t border-dark-700/50" />}
|
||||
{section.items.map((item) => {
|
||||
const isActive = activeSection === item.id
|
||||
const hasIcon = item.iconType === 'star'
|
||||
const isActive = activeSection === item.id;
|
||||
const hasIcon = item.iconType === 'star';
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
setActiveSection(item.id)
|
||||
setMobileMenuOpen(false)
|
||||
setActiveSection(item.id);
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all ${
|
||||
className={`flex w-full items-center gap-3 rounded-xl px-3 py-2.5 transition-all ${
|
||||
isActive
|
||||
? 'bg-accent-500/10 text-accent-400'
|
||||
: 'text-dark-400 hover:text-dark-200 hover:bg-dark-800/50'
|
||||
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
{hasIcon && <StarIcon filled={isActive && item.id === 'favorites'} />}
|
||||
<span className="font-medium">{t(`admin.settings.${item.id}`)}</span>
|
||||
{item.id === 'favorites' && favoritesCount > 0 && (
|
||||
<span className="ml-auto px-2 py-0.5 text-xs rounded-full bg-warning-500/20 text-warning-400">
|
||||
<span className="ml-auto rounded-full bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||
{favoritesCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings'
|
||||
import { ChevronDownIcon } from './icons'
|
||||
import { SettingRow } from './SettingRow'
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { SettingDefinition, adminSettingsApi } from '../../api/adminSettings';
|
||||
import { ChevronDownIcon } from './icons';
|
||||
import { SettingRow } from './SettingRow';
|
||||
|
||||
interface CategoryGroup {
|
||||
key: string
|
||||
label: string
|
||||
settings: SettingDefinition[]
|
||||
key: string;
|
||||
label: string;
|
||||
settings: SettingDefinition[];
|
||||
}
|
||||
|
||||
interface SettingsTabProps {
|
||||
categories: CategoryGroup[]
|
||||
searchQuery: string
|
||||
filteredSettings: SettingDefinition[]
|
||||
isFavorite: (key: string) => boolean
|
||||
toggleFavorite: (key: string) => void
|
||||
categories: CategoryGroup[];
|
||||
searchQuery: string;
|
||||
filteredSettings: SettingDefinition[];
|
||||
isFavorite: (key: string) => boolean;
|
||||
toggleFavorite: (key: string) => void;
|
||||
}
|
||||
|
||||
export function SettingsTab({
|
||||
@@ -24,49 +24,50 @@ export function SettingsTab({
|
||||
searchQuery,
|
||||
filteredSettings,
|
||||
isFavorite,
|
||||
toggleFavorite
|
||||
toggleFavorite,
|
||||
}: SettingsTabProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set())
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleSection = (key: string) => {
|
||||
setExpandedSections(prev => {
|
||||
const next = new Set(prev)
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
next.delete(key)
|
||||
next.delete(key);
|
||||
} else {
|
||||
next.add(key)
|
||||
next.add(key);
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateSettingMutation = useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) => adminSettingsApi.updateSetting(key, value),
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) =>
|
||||
adminSettingsApi.updateSetting(key, value),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const resetSettingMutation = useMutation({
|
||||
mutationFn: (key: string) => adminSettingsApi.resetSetting(key),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// If searching, show flat list
|
||||
if (searchQuery) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{filteredSettings.length === 0 ? (
|
||||
<div className="p-12 rounded-2xl bg-dark-800/30 border border-dark-700/30 text-center">
|
||||
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
|
||||
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{filteredSettings.map((setting) => (
|
||||
<SettingRow
|
||||
key={setting.key}
|
||||
@@ -82,46 +83,50 @@ export function SettingsTab({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Show accordion for subcategories
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{categories.map((cat) => {
|
||||
const isExpanded = expandedSections.has(cat.key)
|
||||
const isExpanded = expandedSections.has(cat.key);
|
||||
return (
|
||||
<div
|
||||
key={cat.key}
|
||||
className="rounded-2xl bg-dark-800/30 border border-dark-700/30 overflow-hidden"
|
||||
className="overflow-hidden rounded-2xl border border-dark-700/30 bg-dark-800/30"
|
||||
>
|
||||
{/* Accordion header */}
|
||||
<button
|
||||
onClick={() => toggleSection(cat.key)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-dark-800/50 transition-colors"
|
||||
className="flex w-full items-center justify-between p-4 transition-colors hover:bg-dark-800/50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-medium text-dark-100">{cat.label}</span>
|
||||
<span className="px-2 py-0.5 text-xs rounded-full bg-dark-700 text-dark-400">
|
||||
<span className="rounded-full bg-dark-700 px-2 py-0.5 text-xs text-dark-400">
|
||||
{cat.settings.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`transition-transform duration-200 text-dark-400 ${isExpanded ? 'rotate-180' : ''}`}>
|
||||
<div
|
||||
className={`text-dark-400 transition-transform duration-200 ${isExpanded ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Accordion content */}
|
||||
{isExpanded && (
|
||||
<div className="p-4 pt-0 border-t border-dark-700/30">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 pt-4">
|
||||
<div className="border-t border-dark-700/30 p-4 pt-0">
|
||||
<div className="grid grid-cols-1 gap-4 pt-4 lg:grid-cols-2">
|
||||
{cat.settings.map((setting) => (
|
||||
<SettingRow
|
||||
key={setting.key}
|
||||
setting={setting}
|
||||
isFavorite={isFavorite(setting.key)}
|
||||
onToggleFavorite={() => toggleFavorite(setting.key)}
|
||||
onUpdate={(value) => updateSettingMutation.mutate({ key: setting.key, value })}
|
||||
onUpdate={(value) =>
|
||||
updateSettingMutation.mutate({ key: setting.key, value })
|
||||
}
|
||||
onReset={() => resetSettingMutation.mutate(setting.key)}
|
||||
isUpdating={updateSettingMutation.isPending}
|
||||
isResetting={resetSettingMutation.isPending}
|
||||
@@ -131,14 +136,14 @@ export function SettingsTab({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
|
||||
{categories.length === 0 && (
|
||||
<div className="p-12 rounded-2xl bg-dark-800/30 border border-dark-700/30 text-center">
|
||||
<div className="rounded-2xl border border-dark-700/30 bg-dark-800/30 p-12 text-center">
|
||||
<p className="text-dark-400">{t('admin.settings.noSettings')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,101 +1,107 @@
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { themeColorsApi } from '../../api/themeColors'
|
||||
import { DEFAULT_THEME_COLORS } from '../../types/theme'
|
||||
import { ColorPicker } from '../ColorPicker'
|
||||
import { applyThemeColors } from '../../hooks/useThemeColors'
|
||||
import { updateEnabledThemesCache } from '../../hooks/useTheme'
|
||||
import { MoonIcon, SunIcon, ChevronDownIcon } from './icons'
|
||||
import { Toggle } from './Toggle'
|
||||
import { THEME_PRESETS } from './constants'
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { themeColorsApi } from '../../api/themeColors';
|
||||
import { DEFAULT_THEME_COLORS } from '../../types/theme';
|
||||
import { ColorPicker } from '../ColorPicker';
|
||||
import { applyThemeColors } from '../../hooks/useThemeColors';
|
||||
import { updateEnabledThemesCache } from '../../hooks/useTheme';
|
||||
import { MoonIcon, SunIcon, ChevronDownIcon } from './icons';
|
||||
import { Toggle } from './Toggle';
|
||||
import { THEME_PRESETS } from './constants';
|
||||
|
||||
export function ThemeTab() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['presets']))
|
||||
const [expandedSections, setExpandedSections] = useState<Set<string>>(new Set(['presets']));
|
||||
|
||||
const toggleSection = (section: string) => {
|
||||
setExpandedSections(prev => {
|
||||
const next = new Set(prev)
|
||||
setExpandedSections((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(section)) {
|
||||
next.delete(section)
|
||||
next.delete(section);
|
||||
} else {
|
||||
next.add(section)
|
||||
next.add(section);
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Queries
|
||||
const { data: themeColors } = useQuery({
|
||||
queryKey: ['theme-colors'],
|
||||
queryFn: themeColorsApi.getColors,
|
||||
})
|
||||
});
|
||||
|
||||
const { data: enabledThemes } = useQuery({
|
||||
queryKey: ['enabled-themes'],
|
||||
queryFn: themeColorsApi.getEnabledThemes,
|
||||
})
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const updateColorsMutation = useMutation({
|
||||
mutationFn: themeColorsApi.updateColors,
|
||||
onSuccess: (data) => {
|
||||
applyThemeColors(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] })
|
||||
applyThemeColors(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const resetColorsMutation = useMutation({
|
||||
mutationFn: themeColorsApi.resetColors,
|
||||
onSuccess: (data) => {
|
||||
applyThemeColors(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] })
|
||||
applyThemeColors(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const updateEnabledThemesMutation = useMutation({
|
||||
mutationFn: themeColorsApi.updateEnabledThemes,
|
||||
onSuccess: (data) => {
|
||||
updateEnabledThemesCache(data)
|
||||
queryClient.invalidateQueries({ queryKey: ['enabled-themes'] })
|
||||
updateEnabledThemesCache(data);
|
||||
queryClient.invalidateQueries({ queryKey: ['enabled-themes'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Theme toggles */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<h3 className="text-lg font-semibold text-dark-100 mb-4">{t('admin.settings.availableThemes')}</h3>
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.availableThemes')}
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
<div className="flex items-center justify-between p-3 sm:p-4 rounded-xl bg-dark-700/30">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-4">
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-3 sm:p-4">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<MoonIcon />
|
||||
<span className="font-medium text-dark-200 text-sm sm:text-base">{t('admin.settings.darkTheme')}</span>
|
||||
<span className="text-sm font-medium text-dark-200 sm:text-base">
|
||||
{t('admin.settings.darkTheme')}
|
||||
</span>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={enabledThemes?.dark ?? true}
|
||||
onChange={() => {
|
||||
if ((enabledThemes?.dark ?? true) && !(enabledThemes?.light ?? true)) return
|
||||
updateEnabledThemesMutation.mutate({ dark: !(enabledThemes?.dark ?? true) })
|
||||
if ((enabledThemes?.dark ?? true) && !(enabledThemes?.light ?? true)) return;
|
||||
updateEnabledThemesMutation.mutate({ dark: !(enabledThemes?.dark ?? true) });
|
||||
}}
|
||||
disabled={updateEnabledThemesMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 sm:p-4 rounded-xl bg-dark-700/30">
|
||||
<div className="flex items-center justify-between rounded-xl bg-dark-700/30 p-3 sm:p-4">
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<SunIcon />
|
||||
<span className="font-medium text-dark-200 text-sm sm:text-base">{t('admin.settings.lightTheme')}</span>
|
||||
<span className="text-sm font-medium text-dark-200 sm:text-base">
|
||||
{t('admin.settings.lightTheme')}
|
||||
</span>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={enabledThemes?.light ?? true}
|
||||
onChange={() => {
|
||||
if ((enabledThemes?.light ?? true) && !(enabledThemes?.dark ?? true)) return
|
||||
updateEnabledThemesMutation.mutate({ light: !(enabledThemes?.light ?? true) })
|
||||
if ((enabledThemes?.light ?? true) && !(enabledThemes?.dark ?? true)) return;
|
||||
updateEnabledThemesMutation.mutate({ light: !(enabledThemes?.light ?? true) });
|
||||
}}
|
||||
disabled={updateEnabledThemesMutation.isPending}
|
||||
/>
|
||||
@@ -104,30 +110,34 @@ export function ThemeTab() {
|
||||
</div>
|
||||
|
||||
{/* Quick Presets */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<button
|
||||
onClick={() => toggleSection('presets')}
|
||||
className="w-full flex items-center justify-between"
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-dark-100">{t('admin.settings.quickPresets')}</h3>
|
||||
<div className={`transition-transform ${expandedSections.has('presets') ? 'rotate-180' : ''}`}>
|
||||
<h3 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.quickPresets')}
|
||||
</h3>
|
||||
<div
|
||||
className={`transition-transform ${expandedSections.has('presets') ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{expandedSections.has('presets') && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-4">
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{THEME_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => updateColorsMutation.mutate(preset.colors)}
|
||||
disabled={updateColorsMutation.isPending}
|
||||
className="p-3 rounded-xl border border-dark-600 hover:border-dark-500 transition-all hover:scale-[1.02]"
|
||||
className="rounded-xl border border-dark-600 p-3 transition-all hover:scale-[1.02] hover:border-dark-500"
|
||||
style={{ backgroundColor: preset.colors.darkBackground }}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<div
|
||||
className="w-4 h-4 rounded-full ring-2 ring-white/20"
|
||||
className="h-4 w-4 rounded-full ring-2 ring-white/20"
|
||||
style={{ backgroundColor: preset.colors.accent }}
|
||||
/>
|
||||
<span className="text-xs font-medium" style={{ color: preset.colors.darkText }}>
|
||||
@@ -135,9 +145,18 @@ export function ThemeTab() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: preset.colors.success }} />
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: preset.colors.warning }} />
|
||||
<div className="w-3 h-3 rounded" style={{ backgroundColor: preset.colors.error }} />
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
style={{ backgroundColor: preset.colors.success }}
|
||||
/>
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
style={{ backgroundColor: preset.colors.warning }}
|
||||
/>
|
||||
<div
|
||||
className="h-3 w-3 rounded"
|
||||
style={{ backgroundColor: preset.colors.error }}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -146,13 +165,17 @@ export function ThemeTab() {
|
||||
</div>
|
||||
|
||||
{/* Custom Colors */}
|
||||
<div className="p-6 rounded-2xl bg-dark-800/50 border border-dark-700/50">
|
||||
<div className="rounded-2xl border border-dark-700/50 bg-dark-800/50 p-6">
|
||||
<button
|
||||
onClick={() => toggleSection('colors')}
|
||||
className="w-full flex items-center justify-between"
|
||||
className="flex w-full items-center justify-between"
|
||||
>
|
||||
<h3 className="text-lg font-semibold text-dark-100">{t('admin.settings.customColors')}</h3>
|
||||
<div className={`transition-transform ${expandedSections.has('colors') ? 'rotate-180' : ''}`}>
|
||||
<h3 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.settings.customColors')}
|
||||
</h3>
|
||||
<div
|
||||
className={`transition-transform ${expandedSections.has('colors') ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</div>
|
||||
</button>
|
||||
@@ -161,7 +184,9 @@ export function ThemeTab() {
|
||||
<div className="mt-4 space-y-6">
|
||||
{/* Accent */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-dark-300 mb-3">{t('admin.settings.accentColor')}</h4>
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-300">
|
||||
{t('admin.settings.accentColor')}
|
||||
</h4>
|
||||
<ColorPicker
|
||||
label={t('theme.accent')}
|
||||
value={themeColors?.accent || DEFAULT_THEME_COLORS.accent}
|
||||
@@ -172,10 +197,10 @@ export function ThemeTab() {
|
||||
|
||||
{/* Dark theme */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-dark-300 mb-3 flex items-center gap-2">
|
||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
|
||||
<MoonIcon /> {t('admin.settings.darkTheme')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<ColorPicker
|
||||
label={t('admin.settings.colors.background')}
|
||||
value={themeColors?.darkBackground || DEFAULT_THEME_COLORS.darkBackground}
|
||||
@@ -205,10 +230,10 @@ export function ThemeTab() {
|
||||
|
||||
{/* Light theme */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-dark-300 mb-3 flex items-center gap-2">
|
||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-medium text-dark-300">
|
||||
<SunIcon /> {t('admin.settings.lightTheme')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<ColorPicker
|
||||
label={t('admin.settings.colors.background')}
|
||||
value={themeColors?.lightBackground || DEFAULT_THEME_COLORS.lightBackground}
|
||||
@@ -238,8 +263,10 @@ export function ThemeTab() {
|
||||
|
||||
{/* Status colors */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-dark-300 mb-3">{t('admin.settings.statusColors')}</h4>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<h4 className="mb-3 text-sm font-medium text-dark-300">
|
||||
{t('admin.settings.statusColors')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<ColorPicker
|
||||
label={t('admin.settings.colors.success')}
|
||||
value={themeColors?.success || DEFAULT_THEME_COLORS.success}
|
||||
@@ -265,7 +292,7 @@ export function ThemeTab() {
|
||||
<button
|
||||
onClick={() => resetColorsMutation.mutate()}
|
||||
disabled={resetColorsMutation.isPending}
|
||||
className="px-4 py-2 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors disabled:opacity-50"
|
||||
className="rounded-xl bg-dark-700 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-600 disabled:opacity-50"
|
||||
>
|
||||
{t('admin.settings.resetAllColors')}
|
||||
</button>
|
||||
@@ -273,5 +300,5 @@ export function ThemeTab() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
interface ToggleProps {
|
||||
checked: boolean
|
||||
onChange: () => void
|
||||
disabled?: boolean
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Toggle({ checked, onChange, disabled }: ToggleProps) {
|
||||
@@ -9,13 +9,15 @@ export function Toggle({ checked, onChange, disabled }: ToggleProps) {
|
||||
<button
|
||||
onClick={onChange}
|
||||
disabled={disabled}
|
||||
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||
className={`relative h-6 w-12 rounded-full transition-colors ${
|
||||
checked ? 'bg-accent-500' : 'bg-dark-600'
|
||||
} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
} ${disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||
>
|
||||
<div className={`absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform duration-200 ${
|
||||
checked ? 'translate-x-6' : 'translate-x-0'
|
||||
}`} />
|
||||
<div
|
||||
className={`absolute left-1 top-1 h-4 w-4 rounded-full bg-white transition-transform duration-200 ${
|
||||
checked ? 'translate-x-6' : 'translate-x-0'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme'
|
||||
import { ThemeColors, DEFAULT_THEME_COLORS } from '../../types/theme';
|
||||
|
||||
// Menu item types
|
||||
export interface MenuItem {
|
||||
id: string
|
||||
iconType?: 'star' | null
|
||||
categories?: string[]
|
||||
id: string;
|
||||
iconType?: 'star' | null;
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
export interface MenuSection {
|
||||
id: string
|
||||
items: MenuItem[]
|
||||
id: string;
|
||||
items: MenuItem[];
|
||||
}
|
||||
|
||||
// Sidebar menu configuration
|
||||
@@ -21,36 +21,215 @@ export const MENU_SECTIONS: MenuSection[] = [
|
||||
{ id: 'branding', iconType: null },
|
||||
{ id: 'theme', iconType: null },
|
||||
{ id: 'analytics', iconType: null },
|
||||
]
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
items: [
|
||||
{ id: 'payments', iconType: null, categories: ['PAYMENT', 'PAYMENT_VERIFICATION', 'YOOKASSA', 'CRYPTOBOT', 'HELEKET', 'PLATEGA', 'TRIBUTE', 'MULENPAY', 'PAL24', 'WATA', 'TELEGRAM'] },
|
||||
{ id: 'subscriptions', iconType: null, categories: ['SUBSCRIPTIONS_CORE', 'SIMPLE_SUBSCRIPTION', 'PERIODS', 'SUBSCRIPTION_PRICES', 'TRAFFIC', 'TRAFFIC_PACKAGES', 'TRIAL', 'AUTOPAY'] },
|
||||
{ id: 'interface', iconType: null, categories: ['INTERFACE', 'INTERFACE_BRANDING', 'INTERFACE_SUBSCRIPTION', 'CONNECT_BUTTON', 'MINIAPP', 'HAPP', 'SKIP', 'ADDITIONAL'] },
|
||||
{ id: 'notifications', iconType: null, categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'] },
|
||||
{
|
||||
id: 'payments',
|
||||
iconType: null,
|
||||
categories: [
|
||||
'PAYMENT',
|
||||
'PAYMENT_VERIFICATION',
|
||||
'YOOKASSA',
|
||||
'CRYPTOBOT',
|
||||
'HELEKET',
|
||||
'PLATEGA',
|
||||
'TRIBUTE',
|
||||
'MULENPAY',
|
||||
'PAL24',
|
||||
'WATA',
|
||||
'TELEGRAM',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'subscriptions',
|
||||
iconType: null,
|
||||
categories: [
|
||||
'SUBSCRIPTIONS_CORE',
|
||||
'SIMPLE_SUBSCRIPTION',
|
||||
'PERIODS',
|
||||
'SUBSCRIPTION_PRICES',
|
||||
'TRAFFIC',
|
||||
'TRAFFIC_PACKAGES',
|
||||
'TRIAL',
|
||||
'AUTOPAY',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'interface',
|
||||
iconType: null,
|
||||
categories: [
|
||||
'INTERFACE',
|
||||
'INTERFACE_BRANDING',
|
||||
'INTERFACE_SUBSCRIPTION',
|
||||
'CONNECT_BUTTON',
|
||||
'MINIAPP',
|
||||
'HAPP',
|
||||
'SKIP',
|
||||
'ADDITIONAL',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'notifications',
|
||||
iconType: null,
|
||||
categories: ['NOTIFICATIONS', 'ADMIN_NOTIFICATIONS', 'ADMIN_REPORTS'],
|
||||
},
|
||||
{ id: 'database', iconType: null, categories: ['DATABASE', 'POSTGRES', 'SQLITE', 'REDIS'] },
|
||||
{ id: 'system', iconType: null, categories: ['CORE', 'REMNAWAVE', 'SERVER_STATUS', 'MONITORING', 'MAINTENANCE', 'BACKUP', 'VERSION', 'WEB_API', 'WEBHOOK', 'LOG', 'DEBUG', 'EXTERNAL_ADMIN'] },
|
||||
{ id: 'users', iconType: null, categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'] },
|
||||
]
|
||||
}
|
||||
]
|
||||
{
|
||||
id: 'system',
|
||||
iconType: null,
|
||||
categories: [
|
||||
'CORE',
|
||||
'REMNAWAVE',
|
||||
'SERVER_STATUS',
|
||||
'MONITORING',
|
||||
'MAINTENANCE',
|
||||
'BACKUP',
|
||||
'VERSION',
|
||||
'WEB_API',
|
||||
'WEBHOOK',
|
||||
'LOG',
|
||||
'DEBUG',
|
||||
'EXTERNAL_ADMIN',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'users',
|
||||
iconType: null,
|
||||
categories: ['SUPPORT', 'LOCALIZATION', 'CHANNEL', 'TIMEZONE', 'REFERRAL', 'MODERATION'],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Theme preset type
|
||||
export interface ThemePreset {
|
||||
id: string
|
||||
colors: ThemeColors
|
||||
id: string;
|
||||
colors: ThemeColors;
|
||||
}
|
||||
|
||||
// Theme presets
|
||||
export const THEME_PRESETS: ThemePreset[] = [
|
||||
{ id: 'standard', colors: DEFAULT_THEME_COLORS },
|
||||
{ id: 'ocean', colors: { accent: '#0ea5e9', darkBackground: '#0c1222', darkSurface: '#1e293b', darkText: '#f1f5f9', darkTextSecondary: '#94a3b8', lightBackground: '#e0f2fe', lightSurface: '#f0f9ff', lightText: '#0c4a6e', lightTextSecondary: '#0369a1', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
{ id: 'forest', colors: { accent: '#22c55e', darkBackground: '#0a1a0f', darkSurface: '#14532d', darkText: '#f0fdf4', darkTextSecondary: '#86efac', lightBackground: '#dcfce7', lightSurface: '#f0fdf4', lightText: '#14532d', lightTextSecondary: '#166534', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
{ id: 'sunset', colors: { accent: '#f97316', darkBackground: '#1c1009', darkSurface: '#2d1a0e', darkText: '#fff7ed', darkTextSecondary: '#fdba74', lightBackground: '#ffedd5', lightSurface: '#fff7ed', lightText: '#7c2d12', lightTextSecondary: '#c2410c', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
{ id: 'violet', colors: { accent: '#a855f7', darkBackground: '#0f0a1a', darkSurface: '#1e1b2e', darkText: '#faf5ff', darkTextSecondary: '#c4b5fd', lightBackground: '#f3e8ff', lightSurface: '#faf5ff', lightText: '#581c87', lightTextSecondary: '#7e22ce', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
{ id: 'rose', colors: { accent: '#f43f5e', darkBackground: '#1a0a10', darkSurface: '#2d1520', darkText: '#fff1f2', darkTextSecondary: '#fda4af', lightBackground: '#ffe4e6', lightSurface: '#fff1f2', lightText: '#881337', lightTextSecondary: '#be123c', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
{ id: 'midnight', colors: { accent: '#6366f1', darkBackground: '#030712', darkSurface: '#111827', darkText: '#f9fafb', darkTextSecondary: '#9ca3af', lightBackground: '#e5e7eb', lightSurface: '#f3f4f6', lightText: '#111827', lightTextSecondary: '#4b5563', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
{ id: 'turquoise', colors: { accent: '#14b8a6', darkBackground: '#0a1614', darkSurface: '#134e4a', darkText: '#f0fdfa', darkTextSecondary: '#5eead4', lightBackground: '#ccfbf1', lightSurface: '#f0fdfa', lightText: '#134e4a', lightTextSecondary: '#0f766e', success: '#22c55e', warning: '#f59e0b', error: '#ef4444' } },
|
||||
]
|
||||
{
|
||||
id: 'ocean',
|
||||
colors: {
|
||||
accent: '#0ea5e9',
|
||||
darkBackground: '#0c1222',
|
||||
darkSurface: '#1e293b',
|
||||
darkText: '#f1f5f9',
|
||||
darkTextSecondary: '#94a3b8',
|
||||
lightBackground: '#e0f2fe',
|
||||
lightSurface: '#f0f9ff',
|
||||
lightText: '#0c4a6e',
|
||||
lightTextSecondary: '#0369a1',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'forest',
|
||||
colors: {
|
||||
accent: '#22c55e',
|
||||
darkBackground: '#0a1a0f',
|
||||
darkSurface: '#14532d',
|
||||
darkText: '#f0fdf4',
|
||||
darkTextSecondary: '#86efac',
|
||||
lightBackground: '#dcfce7',
|
||||
lightSurface: '#f0fdf4',
|
||||
lightText: '#14532d',
|
||||
lightTextSecondary: '#166534',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sunset',
|
||||
colors: {
|
||||
accent: '#f97316',
|
||||
darkBackground: '#1c1009',
|
||||
darkSurface: '#2d1a0e',
|
||||
darkText: '#fff7ed',
|
||||
darkTextSecondary: '#fdba74',
|
||||
lightBackground: '#ffedd5',
|
||||
lightSurface: '#fff7ed',
|
||||
lightText: '#7c2d12',
|
||||
lightTextSecondary: '#c2410c',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'violet',
|
||||
colors: {
|
||||
accent: '#a855f7',
|
||||
darkBackground: '#0f0a1a',
|
||||
darkSurface: '#1e1b2e',
|
||||
darkText: '#faf5ff',
|
||||
darkTextSecondary: '#c4b5fd',
|
||||
lightBackground: '#f3e8ff',
|
||||
lightSurface: '#faf5ff',
|
||||
lightText: '#581c87',
|
||||
lightTextSecondary: '#7e22ce',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rose',
|
||||
colors: {
|
||||
accent: '#f43f5e',
|
||||
darkBackground: '#1a0a10',
|
||||
darkSurface: '#2d1520',
|
||||
darkText: '#fff1f2',
|
||||
darkTextSecondary: '#fda4af',
|
||||
lightBackground: '#ffe4e6',
|
||||
lightSurface: '#fff1f2',
|
||||
lightText: '#881337',
|
||||
lightTextSecondary: '#be123c',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'midnight',
|
||||
colors: {
|
||||
accent: '#6366f1',
|
||||
darkBackground: '#030712',
|
||||
darkSurface: '#111827',
|
||||
darkText: '#f9fafb',
|
||||
darkTextSecondary: '#9ca3af',
|
||||
lightBackground: '#e5e7eb',
|
||||
lightSurface: '#f3f4f6',
|
||||
lightText: '#111827',
|
||||
lightTextSecondary: '#4b5563',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'turquoise',
|
||||
colors: {
|
||||
accent: '#14b8a6',
|
||||
darkBackground: '#0a1614',
|
||||
darkSurface: '#134e4a',
|
||||
darkText: '#f0fdfa',
|
||||
darkTextSecondary: '#5eead4',
|
||||
lightBackground: '#ccfbf1',
|
||||
lightSurface: '#f0fdfa',
|
||||
lightText: '#134e4a',
|
||||
lightTextSecondary: '#0f766e',
|
||||
success: '#22c55e',
|
||||
warning: '#f59e0b',
|
||||
error: '#ef4444',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,91 +1,141 @@
|
||||
// Admin Settings Icons
|
||||
|
||||
export const BackIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const SearchIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const StarIcon = ({ filled }: { filled?: boolean }) => (
|
||||
<svg className="w-5 h-5" fill={filled ? 'currentColor' : 'none'} viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill={filled ? 'currentColor' : 'none'}
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const ChevronDownIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const UploadIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const TrashIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const PencilIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const RefreshIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const LockIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const CheckIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const CloseIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const SunIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const MoonIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const MenuIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export const EditIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// Components
|
||||
export * from './icons'
|
||||
export * from './Toggle'
|
||||
export * from './SettingInput'
|
||||
export * from './SettingRow'
|
||||
export * from './AnalyticsTab'
|
||||
export * from './BrandingTab'
|
||||
export * from './ThemeTab'
|
||||
export * from './FavoritesTab'
|
||||
export * from './SettingsTab'
|
||||
export * from './SettingsSidebar'
|
||||
export * from './SettingsSearch'
|
||||
export * from './icons';
|
||||
export * from './Toggle';
|
||||
export * from './SettingInput';
|
||||
export * from './SettingRow';
|
||||
export * from './AnalyticsTab';
|
||||
export * from './BrandingTab';
|
||||
export * from './ThemeTab';
|
||||
export * from './FavoritesTab';
|
||||
export * from './SettingsTab';
|
||||
export * from './SettingsSidebar';
|
||||
export * from './SettingsSearch';
|
||||
|
||||
// Constants and utils
|
||||
export * from './constants'
|
||||
export * from './utils'
|
||||
export * from './constants';
|
||||
export * from './utils';
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
// Format setting key from Snake_Case / CamelCase to readable text
|
||||
export function formatSettingKey(name: string): string {
|
||||
if (!name) return ''
|
||||
if (!name) return '';
|
||||
|
||||
return name
|
||||
// CamelCase -> spaces
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
// snake_case -> spaces
|
||||
.replace(/_/g, ' ')
|
||||
// Remove extra spaces
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
// Capitalize first letter
|
||||
.replace(/^./, c => c.toUpperCase())
|
||||
return (
|
||||
name
|
||||
// CamelCase -> spaces
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
// snake_case -> spaces
|
||||
.replace(/_/g, ' ')
|
||||
// Remove extra spaces
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
// Capitalize first letter
|
||||
.replace(/^./, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Strip HTML tags and template descriptions from setting descriptions
|
||||
export function stripHtml(html: string): string {
|
||||
if (!html) return ''
|
||||
if (!html) return '';
|
||||
const cleaned = html
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.replace(/ /g, ' ')
|
||||
@@ -24,12 +26,12 @@ export function stripHtml(html: string): string {
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.trim()
|
||||
.trim();
|
||||
|
||||
// Remove template descriptions like "Параметр X управляет категорией Y"
|
||||
if (cleaned.match(/^Параметр .+ управляет категорией/)) {
|
||||
return ''
|
||||
return '';
|
||||
}
|
||||
|
||||
return cleaned
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
@@ -1,97 +1,99 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBlockingStore } from '../../store/blocking'
|
||||
import { apiClient } from '../../api/client'
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useBlockingStore } from '../../store/blocking';
|
||||
import { apiClient } from '../../api/client';
|
||||
|
||||
const CHECK_COOLDOWN_SECONDS = 5
|
||||
const CHECK_COOLDOWN_SECONDS = 5;
|
||||
|
||||
export default function ChannelSubscriptionScreen() {
|
||||
const { t } = useTranslation()
|
||||
const { channelInfo, clearBlocking } = useBlockingStore()
|
||||
const [isChecking, setIsChecking] = useState(false)
|
||||
const [cooldown, setCooldown] = useState(0)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { t } = useTranslation();
|
||||
const { channelInfo, clearBlocking } = useBlockingStore();
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Cooldown timer
|
||||
useEffect(() => {
|
||||
if (cooldown <= 0) return
|
||||
if (cooldown <= 0) return;
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setCooldown((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(timer)
|
||||
return 0
|
||||
clearInterval(timer);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [cooldown])
|
||||
return () => clearInterval(timer);
|
||||
}, [cooldown]);
|
||||
|
||||
const openChannel = useCallback(() => {
|
||||
if (channelInfo?.channel_link) {
|
||||
window.open(channelInfo.channel_link, '_blank')
|
||||
window.open(channelInfo.channel_link, '_blank');
|
||||
}
|
||||
}, [channelInfo?.channel_link])
|
||||
}, [channelInfo?.channel_link]);
|
||||
|
||||
const checkSubscription = useCallback(async () => {
|
||||
if (isChecking || cooldown > 0) return
|
||||
if (isChecking || cooldown > 0) return;
|
||||
|
||||
setIsChecking(true)
|
||||
setError(null)
|
||||
setIsChecking(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Make any authenticated request - if channel check passes, it will succeed
|
||||
await apiClient.get('/cabinet/auth/me')
|
||||
await apiClient.get('/cabinet/auth/me');
|
||||
// If we get here, subscription is valid - reload page
|
||||
clearBlocking()
|
||||
window.location.reload()
|
||||
clearBlocking();
|
||||
window.location.reload();
|
||||
} catch (err: unknown) {
|
||||
// Check if it's still a channel subscription error
|
||||
const error = err as { response?: { status?: number; data?: { detail?: { code?: string } } } }
|
||||
if (error.response?.status === 403 && error.response?.data?.detail?.code === 'channel_subscription_required') {
|
||||
setError(t('blocking.channel.notSubscribed', 'Вы ещё не подписались на канал'))
|
||||
const error = err as {
|
||||
response?: { status?: number; data?: { detail?: { code?: string } } };
|
||||
};
|
||||
if (
|
||||
error.response?.status === 403 &&
|
||||
error.response?.data?.detail?.code === 'channel_subscription_required'
|
||||
) {
|
||||
setError(t('blocking.channel.notSubscribed', 'Вы ещё не подписались на канал'));
|
||||
} else {
|
||||
// Other error - might be network issue
|
||||
setError(t('blocking.channel.checkError', 'Ошибка проверки. Попробуйте позже.'))
|
||||
setError(t('blocking.channel.checkError', 'Ошибка проверки. Попробуйте позже.'));
|
||||
}
|
||||
} finally {
|
||||
setIsChecking(false)
|
||||
setCooldown(CHECK_COOLDOWN_SECONDS)
|
||||
setIsChecking(false);
|
||||
setCooldown(CHECK_COOLDOWN_SECONDS);
|
||||
}
|
||||
}, [isChecking, cooldown, clearBlocking, t])
|
||||
}, [isChecking, cooldown, clearBlocking, t]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] bg-dark-950 flex flex-col items-center justify-center p-6">
|
||||
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
|
||||
<div className="w-full max-w-md text-center">
|
||||
{/* Icon */}
|
||||
<div className="mb-8">
|
||||
<div className="w-24 h-24 mx-auto rounded-full bg-gradient-to-br from-blue-500/20 to-cyan-500/20 flex items-center justify-center">
|
||||
<svg
|
||||
className="w-12 h-12 text-blue-400"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<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"/>
|
||||
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-gradient-to-br from-blue-500/20 to-cyan-500/20">
|
||||
<svg className="h-12 w-12 text-blue-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-2xl font-bold text-white mb-4">
|
||||
<h1 className="mb-4 text-2xl font-bold text-white">
|
||||
{t('blocking.channel.title', 'Подписка на канал')}
|
||||
</h1>
|
||||
|
||||
{/* Message */}
|
||||
<p className="text-gray-400 mb-8 text-lg">
|
||||
{channelInfo?.message || t('blocking.channel.defaultMessage', 'Для продолжения работы подпишитесь на наш канал')}
|
||||
<p className="mb-8 text-lg text-gray-400">
|
||||
{channelInfo?.message ||
|
||||
t('blocking.channel.defaultMessage', 'Для продолжения работы подпишитесь на наш канал')}
|
||||
</p>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/30 rounded-xl p-4 mb-6">
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
<div className="mb-6 rounded-xl border border-red-500/30 bg-red-500/10 p-4">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -101,14 +103,10 @@ export default function ChannelSubscriptionScreen() {
|
||||
<button
|
||||
onClick={openChannel}
|
||||
disabled={!channelInfo?.channel_link}
|
||||
className="w-full py-4 px-6 bg-gradient-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600 disabled:from-gray-600 disabled:to-gray-600 text-white font-semibold rounded-xl transition-all duration-200 flex items-center justify-center gap-3"
|
||||
className="flex w-full items-center justify-center gap-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 px-6 py-4 font-semibold text-white transition-all duration-200 hover:from-blue-600 hover:to-cyan-600 disabled:from-gray-600 disabled:to-gray-600"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
{t('blocking.channel.openChannel', 'Открыть канал')}
|
||||
</button>
|
||||
@@ -117,27 +115,60 @@ export default function ChannelSubscriptionScreen() {
|
||||
<button
|
||||
onClick={checkSubscription}
|
||||
disabled={isChecking || cooldown > 0}
|
||||
className="w-full py-4 px-6 bg-dark-800 hover:bg-dark-700 disabled:bg-dark-800 disabled:opacity-60 text-white font-semibold rounded-xl transition-all duration-200 flex items-center justify-center gap-3"
|
||||
className="flex w-full items-center justify-center gap-3 rounded-xl bg-dark-800 px-6 py-4 font-semibold text-white transition-all duration-200 hover:bg-dark-700 disabled:bg-dark-800 disabled:opacity-60"
|
||||
>
|
||||
{isChecking ? (
|
||||
<>
|
||||
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
<svg
|
||||
className="h-5 w-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
{t('blocking.channel.checking', 'Проверяем...')}
|
||||
</>
|
||||
) : cooldown > 0 ? (
|
||||
<>
|
||||
<svg className="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg
|
||||
className="h-5 w-5 text-gray-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
{t('blocking.channel.waitSeconds', 'Подождите {{seconds}} сек.', { seconds: cooldown })}
|
||||
{t('blocking.channel.waitSeconds', 'Подождите {{seconds}} сек.', {
|
||||
seconds: cooldown,
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
{t('blocking.channel.checkSubscription', 'Проверить подписку')}
|
||||
</>
|
||||
@@ -146,10 +177,10 @@ export default function ChannelSubscriptionScreen() {
|
||||
</div>
|
||||
|
||||
{/* Hint */}
|
||||
<p className="text-gray-500 text-sm mt-6">
|
||||
<p className="mt-6 text-sm text-gray-500">
|
||||
{t('blocking.channel.hint', 'После подписки нажмите кнопку проверки')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBlockingStore } from '../../store/blocking'
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useBlockingStore } from '../../store/blocking';
|
||||
|
||||
export default function MaintenanceScreen() {
|
||||
const { t } = useTranslation()
|
||||
const { maintenanceInfo } = useBlockingStore()
|
||||
const { t } = useTranslation();
|
||||
const { maintenanceInfo } = useBlockingStore();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] bg-dark-950 flex flex-col items-center justify-center p-6">
|
||||
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-dark-950 p-6">
|
||||
<div className="w-full max-w-md text-center">
|
||||
{/* Icon */}
|
||||
<div className="mb-8">
|
||||
<div className="w-24 h-24 mx-auto rounded-full bg-dark-800 flex items-center justify-center">
|
||||
<div className="mx-auto flex h-24 w-24 items-center justify-center rounded-full bg-dark-800">
|
||||
<svg
|
||||
className="w-12 h-12 text-amber-500"
|
||||
className="h-12 w-12 text-amber-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -28,38 +28,49 @@ export default function MaintenanceScreen() {
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-2xl font-bold text-white mb-4">
|
||||
<h1 className="mb-4 text-2xl font-bold text-white">
|
||||
{t('blocking.maintenance.title', 'Технические работы')}
|
||||
</h1>
|
||||
|
||||
{/* Message */}
|
||||
<p className="text-gray-400 mb-6 text-lg">
|
||||
{maintenanceInfo?.message || t('blocking.maintenance.defaultMessage', 'Сервис временно недоступен. Проводятся технические работы.')}
|
||||
<p className="mb-6 text-lg text-gray-400">
|
||||
{maintenanceInfo?.message ||
|
||||
t(
|
||||
'blocking.maintenance.defaultMessage',
|
||||
'Сервис временно недоступен. Проводятся технические работы.',
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Reason */}
|
||||
{maintenanceInfo?.reason && (
|
||||
<div className="bg-dark-800/50 rounded-xl p-4 mb-6">
|
||||
<p className="text-gray-500 text-sm mb-1">
|
||||
<div className="mb-6 rounded-xl bg-dark-800/50 p-4">
|
||||
<p className="mb-1 text-sm text-gray-500">
|
||||
{t('blocking.maintenance.reason', 'Причина')}:
|
||||
</p>
|
||||
<p className="text-gray-300">
|
||||
{maintenanceInfo.reason}
|
||||
</p>
|
||||
<p className="text-gray-300">{maintenanceInfo.reason}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decorative dots */}
|
||||
<div className="flex items-center justify-center gap-2 mt-8">
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" style={{ animationDelay: '0ms' }} />
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" style={{ animationDelay: '300ms' }} />
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" style={{ animationDelay: '600ms' }} />
|
||||
<div className="mt-8 flex items-center justify-center gap-2">
|
||||
<div
|
||||
className="h-2 w-2 animate-pulse rounded-full bg-amber-500"
|
||||
style={{ animationDelay: '0ms' }}
|
||||
/>
|
||||
<div
|
||||
className="h-2 w-2 animate-pulse rounded-full bg-amber-500"
|
||||
style={{ animationDelay: '300ms' }}
|
||||
/>
|
||||
<div
|
||||
className="h-2 w-2 animate-pulse rounded-full bg-amber-500"
|
||||
style={{ animationDelay: '600ms' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-500 text-sm mt-4">
|
||||
<p className="mt-4 text-sm text-gray-500">
|
||||
{t('blocking.maintenance.waitMessage', 'Пожалуйста, подождите...')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as MaintenanceScreen } from './MaintenanceScreen'
|
||||
export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen'
|
||||
export { default as MaintenanceScreen } from './MaintenanceScreen';
|
||||
export { default as ChannelSubscriptionScreen } from './ChannelSubscriptionScreen';
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
interface PageLoaderProps {
|
||||
variant?: 'dark' | 'light'
|
||||
variant?: 'dark' | 'light';
|
||||
}
|
||||
|
||||
export default function PageLoader({ variant = 'dark' }: PageLoaderProps) {
|
||||
const bgClass = variant === 'dark' ? 'bg-dark-950' : 'bg-gray-50'
|
||||
const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500'
|
||||
const bgClass = variant === 'dark' ? 'bg-dark-950' : 'bg-gray-50';
|
||||
const spinnerColor = variant === 'dark' ? 'border-accent-500' : 'border-blue-500';
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex items-center justify-center ${bgClass}`}>
|
||||
<div className={`w-10 h-10 border-[3px] ${spinnerColor} border-t-transparent rounded-full animate-spin`} />
|
||||
<div className={`flex min-h-screen items-center justify-center ${bgClass}`}>
|
||||
<div
|
||||
className={`h-10 w-10 border-[3px] ${spinnerColor} animate-spin rounded-full border-t-transparent`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,274 +1,346 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuthStore } from '../../store/auth'
|
||||
import LanguageSwitcher from '../LanguageSwitcher'
|
||||
import PromoDiscountBadge from '../PromoDiscountBadge'
|
||||
import TicketNotificationBell from '../TicketNotificationBell'
|
||||
import AnimatedBackground from '../AnimatedBackground'
|
||||
import { contestsApi } from '../../api/contests'
|
||||
import { pollsApi } from '../../api/polls'
|
||||
import { brandingApi, getCachedBranding, setCachedBranding, preloadLogo, isLogoPreloaded } from '../../api/branding'
|
||||
import { wheelApi } from '../../api/wheel'
|
||||
import { themeColorsApi } from '../../api/themeColors'
|
||||
import { promoApi } from '../../api/promo'
|
||||
import { referralApi } from '../../api/referral'
|
||||
import { useTheme } from '../../hooks/useTheme'
|
||||
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp'
|
||||
import { usePullToRefresh } from '../../hooks/usePullToRefresh'
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '../../store/auth';
|
||||
import LanguageSwitcher from '../LanguageSwitcher';
|
||||
import PromoDiscountBadge from '../PromoDiscountBadge';
|
||||
import TicketNotificationBell from '../TicketNotificationBell';
|
||||
import AnimatedBackground from '../AnimatedBackground';
|
||||
import { contestsApi } from '../../api/contests';
|
||||
import { pollsApi } from '../../api/polls';
|
||||
import {
|
||||
brandingApi,
|
||||
getCachedBranding,
|
||||
setCachedBranding,
|
||||
preloadLogo,
|
||||
isLogoPreloaded,
|
||||
} from '../../api/branding';
|
||||
import { wheelApi } from '../../api/wheel';
|
||||
import { themeColorsApi } from '../../api/themeColors';
|
||||
import { promoApi } from '../../api/promo';
|
||||
import { referralApi } from '../../api/referral';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp';
|
||||
import { usePullToRefresh } from '../../hooks/usePullToRefresh';
|
||||
|
||||
// Fallback branding from environment variables
|
||||
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'
|
||||
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V'
|
||||
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet';
|
||||
const FALLBACK_LOGO = import.meta.env.VITE_APP_LOGO || 'V';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Icons as simple SVG components
|
||||
const HomeIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const SubscriptionIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const WalletIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const UsersIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ChatIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const UserIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const LogoutIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
// Theme toggle icons
|
||||
const SunIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const MoonIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const MenuIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CloseIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const GamepadIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.959.401v0a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.78.78 0 01.79-.869" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M14.25 6.087c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.036-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959v0a.64.64 0 01-.657.643 48.39 48.39 0 01-4.163-.3c.186 1.613.293 3.25.315 4.907a.656.656 0 01-.658.663v0c-.355 0-.676-.186-.959-.401a1.647 1.647 0 00-1.003-.349c-1.036 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401v0c.31 0 .555.26.532.57a48.039 48.039 0 01-.642 5.056c1.518.19 3.058.309 4.616.354a.64.64 0 00.657-.643v0c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.035 1.008-1.875 2.25-1.875 1.243 0 2.25.84 2.25 1.875 0 .369-.128.713-.349 1.003-.215.283-.4.604-.4.959v0c0 .333.277.599.61.58a48.1 48.1 0 005.427-.63 48.05 48.05 0 00.582-4.717.532.532 0 00-.533-.57v0c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.035 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.37 0 .713.128 1.003.349.283.215.604.401.959.401v0a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.78.78 0 01.79-.869"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ClipboardIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 002.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 00-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 00.75-.75 2.25 2.25 0 00-.1-.664m-5.8 0A2.251 2.251 0 0113.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25zM6.75 12h.008v.008H6.75V12zm0 3h.008v.008H6.75V15zm0 3h.008v.008H6.75V18z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const InfoIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CogIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const WheelIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const { t } = useTranslation()
|
||||
const location = useLocation()
|
||||
const { user, logout, isAdmin, isAuthenticated } = useAuthStore()
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false)
|
||||
const { toggleTheme, isDark } = useTheme()
|
||||
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null)
|
||||
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp()
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const { user, logout, isAdmin, isAuthenticated } = useAuthStore();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
const [isKeyboardOpen, setIsKeyboardOpen] = useState(false);
|
||||
const { toggleTheme, isDark } = useTheme();
|
||||
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null);
|
||||
const { isFullscreen, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
|
||||
|
||||
// Pull to refresh (disabled when mobile menu is open)
|
||||
const { isPulling, pullDistance, isRefreshing, progress } = usePullToRefresh({
|
||||
disabled: mobileMenuOpen,
|
||||
threshold: 80,
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch enabled themes from API - same source of truth as AdminSettings
|
||||
const { data: enabledThemes } = useQuery({
|
||||
queryKey: ['enabled-themes'],
|
||||
queryFn: themeColorsApi.getEnabledThemes,
|
||||
staleTime: 1000 * 60 * 5, // 5 minutes
|
||||
})
|
||||
});
|
||||
|
||||
// Only show theme toggle if both themes are enabled
|
||||
const canToggle = enabledThemes?.dark && enabledThemes?.light
|
||||
const canToggle = enabledThemes?.dark && enabledThemes?.light;
|
||||
|
||||
// Get user photo from Telegram WebApp
|
||||
useEffect(() => {
|
||||
try {
|
||||
const tg = (window as any).Telegram?.WebApp
|
||||
const photoUrl = tg?.initDataUnsafe?.user?.photo_url
|
||||
const tg = (
|
||||
window as unknown as {
|
||||
Telegram?: { WebApp?: { initDataUnsafe?: { user?: { photo_url?: string } } } };
|
||||
}
|
||||
).Telegram?.WebApp;
|
||||
const photoUrl = tg?.initDataUnsafe?.user?.photo_url;
|
||||
if (photoUrl) {
|
||||
setUserPhotoUrl(photoUrl)
|
||||
setUserPhotoUrl(photoUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to get Telegram user photo:', e)
|
||||
console.warn('Failed to get Telegram user photo:', e);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
// Lock body scroll when mobile menu is open (cross-platform)
|
||||
// Note: We avoid using body position:fixed with top:-scrollY as it causes issues
|
||||
// in Telegram Mini App where the menu disappears when opened from scrolled position
|
||||
useEffect(() => {
|
||||
if (!mobileMenuOpen) return
|
||||
if (!mobileMenuOpen) return;
|
||||
|
||||
const body = document.body
|
||||
const html = document.documentElement
|
||||
const body = document.body;
|
||||
const html = document.documentElement;
|
||||
|
||||
// Save original styles
|
||||
const originalStyles = {
|
||||
bodyOverflow: body.style.overflow,
|
||||
htmlOverflow: html.style.overflow,
|
||||
}
|
||||
};
|
||||
|
||||
// Lock scroll - simple approach without body position manipulation
|
||||
body.style.overflow = 'hidden'
|
||||
html.style.overflow = 'hidden'
|
||||
body.style.overflow = 'hidden';
|
||||
html.style.overflow = 'hidden';
|
||||
|
||||
// Prevent touchmove on body (critical for mobile, especially Telegram Mini App)
|
||||
const preventScroll = (e: TouchEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
const target = e.target as HTMLElement;
|
||||
// Allow scroll inside menu content
|
||||
if (target.closest('.mobile-menu-content')) return
|
||||
e.preventDefault()
|
||||
}
|
||||
document.addEventListener('touchmove', preventScroll, { passive: false })
|
||||
if (target.closest('.mobile-menu-content')) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
document.addEventListener('touchmove', preventScroll, { passive: false });
|
||||
|
||||
// Also prevent wheel scroll on desktop
|
||||
const preventWheel = (e: WheelEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (target.closest('.mobile-menu-content')) return
|
||||
e.preventDefault()
|
||||
}
|
||||
document.addEventListener('wheel', preventWheel, { passive: false })
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('.mobile-menu-content')) return;
|
||||
e.preventDefault();
|
||||
};
|
||||
document.addEventListener('wheel', preventWheel, { passive: false });
|
||||
|
||||
return () => {
|
||||
// Restore original styles
|
||||
body.style.overflow = originalStyles.bodyOverflow
|
||||
html.style.overflow = originalStyles.htmlOverflow
|
||||
body.style.overflow = originalStyles.bodyOverflow;
|
||||
html.style.overflow = originalStyles.htmlOverflow;
|
||||
|
||||
// Remove listeners
|
||||
document.removeEventListener('touchmove', preventScroll)
|
||||
document.removeEventListener('wheel', preventWheel)
|
||||
}
|
||||
}, [mobileMenuOpen])
|
||||
document.removeEventListener('touchmove', preventScroll);
|
||||
document.removeEventListener('wheel', preventWheel);
|
||||
};
|
||||
}, [mobileMenuOpen]);
|
||||
|
||||
// Detect virtual keyboard by tracking focus on input elements
|
||||
useEffect(() => {
|
||||
const handleFocusIn = (e: FocusEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
setIsKeyboardOpen(true)
|
||||
setIsKeyboardOpen(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocusOut = (e: FocusEvent) => {
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null
|
||||
const relatedTarget = e.relatedTarget as HTMLElement | null;
|
||||
// Only close if not focusing another input
|
||||
if (!relatedTarget ||
|
||||
(relatedTarget.tagName !== 'INPUT' &&
|
||||
relatedTarget.tagName !== 'TEXTAREA' &&
|
||||
!relatedTarget.isContentEditable)) {
|
||||
setIsKeyboardOpen(false)
|
||||
if (
|
||||
!relatedTarget ||
|
||||
(relatedTarget.tagName !== 'INPUT' &&
|
||||
relatedTarget.tagName !== 'TEXTAREA' &&
|
||||
!relatedTarget.isContentEditable)
|
||||
) {
|
||||
setIsKeyboardOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('focusin', handleFocusIn)
|
||||
document.addEventListener('focusout', handleFocusOut)
|
||||
document.addEventListener('focusin', handleFocusIn);
|
||||
document.addEventListener('focusout', handleFocusOut);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('focusin', handleFocusIn)
|
||||
document.removeEventListener('focusout', handleFocusOut)
|
||||
}
|
||||
}, [])
|
||||
document.removeEventListener('focusin', handleFocusIn);
|
||||
document.removeEventListener('focusout', handleFocusOut);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// State to track if logo image has loaded - start with true if already preloaded
|
||||
const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded())
|
||||
const [logoLoaded, setLogoLoaded] = useState(() => isLogoPreloaded());
|
||||
|
||||
// Fetch branding settings with localStorage cache for instant load
|
||||
const { data: branding } = useQuery({
|
||||
queryKey: ['branding'],
|
||||
queryFn: async () => {
|
||||
const data = await brandingApi.getBranding()
|
||||
setCachedBranding(data) // Update cache
|
||||
const data = await brandingApi.getBranding();
|
||||
setCachedBranding(data); // Update cache
|
||||
// Preload logo in background
|
||||
preloadLogo(data)
|
||||
return data
|
||||
preloadLogo(data);
|
||||
return data;
|
||||
},
|
||||
initialData: getCachedBranding() ?? undefined, // Use cached data immediately
|
||||
staleTime: 60000, // 1 minute
|
||||
refetchOnWindowFocus: true,
|
||||
retry: 1,
|
||||
})
|
||||
});
|
||||
|
||||
// Computed branding values - use fallback only if no branding and no cache
|
||||
const appName = branding ? branding.name : FALLBACK_NAME // Empty string is valid (logo-only mode)
|
||||
const logoLetter = branding?.logo_letter || FALLBACK_LOGO
|
||||
const hasCustomLogo = branding?.has_custom_logo || false
|
||||
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null
|
||||
const appName = branding ? branding.name : FALLBACK_NAME; // Empty string is valid (logo-only mode)
|
||||
const logoLetter = branding?.logo_letter || FALLBACK_LOGO;
|
||||
const hasCustomLogo = branding?.has_custom_logo || false;
|
||||
const logoUrl = branding ? brandingApi.getLogoUrl(branding) : null;
|
||||
|
||||
// Set document title
|
||||
useEffect(() => {
|
||||
document.title = appName || 'VPN' // Fallback title if name is empty
|
||||
}, [appName])
|
||||
document.title = appName || 'VPN'; // Fallback title if name is empty
|
||||
}, [appName]);
|
||||
|
||||
// Fetch contests and polls counts to determine if they should be shown
|
||||
const { data: contestsCount } = useQuery({
|
||||
@@ -277,7 +349,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60000, // 1 minute
|
||||
retry: false,
|
||||
})
|
||||
});
|
||||
|
||||
const { data: pollsCount } = useQuery({
|
||||
queryKey: ['polls-count'],
|
||||
@@ -285,7 +357,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60000, // 1 minute
|
||||
retry: false,
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch wheel config to check if enabled
|
||||
const { data: wheelConfig } = useQuery({
|
||||
@@ -294,7 +366,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60000, // 1 minute
|
||||
retry: false,
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch referral terms to check if enabled
|
||||
const { data: referralTerms } = useQuery({
|
||||
@@ -303,7 +375,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 60000, // 1 minute
|
||||
retry: false,
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch active discount to determine mobile layout
|
||||
const { data: activeDiscount } = useQuery({
|
||||
@@ -311,86 +383,90 @@ export default function Layout({ children }: LayoutProps) {
|
||||
queryFn: promoApi.getActiveDiscount,
|
||||
enabled: isAuthenticated,
|
||||
staleTime: 30000,
|
||||
})
|
||||
});
|
||||
|
||||
// Check if promo is active (to hide language switcher on mobile)
|
||||
const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent
|
||||
const isPromoActive = activeDiscount?.is_active && activeDiscount?.discount_percent;
|
||||
|
||||
const navItems = useMemo(() => {
|
||||
const items = [
|
||||
{ path: '/', label: t('nav.dashboard'), icon: HomeIcon },
|
||||
{ path: '/subscription', label: t('nav.subscription'), icon: SubscriptionIcon },
|
||||
{ path: '/balance', label: t('nav.balance'), icon: WalletIcon },
|
||||
]
|
||||
];
|
||||
|
||||
// Only show referral if program is enabled
|
||||
if (referralTerms?.is_enabled) {
|
||||
items.push({ path: '/referral', label: t('nav.referral'), icon: UsersIcon })
|
||||
items.push({ path: '/referral', label: t('nav.referral'), icon: UsersIcon });
|
||||
}
|
||||
|
||||
items.push({ path: '/support', label: t('nav.support'), icon: ChatIcon })
|
||||
items.push({ path: '/support', label: t('nav.support'), icon: ChatIcon });
|
||||
|
||||
// Only show contests if there are available contests
|
||||
if (contestsCount && contestsCount.count > 0) {
|
||||
items.push({ path: '/contests', label: t('nav.contests'), icon: GamepadIcon })
|
||||
items.push({ path: '/contests', label: t('nav.contests'), icon: GamepadIcon });
|
||||
}
|
||||
|
||||
// Only show polls if there are available polls
|
||||
if (pollsCount && pollsCount.count > 0) {
|
||||
items.push({ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon })
|
||||
items.push({ path: '/polls', label: t('nav.polls'), icon: ClipboardIcon });
|
||||
}
|
||||
|
||||
items.push({ path: '/info', label: t('nav.info'), icon: InfoIcon })
|
||||
items.push({ path: '/info', label: t('nav.info'), icon: InfoIcon });
|
||||
|
||||
return items
|
||||
}, [t, contestsCount, pollsCount, referralTerms])
|
||||
return items;
|
||||
}, [t, contestsCount, pollsCount, referralTerms]);
|
||||
|
||||
// Separate navItems for desktop that includes wheel (if enabled)
|
||||
const desktopNavItems = useMemo(() => {
|
||||
const items = [...navItems]
|
||||
const items = [...navItems];
|
||||
// Add wheel before info if enabled
|
||||
if (wheelConfig?.is_enabled) {
|
||||
const infoIndex = items.findIndex(item => item.path === '/info')
|
||||
const infoIndex = items.findIndex((item) => item.path === '/info');
|
||||
if (infoIndex !== -1) {
|
||||
items.splice(infoIndex, 0, { path: '/wheel', label: t('nav.wheel'), icon: WheelIcon })
|
||||
items.splice(infoIndex, 0, { path: '/wheel', label: t('nav.wheel'), icon: WheelIcon });
|
||||
} else {
|
||||
items.push({ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon })
|
||||
items.push({ path: '/wheel', label: t('nav.wheel'), icon: WheelIcon });
|
||||
}
|
||||
}
|
||||
return items
|
||||
}, [navItems, wheelConfig, t])
|
||||
return items;
|
||||
}, [navItems, wheelConfig, t]);
|
||||
|
||||
const adminNavItems = [
|
||||
{ path: '/admin', label: t('admin.nav.title'), icon: CogIcon },
|
||||
]
|
||||
const adminNavItems = [{ path: '/admin', label: t('admin.nav.title'), icon: CogIcon }];
|
||||
|
||||
const isActive = (path: string) => location.pathname === path
|
||||
const isAdminActive = () => location.pathname.startsWith('/admin')
|
||||
const isActive = (path: string) => location.pathname === path;
|
||||
const isAdminActive = () => location.pathname.startsWith('/admin');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<div className="flex min-h-screen flex-col">
|
||||
{/* Animated Background */}
|
||||
<AnimatedBackground />
|
||||
|
||||
{/* Pull to refresh indicator */}
|
||||
{(isPulling || isRefreshing) && (
|
||||
<div
|
||||
className="fixed left-1/2 -translate-x-1/2 z-[100] flex items-center justify-center transition-all duration-200"
|
||||
className="fixed left-1/2 z-[100] flex -translate-x-1/2 items-center justify-center transition-all duration-200"
|
||||
style={{
|
||||
top: `calc(${Math.max(pullDistance, isRefreshing ? 40 : 0)}px + env(safe-area-inset-top, 0px) + 0.5rem)`,
|
||||
opacity: isRefreshing ? 1 : progress,
|
||||
}}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-full bg-dark-800 border border-dark-700 shadow-lg flex items-center justify-center ${isRefreshing ? 'animate-pulse' : ''}`}>
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full border border-dark-700 bg-dark-800 shadow-lg ${isRefreshing ? 'animate-pulse' : ''}`}
|
||||
>
|
||||
<svg
|
||||
className={`w-5 h-5 text-accent-400 transition-transform duration-200 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
className={`h-5 w-5 text-accent-400 transition-transform duration-200 ${isRefreshing ? 'animate-spin' : ''}`}
|
||||
style={{ transform: isRefreshing ? undefined : `rotate(${progress * 360}deg)` }}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
@@ -398,19 +474,30 @@ export default function Layout({ children }: LayoutProps) {
|
||||
|
||||
{/* Header */}
|
||||
<header
|
||||
className="fixed top-0 left-0 right-0 z-50 glass shadow-lg shadow-black/10"
|
||||
className="glass fixed left-0 right-0 top-0 z-50 shadow-lg shadow-black/10"
|
||||
style={{
|
||||
// In fullscreen mode, add padding for safe area + Telegram native controls (close/menu buttons in corners)
|
||||
paddingTop: isFullscreen ? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` : undefined,
|
||||
paddingTop: isFullscreen
|
||||
? `${Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<div className="w-full mx-auto px-4 sm:px-6" onClick={() => mobileMenuOpen && setMobileMenuOpen(false)}>
|
||||
<div className="flex justify-between items-center h-16 lg:h-20">
|
||||
<div
|
||||
className="mx-auto w-full px-4 sm:px-6"
|
||||
onClick={() => mobileMenuOpen && setMobileMenuOpen(false)}
|
||||
>
|
||||
<div className="flex h-16 items-center justify-between lg:h-20">
|
||||
{/* Logo */}
|
||||
<Link to="/" onClick={() => setMobileMenuOpen(false)} className={`flex items-center gap-2.5 flex-shrink-0 ${!appName ? 'lg:mr-4' : ''}`}>
|
||||
<div className="w-10 h-10 sm:w-11 sm:h-11 lg:w-12 lg:h-12 rounded-xl bg-dark-800/80 dark:bg-dark-800/80 border border-dark-700/50 flex items-center justify-center overflow-hidden shadow-md flex-shrink-0 relative">
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={`flex flex-shrink-0 items-center gap-2.5 ${!appName ? 'lg:mr-4' : ''}`}
|
||||
>
|
||||
<div className="relative flex h-10 w-10 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-dark-700/50 bg-dark-800/80 shadow-md dark:bg-dark-800/80 sm:h-11 sm:w-11 lg:h-12 lg:w-12">
|
||||
{/* Always show letter as fallback */}
|
||||
<span className={`text-accent-400 font-bold text-lg sm:text-xl absolute transition-opacity duration-200 ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}>
|
||||
<span
|
||||
className={`absolute text-lg font-bold text-accent-400 transition-opacity duration-200 sm:text-xl ${hasCustomLogo && logoLoaded ? 'opacity-0' : 'opacity-100'}`}
|
||||
>
|
||||
{logoLetter}
|
||||
</span>
|
||||
{/* Logo image with smooth fade-in */}
|
||||
@@ -418,28 +505,28 @@ export default function Layout({ children }: LayoutProps) {
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt={appName || 'Logo'}
|
||||
className={`w-full h-full object-contain absolute transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
className={`absolute h-full w-full object-contain transition-opacity duration-200 ${logoLoaded ? 'opacity-100' : 'opacity-0'}`}
|
||||
onLoad={() => setLogoLoaded(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{appName && (
|
||||
<span className="text-base lg:text-lg font-semibold text-dark-100 whitespace-nowrap">
|
||||
<span className="whitespace-nowrap text-base font-semibold text-dark-100 lg:text-lg">
|
||||
{appName}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden lg:flex items-center gap-1">
|
||||
<nav className="hidden items-center gap-1 lg:flex">
|
||||
{desktopNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 ${
|
||||
isActive(item.path)
|
||||
? 'text-accent-400 bg-accent-500/10'
|
||||
: 'text-dark-400 hover:text-dark-100 hover:bg-dark-800/50'
|
||||
? 'bg-accent-500/10 text-accent-400'
|
||||
: 'text-dark-400 hover:bg-dark-800/50 hover:text-dark-100'
|
||||
}`}
|
||||
>
|
||||
<item.icon />
|
||||
@@ -448,15 +535,15 @@ export default function Layout({ children }: LayoutProps) {
|
||||
))}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="w-px h-6 bg-dark-700 mx-2" />
|
||||
<div className="mx-2 h-6 w-px bg-dark-700" />
|
||||
{adminNavItems.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 ${
|
||||
className={`flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-medium transition-all duration-200 ${
|
||||
isAdminActive()
|
||||
? 'text-warning-400 bg-warning-500/10'
|
||||
: 'text-warning-500/70 hover:text-warning-400 hover:bg-warning-500/10'
|
||||
? 'bg-warning-500/10 text-warning-400'
|
||||
: 'text-warning-500/70 hover:bg-warning-500/10 hover:text-warning-400'
|
||||
}`}
|
||||
>
|
||||
<item.icon />
|
||||
@@ -473,21 +560,26 @@ export default function Layout({ children }: LayoutProps) {
|
||||
{canToggle && (
|
||||
<button
|
||||
onClick={() => {
|
||||
toggleTheme()
|
||||
setMobileMenuOpen(false)
|
||||
toggleTheme();
|
||||
setMobileMenuOpen(false);
|
||||
}}
|
||||
className="relative p-2 rounded-xl transition-all duration-200
|
||||
bg-dark-800/50 hover:bg-dark-700 border border-dark-700/50
|
||||
dark:text-dark-400 dark:hover:text-accent-400
|
||||
text-champagne-500 hover:text-champagne-800"
|
||||
className="relative rounded-xl border border-dark-700/50 bg-dark-800/50 p-2 text-champagne-500 transition-all duration-200 hover:bg-dark-700 hover:text-champagne-800 dark:text-dark-400 dark:hover:text-accent-400"
|
||||
title={isDark ? t('theme.light') || 'Light mode' : t('theme.dark') || 'Dark mode'}
|
||||
aria-label={isDark ? t('theme.light') || 'Switch to light mode' : t('theme.dark') || 'Switch to dark mode'}
|
||||
aria-label={
|
||||
isDark
|
||||
? t('theme.light') || 'Switch to light mode'
|
||||
: t('theme.dark') || 'Switch to dark mode'
|
||||
}
|
||||
>
|
||||
<div className="relative w-5 h-5">
|
||||
<div className={`absolute inset-0 transition-all duration-300 ${isDark ? 'opacity-100 rotate-0' : 'opacity-0 rotate-90'}`}>
|
||||
<div className="relative h-5 w-5">
|
||||
<div
|
||||
className={`absolute inset-0 transition-all duration-300 ${isDark ? 'rotate-0 opacity-100' : 'rotate-90 opacity-0'}`}
|
||||
>
|
||||
<MoonIcon />
|
||||
</div>
|
||||
<div className={`absolute inset-0 transition-all duration-300 ${isDark ? 'opacity-0 -rotate-90' : 'opacity-100 rotate-0'}`}>
|
||||
<div
|
||||
className={`absolute inset-0 transition-all duration-300 ${isDark ? '-rotate-90 opacity-0' : 'rotate-0 opacity-100'}`}
|
||||
>
|
||||
<SunIcon />
|
||||
</div>
|
||||
</div>
|
||||
@@ -501,17 +593,20 @@ export default function Layout({ children }: LayoutProps) {
|
||||
<TicketNotificationBell isAdmin={isAdminActive()} />
|
||||
</div>
|
||||
{/* Hide language switcher on mobile when promo is active */}
|
||||
<div className={isPromoActive ? 'hidden sm:block' : ''} onClick={() => setMobileMenuOpen(false)}>
|
||||
<div
|
||||
className={isPromoActive ? 'hidden sm:block' : ''}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
>
|
||||
<LanguageSwitcher />
|
||||
</div>
|
||||
|
||||
{/* Profile - Desktop */}
|
||||
<div className="hidden sm:flex items-center gap-3">
|
||||
<div className="hidden items-center gap-3 sm:flex">
|
||||
<Link
|
||||
to="/profile"
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-lg hover:bg-dark-800/50 transition-colors"
|
||||
className="flex items-center gap-2 rounded-lg px-3 py-1.5 transition-colors hover:bg-dark-800/50"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-dark-700 flex items-center justify-center">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-dark-700">
|
||||
<UserIcon />
|
||||
</div>
|
||||
<span className="text-sm text-dark-300">
|
||||
@@ -531,11 +626,13 @@ export default function Layout({ children }: LayoutProps) {
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setMobileMenuOpen(!mobileMenuOpen)
|
||||
e.stopPropagation();
|
||||
setMobileMenuOpen(!mobileMenuOpen);
|
||||
}}
|
||||
className="lg:hidden btn-icon"
|
||||
aria-label={mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'}
|
||||
className="btn-icon lg:hidden"
|
||||
aria-label={
|
||||
mobileMenuOpen ? t('common.close') || 'Close menu' : t('nav.menu') || 'Open menu'
|
||||
}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
>
|
||||
{mobileMenuOpen ? <CloseIcon /> : <MenuIcon />}
|
||||
@@ -543,7 +640,6 @@ export default function Layout({ children }: LayoutProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
{/* Spacer for fixed header - matches header height */}
|
||||
@@ -553,17 +649,17 @@ export default function Layout({ children }: LayoutProps) {
|
||||
style={{ height: `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex-shrink-0 h-16 lg:h-20" />
|
||||
<div className="h-16 flex-shrink-0 lg:h-20" />
|
||||
)}
|
||||
|
||||
{/* Mobile menu - fixed overlay below header */}
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-x-0 bottom-0 z-40 animate-fade-in"
|
||||
className="fixed inset-x-0 bottom-0 z-40 animate-fade-in lg:hidden"
|
||||
style={{
|
||||
top: isFullscreen
|
||||
? `${64 + Math.max(safeAreaInset.top, contentSafeAreaInset.top) + 45}px`
|
||||
: '64px'
|
||||
: '64px',
|
||||
}}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
@@ -573,23 +669,28 @@ export default function Layout({ children }: LayoutProps) {
|
||||
/>
|
||||
|
||||
{/* Menu content */}
|
||||
<div className="mobile-menu-content absolute inset-x-0 top-0 bottom-0 bg-dark-900 border-t border-dark-800/50 overflow-y-auto overscroll-contain pb-[calc(5rem+env(safe-area-inset-bottom,0px))]" style={{ WebkitOverflowScrolling: 'touch' }}>
|
||||
<div className="max-w-6xl mx-auto px-4 py-4">
|
||||
<div
|
||||
className="mobile-menu-content absolute inset-x-0 bottom-0 top-0 overflow-y-auto overscroll-contain border-t border-dark-800/50 bg-dark-900 pb-[calc(5rem+env(safe-area-inset-bottom,0px))]"
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
>
|
||||
<div className="mx-auto max-w-6xl px-4 py-4">
|
||||
{/* User info */}
|
||||
<div className="flex items-center justify-between pb-4 mb-4 border-b border-dark-800/50">
|
||||
<div className="mb-4 flex items-center justify-between border-b border-dark-800/50 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{userPhotoUrl ? (
|
||||
<img
|
||||
src={userPhotoUrl}
|
||||
alt="Avatar"
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
e.currentTarget.nextElementSibling?.classList.remove('hidden')
|
||||
e.currentTarget.style.display = 'none';
|
||||
e.currentTarget.nextElementSibling?.classList.remove('hidden');
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div className={`w-10 h-10 rounded-full bg-dark-700 flex items-center justify-center ${userPhotoUrl ? 'hidden' : ''}`}>
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full bg-dark-700 ${userPhotoUrl ? 'hidden' : ''}`}
|
||||
>
|
||||
<UserIcon />
|
||||
</div>
|
||||
<div>
|
||||
@@ -622,7 +723,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
{isAdmin && (
|
||||
<>
|
||||
<div className="divider my-3" />
|
||||
<div className="px-4 py-1 text-xs font-medium text-dark-500 uppercase tracking-wider">
|
||||
<div className="px-4 py-1 text-xs font-medium uppercase tracking-wider text-dark-500">
|
||||
{t('admin.nav.title')}
|
||||
</div>
|
||||
{adminNavItems.map((item) => (
|
||||
@@ -630,7 +731,7 @@ export default function Layout({ children }: LayoutProps) {
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={`nav-item ${isAdminActive() ? 'text-warning-400 bg-warning-500/10' : 'text-warning-500/70'}`}
|
||||
className={`nav-item ${isAdminActive() ? 'bg-warning-500/10 text-warning-400' : 'text-warning-500/70'}`}
|
||||
>
|
||||
<item.icon />
|
||||
{item.label}
|
||||
@@ -652,8 +753,8 @@ export default function Layout({ children }: LayoutProps) {
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false)
|
||||
logout()
|
||||
setMobileMenuOpen(false);
|
||||
logout();
|
||||
}}
|
||||
className="nav-item w-full text-error-400"
|
||||
>
|
||||
@@ -667,31 +768,32 @@ export default function Layout({ children }: LayoutProps) {
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 max-w-6xl w-full mx-auto px-4 sm:px-6 py-6 pb-24 lg:pb-8">
|
||||
<div className="animate-fade-in">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<main className="mx-auto w-full max-w-6xl flex-1 px-4 py-6 pb-24 sm:px-6 lg:pb-8">
|
||||
<div className="animate-fade-in">{children}</div>
|
||||
</main>
|
||||
|
||||
{/* Mobile Bottom Navigation - only core items, hidden when keyboard is open */}
|
||||
<nav className={`bottom-nav lg:hidden transition-opacity duration-200 ${isKeyboardOpen ? 'opacity-0 pointer-events-none' : 'opacity-100'}`}>
|
||||
<nav
|
||||
className={`bottom-nav transition-opacity duration-200 lg:hidden ${isKeyboardOpen ? 'pointer-events-none opacity-0' : 'opacity-100'}`}
|
||||
>
|
||||
<div className="flex justify-around">
|
||||
{navItems.filter(item =>
|
||||
['/', '/subscription', '/balance', '/referral', '/support'].includes(item.path)
|
||||
).map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'}
|
||||
>
|
||||
<item.icon />
|
||||
<span className="text-2xs mt-1 whitespace-nowrap">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
{navItems
|
||||
.filter((item) =>
|
||||
['/', '/subscription', '/balance', '/referral', '/support'].includes(item.path),
|
||||
)
|
||||
.map((item) => (
|
||||
<Link
|
||||
key={item.path}
|
||||
to={item.path}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={isActive(item.path) ? 'bottom-nav-item-active' : 'bottom-nav-item'}
|
||||
>
|
||||
<item.icon />
|
||||
<span className="mt-1 whitespace-nowrap text-2xs">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { forwardRef } from 'react'
|
||||
import { Link } from 'react-router-dom';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
export type BentoSize = 'sm' | 'md' | 'lg' | 'xl'
|
||||
export type BentoSize = 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
interface BentoCardBaseProps {
|
||||
size?: BentoSize
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
hover?: boolean
|
||||
glow?: boolean
|
||||
size?: BentoSize;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
hover?: boolean;
|
||||
glow?: boolean;
|
||||
}
|
||||
|
||||
interface BentoCardDivProps extends BentoCardBaseProps {
|
||||
as?: 'div'
|
||||
onClick?: () => void
|
||||
as?: 'div';
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
interface BentoCardLinkProps extends BentoCardBaseProps {
|
||||
as: 'link'
|
||||
to: string
|
||||
state?: unknown
|
||||
as: 'link';
|
||||
to: string;
|
||||
state?: unknown;
|
||||
}
|
||||
|
||||
interface BentoCardButtonProps extends BentoCardBaseProps {
|
||||
as: 'button'
|
||||
onClick?: () => void
|
||||
disabled?: boolean
|
||||
type?: 'button' | 'submit'
|
||||
as: 'button';
|
||||
onClick?: () => void;
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit';
|
||||
}
|
||||
|
||||
export type BentoCardProps = BentoCardDivProps | BentoCardLinkProps | BentoCardButtonProps
|
||||
export type BentoCardProps = BentoCardDivProps | BentoCardLinkProps | BentoCardButtonProps;
|
||||
|
||||
const sizeClasses: Record<BentoSize, string> = {
|
||||
sm: '',
|
||||
md: 'col-span-2',
|
||||
lg: 'row-span-2',
|
||||
xl: 'col-span-2 row-span-2',
|
||||
}
|
||||
};
|
||||
|
||||
const baseClasses = `
|
||||
bento-card
|
||||
@@ -45,7 +45,7 @@ const baseClasses = `
|
||||
bg-dark-900/70
|
||||
border border-dark-700/40
|
||||
transition-all duration-300 ease-smooth
|
||||
`
|
||||
`;
|
||||
|
||||
const hoverClasses = `
|
||||
cursor-pointer
|
||||
@@ -54,21 +54,15 @@ const hoverClasses = `
|
||||
hover:shadow-lg
|
||||
hover:scale-[1.01]
|
||||
active:scale-[0.99]
|
||||
`
|
||||
`;
|
||||
|
||||
const glowClasses = `
|
||||
hover:shadow-glow
|
||||
hover:border-accent-500/30
|
||||
`
|
||||
`;
|
||||
|
||||
export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref) => {
|
||||
const {
|
||||
size = 'sm',
|
||||
children,
|
||||
className = '',
|
||||
hover = false,
|
||||
glow = false,
|
||||
} = props
|
||||
const { size = 'sm', children, className = '', hover = false, glow = false } = props;
|
||||
|
||||
const classes = [
|
||||
baseClasses,
|
||||
@@ -76,19 +70,21 @@ export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref)
|
||||
hover && hoverClasses,
|
||||
glow && glowClasses,
|
||||
className,
|
||||
].filter(Boolean).join(' ')
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
if (props.as === 'link') {
|
||||
const { to, state } = props as BentoCardLinkProps
|
||||
const { to, state } = props as BentoCardLinkProps;
|
||||
return (
|
||||
<Link to={to} state={state} className={classes}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (props.as === 'button') {
|
||||
const { onClick, disabled, type = 'button' } = props as BentoCardButtonProps
|
||||
const { onClick, disabled, type = 'button' } = props as BentoCardButtonProps;
|
||||
return (
|
||||
<button
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
@@ -99,17 +95,17 @@ export const BentoCard = forwardRef<HTMLDivElement, BentoCardProps>((props, ref)
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { onClick } = props as BentoCardDivProps
|
||||
const { onClick } = props as BentoCardDivProps;
|
||||
return (
|
||||
<div ref={ref} onClick={onClick} className={classes}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
BentoCard.displayName = 'BentoCard'
|
||||
BentoCard.displayName = 'BentoCard';
|
||||
|
||||
export default BentoCard
|
||||
export default BentoCard;
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
interface BentoSkeletonProps {
|
||||
className?: string
|
||||
count?: number
|
||||
className?: string;
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export default function BentoSkeleton({ className = '', count = 1 }: BentoSkeletonProps) {
|
||||
const baseClasses = `animate-pulse bg-dark-800/50 border border-dark-700/30 rounded-[var(--bento-radius,24px)] min-h-[160px] w-full ${className}`
|
||||
const baseClasses = `animate-pulse bg-dark-800/50 border border-dark-700/30 rounded-[var(--bento-radius,24px)] min-h-[160px] w-full ${className}`;
|
||||
|
||||
if (count > 1) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={baseClasses}
|
||||
style={{ '--stagger': i } as React.CSSProperties}
|
||||
/>
|
||||
<div key={i} className={baseClasses} style={{ '--stagger': i } as React.CSSProperties} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={baseClasses}
|
||||
/>
|
||||
)
|
||||
return <div className={baseClasses} />;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { useEffect, useRef, useState, useMemo, memo } from 'react'
|
||||
import type { WheelPrize } from '../../api/wheel'
|
||||
import { useEffect, useRef, useState, useMemo, memo } from 'react';
|
||||
import type { WheelPrize } from '../../api/wheel';
|
||||
|
||||
interface FortuneWheelProps {
|
||||
prizes: WheelPrize[]
|
||||
isSpinning: boolean
|
||||
targetRotation: number | null
|
||||
onSpinComplete: () => void
|
||||
prizes: WheelPrize[];
|
||||
isSpinning: boolean;
|
||||
targetRotation: number | null;
|
||||
onSpinComplete: () => void;
|
||||
}
|
||||
|
||||
// Pre-generate sparkle positions to avoid recalculating on each render
|
||||
const SPARKLE_POSITIONS = Array.from({ length: 8 }, (_, i) => ({
|
||||
top: `${20 + (i * 10) % 60}%`,
|
||||
left: `${15 + (i * 13) % 70}%`,
|
||||
top: `${20 + ((i * 10) % 60)}%`,
|
||||
left: `${15 + ((i * 13) % 70)}%`,
|
||||
delay: `${i * 0.15}s`,
|
||||
}))
|
||||
}));
|
||||
|
||||
const FortuneWheel = memo(function FortuneWheel({
|
||||
prizes,
|
||||
@@ -21,141 +21,150 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
targetRotation,
|
||||
onSpinComplete,
|
||||
}: FortuneWheelProps) {
|
||||
const wheelRef = useRef<SVGGElement>(null)
|
||||
const [currentRotation, setCurrentRotation] = useState(0)
|
||||
const [lightPhase, setLightPhase] = useState(0)
|
||||
const wheelRef = useRef<SVGGElement>(null);
|
||||
const [currentRotation, setCurrentRotation] = useState(0);
|
||||
const [lightPhase, setLightPhase] = useState(0);
|
||||
|
||||
// Animated lights effect - use phase instead of random array (less re-renders)
|
||||
useEffect(() => {
|
||||
if (isSpinning) {
|
||||
const interval = setInterval(() => {
|
||||
setLightPhase(p => (p + 1) % 3) // Just toggle phase 0-1-2
|
||||
}, 250) // Slower interval = better performance
|
||||
return () => clearInterval(interval)
|
||||
setLightPhase((p) => (p + 1) % 3); // Just toggle phase 0-1-2
|
||||
}, 250); // Slower interval = better performance
|
||||
return () => clearInterval(interval);
|
||||
} else {
|
||||
setLightPhase(0)
|
||||
setLightPhase(0);
|
||||
}
|
||||
}, [isSpinning])
|
||||
}, [isSpinning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSpinning && targetRotation !== null && wheelRef.current) {
|
||||
setCurrentRotation(targetRotation)
|
||||
setCurrentRotation(targetRotation);
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
onSpinComplete()
|
||||
}, 5000)
|
||||
onSpinComplete();
|
||||
}, 5000);
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [isSpinning, targetRotation, onSpinComplete])
|
||||
}, [isSpinning, targetRotation, onSpinComplete]);
|
||||
|
||||
// Memoize light pattern calculation
|
||||
const lightPattern = useMemo(() => {
|
||||
return Array.from({ length: 20 }, (_, i) => {
|
||||
if (!isSpinning) return i % 2 === 0
|
||||
return (i + lightPhase) % 3 !== 0
|
||||
})
|
||||
}, [isSpinning, lightPhase])
|
||||
if (!isSpinning) return i % 2 === 0;
|
||||
return (i + lightPhase) % 3 !== 0;
|
||||
});
|
||||
}, [isSpinning, lightPhase]);
|
||||
|
||||
if (prizes.length === 0) {
|
||||
return (
|
||||
<div className="w-full max-w-md mx-auto aspect-square flex items-center justify-center">
|
||||
<div className="mx-auto flex aspect-square w-full max-w-md items-center justify-center">
|
||||
<p className="text-dark-400">No prizes configured</p>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const size = 400
|
||||
const center = size / 2
|
||||
const outerRadius = size / 2 - 20
|
||||
const innerRadius = outerRadius - 15
|
||||
const prizeRadius = innerRadius - 5
|
||||
const sectorAngle = 360 / prizes.length
|
||||
const hubRadius = 45
|
||||
const size = 400;
|
||||
const center = size / 2;
|
||||
const outerRadius = size / 2 - 20;
|
||||
const innerRadius = outerRadius - 15;
|
||||
const prizeRadius = innerRadius - 5;
|
||||
const sectorAngle = 360 / prizes.length;
|
||||
const hubRadius = 45;
|
||||
|
||||
const createSectorPath = (index: number) => {
|
||||
const startAngle = (index * sectorAngle - 90) * (Math.PI / 180)
|
||||
const endAngle = ((index + 1) * sectorAngle - 90) * (Math.PI / 180)
|
||||
const startAngle = (index * sectorAngle - 90) * (Math.PI / 180);
|
||||
const endAngle = ((index + 1) * sectorAngle - 90) * (Math.PI / 180);
|
||||
|
||||
const x1 = center + prizeRadius * Math.cos(startAngle)
|
||||
const y1 = center + prizeRadius * Math.sin(startAngle)
|
||||
const x2 = center + prizeRadius * Math.cos(endAngle)
|
||||
const y2 = center + prizeRadius * Math.sin(endAngle)
|
||||
const x1 = center + prizeRadius * Math.cos(startAngle);
|
||||
const y1 = center + prizeRadius * Math.sin(startAngle);
|
||||
const x2 = center + prizeRadius * Math.cos(endAngle);
|
||||
const y2 = center + prizeRadius * Math.sin(endAngle);
|
||||
|
||||
const x1Inner = center + hubRadius * Math.cos(startAngle)
|
||||
const y1Inner = center + hubRadius * Math.sin(startAngle)
|
||||
const x2Inner = center + hubRadius * Math.cos(endAngle)
|
||||
const y2Inner = center + hubRadius * Math.sin(endAngle)
|
||||
const x1Inner = center + hubRadius * Math.cos(startAngle);
|
||||
const y1Inner = center + hubRadius * Math.sin(startAngle);
|
||||
const x2Inner = center + hubRadius * Math.cos(endAngle);
|
||||
const y2Inner = center + hubRadius * Math.sin(endAngle);
|
||||
|
||||
const largeArc = sectorAngle > 180 ? 1 : 0
|
||||
const largeArc = sectorAngle > 180 ? 1 : 0;
|
||||
|
||||
return `M ${x1Inner} ${y1Inner}
|
||||
L ${x1} ${y1}
|
||||
A ${prizeRadius} ${prizeRadius} 0 ${largeArc} 1 ${x2} ${y2}
|
||||
L ${x2Inner} ${y2Inner}
|
||||
A ${hubRadius} ${hubRadius} 0 ${largeArc} 0 ${x1Inner} ${y1Inner} Z`
|
||||
}
|
||||
A ${hubRadius} ${hubRadius} 0 ${largeArc} 0 ${x1Inner} ${y1Inner} Z`;
|
||||
};
|
||||
|
||||
// Position for emoji - closer to outer edge
|
||||
const getEmojiPosition = (index: number) => {
|
||||
const angle = ((index * sectorAngle + sectorAngle / 2) - 90) * (Math.PI / 180)
|
||||
const emojiRadius = prizeRadius * 0.75
|
||||
const angle = (index * sectorAngle + sectorAngle / 2 - 90) * (Math.PI / 180);
|
||||
const emojiRadius = prizeRadius * 0.75;
|
||||
return {
|
||||
x: center + emojiRadius * Math.cos(angle),
|
||||
y: center + emojiRadius * Math.sin(angle),
|
||||
rotation: index * sectorAngle + sectorAngle / 2,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Position for text - between hub and emoji
|
||||
const getTextPosition = (index: number) => {
|
||||
const angle = ((index * sectorAngle + sectorAngle / 2) - 90) * (Math.PI / 180)
|
||||
const textRadius = prizeRadius * 0.45
|
||||
const angle = (index * sectorAngle + sectorAngle / 2 - 90) * (Math.PI / 180);
|
||||
const textRadius = prizeRadius * 0.45;
|
||||
return {
|
||||
x: center + textRadius * Math.cos(angle),
|
||||
y: center + textRadius * Math.sin(angle),
|
||||
rotation: index * sectorAngle + sectorAngle / 2,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Alternate colors for sectors
|
||||
const getSectorColors = (index: number, baseColor?: string) => {
|
||||
if (baseColor) return baseColor
|
||||
if (baseColor) return baseColor;
|
||||
const colors = [
|
||||
'#8B5CF6', '#EC4899', '#3B82F6', '#10B981',
|
||||
'#F59E0B', '#EF4444', '#6366F1', '#14B8A6'
|
||||
]
|
||||
return colors[index % colors.length]
|
||||
}
|
||||
'#8B5CF6',
|
||||
'#EC4899',
|
||||
'#3B82F6',
|
||||
'#10B981',
|
||||
'#F59E0B',
|
||||
'#EF4444',
|
||||
'#6366F1',
|
||||
'#14B8A6',
|
||||
];
|
||||
return colors[index % colors.length];
|
||||
};
|
||||
|
||||
// Truncate text intelligently
|
||||
const truncateText = (text: string, maxLen: number) => {
|
||||
if (text.length <= maxLen) return text
|
||||
return text.substring(0, maxLen - 1) + '..'
|
||||
}
|
||||
if (text.length <= maxLen) return text;
|
||||
return text.substring(0, maxLen - 1) + '..';
|
||||
};
|
||||
|
||||
// Calculate max text length based on number of sectors
|
||||
const maxTextLength = prizes.length <= 4 ? 12 : prizes.length <= 6 ? 10 : 8
|
||||
const maxTextLength = prizes.length <= 4 ? 12 : prizes.length <= 6 ? 10 : 8;
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-[380px] mx-auto select-none">
|
||||
<div className="relative mx-auto w-full max-w-[380px] select-none">
|
||||
{/* Outer glow effect */}
|
||||
<div
|
||||
className={`absolute inset-[-30px] rounded-full transition-all duration-500 ${
|
||||
isSpinning ? 'opacity-100 scale-105' : 'opacity-60'
|
||||
isSpinning ? 'scale-105 opacity-100' : 'opacity-60'
|
||||
}`}
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(139, 92, 246, 0.4) 0%, rgba(236, 72, 153, 0.2) 40%, transparent 70%)',
|
||||
background:
|
||||
'radial-gradient(circle, rgba(139, 92, 246, 0.4) 0%, rgba(236, 72, 153, 0.2) 40%, transparent 70%)',
|
||||
filter: 'blur(25px)',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Pointer */}
|
||||
<div className="absolute top-[-12px] left-1/2 -translate-x-1/2 z-20">
|
||||
<div className="absolute left-1/2 top-[-12px] z-20 -translate-x-1/2">
|
||||
<div className="relative">
|
||||
<div
|
||||
className={`absolute inset-[-10px] blur-lg transition-opacity ${isSpinning ? 'opacity-100' : 'opacity-70'}`}
|
||||
style={{ background: 'radial-gradient(circle, rgba(251, 191, 36, 0.9) 0%, transparent 60%)' }}
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(251, 191, 36, 0.9) 0%, transparent 60%)',
|
||||
}}
|
||||
/>
|
||||
<svg width="44" height="56" viewBox="0 0 44 56" className="relative drop-shadow-2xl">
|
||||
<defs>
|
||||
@@ -166,7 +175,13 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
<stop offset="100%" stopColor="#D97706" />
|
||||
</linearGradient>
|
||||
<filter id="pointerGlow">
|
||||
<feDropShadow dx="0" dy="2" stdDeviation="3" floodColor="#F59E0B" floodOpacity="0.6"/>
|
||||
<feDropShadow
|
||||
dx="0"
|
||||
dy="2"
|
||||
stdDeviation="3"
|
||||
floodColor="#F59E0B"
|
||||
floodOpacity="0.6"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
<polygon
|
||||
@@ -174,35 +189,35 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
fill="url(#pointerGold)"
|
||||
filter="url(#pointerGlow)"
|
||||
/>
|
||||
<polygon
|
||||
points="22,50 6,16 22,4"
|
||||
fill="rgba(255,255,255,0.3)"
|
||||
/>
|
||||
<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"/>
|
||||
<polygon points="22,50 6,16 22,4" fill="rgba(255,255,255,0.3)" />
|
||||
<circle cx="22" cy="24" r="8" fill="#FEF3C7" />
|
||||
<circle cx="22" cy="24" r="5" fill="#FBBF24" />
|
||||
<circle cx="19" cy="21" r="2" fill="white" opacity="0.8" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Wheel */}
|
||||
<div className="relative aspect-square">
|
||||
<svg viewBox={`0 0 ${size} ${size}`} className="w-full h-full">
|
||||
<svg viewBox={`0 0 ${size} ${size}`} className="h-full w-full">
|
||||
<defs>
|
||||
{/* Sector gradients */}
|
||||
{prizes.map((prize, index) => {
|
||||
const color = getSectorColors(index, prize.color)
|
||||
const color = getSectorColors(index, prize.color);
|
||||
return (
|
||||
<linearGradient
|
||||
key={`grad-${index}`}
|
||||
id={`sectorGrad-${index}`}
|
||||
x1="0%" y1="0%" x2="100%" y2="100%"
|
||||
x1="0%"
|
||||
y1="0%"
|
||||
x2="100%"
|
||||
y2="100%"
|
||||
>
|
||||
<stop offset="0%" stopColor={color} stopOpacity="1" />
|
||||
<stop offset="50%" stopColor={color} stopOpacity="0.85" />
|
||||
<stop offset="100%" stopColor={color} stopOpacity="0.7" />
|
||||
</linearGradient>
|
||||
)
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Outer ring gradient */}
|
||||
@@ -223,7 +238,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
|
||||
{/* Text shadow filter */}
|
||||
<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>
|
||||
</defs>
|
||||
|
||||
@@ -252,10 +267,10 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
|
||||
{/* LED lights on outer ring */}
|
||||
{Array.from({ length: 20 }).map((_, i) => {
|
||||
const angle = (i * 18 - 90) * (Math.PI / 180)
|
||||
const dotX = center + outerRadius * Math.cos(angle)
|
||||
const dotY = center + outerRadius * Math.sin(angle)
|
||||
const isLit = lightPattern[i] ?? (i % 2 === 0)
|
||||
const angle = (i * 18 - 90) * (Math.PI / 180);
|
||||
const dotX = center + outerRadius * Math.cos(angle);
|
||||
const dotY = center + outerRadius * Math.sin(angle);
|
||||
const isLit = lightPattern[i] ?? i % 2 === 0;
|
||||
return (
|
||||
<g key={`led-${i}`}>
|
||||
{isLit && (
|
||||
@@ -277,7 +292,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
strokeWidth="1"
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Rotating wheel group */}
|
||||
@@ -286,9 +301,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
style={{
|
||||
transformOrigin: `${center}px ${center}px`,
|
||||
transform: `rotate(${currentRotation}deg)`,
|
||||
transition: isSpinning
|
||||
? 'transform 5s cubic-bezier(0.15, 0.6, 0.1, 1)'
|
||||
: 'none',
|
||||
transition: isSpinning ? 'transform 5s cubic-bezier(0.15, 0.6, 0.1, 1)' : 'none',
|
||||
}}
|
||||
>
|
||||
{/* Sectors */}
|
||||
@@ -304,11 +317,11 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
|
||||
{/* Sector dividers */}
|
||||
{prizes.map((_, index) => {
|
||||
const angle = (index * sectorAngle - 90) * (Math.PI / 180)
|
||||
const x1 = center + hubRadius * Math.cos(angle)
|
||||
const y1 = center + hubRadius * Math.sin(angle)
|
||||
const x2 = center + prizeRadius * Math.cos(angle)
|
||||
const y2 = center + prizeRadius * Math.sin(angle)
|
||||
const angle = (index * sectorAngle - 90) * (Math.PI / 180);
|
||||
const x1 = center + hubRadius * Math.cos(angle);
|
||||
const y1 = center + hubRadius * Math.sin(angle);
|
||||
const x2 = center + prizeRadius * Math.cos(angle);
|
||||
const y2 = center + prizeRadius * Math.sin(angle);
|
||||
return (
|
||||
<line
|
||||
key={`divider-${index}`}
|
||||
@@ -319,12 +332,12 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
stroke="rgba(255,255,255,0.25)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
)
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Prize content - Emoji */}
|
||||
{prizes.map((prize, index) => {
|
||||
const pos = getEmojiPosition(index)
|
||||
const pos = getEmojiPosition(index);
|
||||
return (
|
||||
<text
|
||||
key={`emoji-${prize.id}`}
|
||||
@@ -332,19 +345,19 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
y={pos.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={prizes.length <= 6 ? "32" : "26"}
|
||||
fontSize={prizes.length <= 6 ? '32' : '26'}
|
||||
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
|
||||
style={{ filter: 'drop-shadow(0 2px 3px rgba(0,0,0,0.5))' }}
|
||||
>
|
||||
{prize.emoji}
|
||||
</text>
|
||||
)
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Prize content - Text */}
|
||||
{prizes.map((prize, index) => {
|
||||
const pos = getTextPosition(index)
|
||||
const displayText = truncateText(prize.display_name, maxTextLength)
|
||||
const pos = getTextPosition(index);
|
||||
const displayText = truncateText(prize.display_name, maxTextLength);
|
||||
return (
|
||||
<text
|
||||
key={`text-${prize.id}`}
|
||||
@@ -352,7 +365,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
y={pos.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={prizes.length <= 4 ? "13" : prizes.length <= 6 ? "11" : "9"}
|
||||
fontSize={prizes.length <= 4 ? '13' : prizes.length <= 6 ? '11' : '9'}
|
||||
fontWeight="700"
|
||||
fill="#FFFFFF"
|
||||
transform={`rotate(${pos.rotation}, ${pos.x}, ${pos.y})`}
|
||||
@@ -361,7 +374,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
>
|
||||
{displayText}
|
||||
</text>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
|
||||
@@ -386,13 +399,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
/>
|
||||
|
||||
{/* Hub shine */}
|
||||
<ellipse
|
||||
cx={center - 10}
|
||||
cy={center - 12}
|
||||
rx={15}
|
||||
ry={10}
|
||||
fill="rgba(255,255,255,0.2)"
|
||||
/>
|
||||
<ellipse cx={center - 10} cy={center - 12} rx={15} ry={10} fill="rgba(255,255,255,0.2)" />
|
||||
|
||||
{/* Center button */}
|
||||
<circle
|
||||
@@ -422,7 +429,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
{/* Spinning overlay glow */}
|
||||
{isSpinning && (
|
||||
<div
|
||||
className="absolute inset-0 rounded-full pointer-events-none"
|
||||
className="pointer-events-none absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(168, 85, 247, 0.25) 0%, transparent 50%)',
|
||||
animation: 'pulse 0.5s ease-in-out infinite',
|
||||
@@ -433,11 +440,11 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
|
||||
{/* Sparkle effects when spinning - optimized with pre-calculated positions */}
|
||||
{isSpinning && (
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden">
|
||||
{SPARKLE_POSITIONS.map((pos, i) => (
|
||||
<div
|
||||
key={`sparkle-${i}`}
|
||||
className="absolute w-2 h-2 bg-yellow-300 rounded-full animate-ping"
|
||||
className="absolute h-2 w-2 animate-ping rounded-full bg-yellow-300"
|
||||
style={{
|
||||
top: pos.top,
|
||||
left: pos.left,
|
||||
@@ -450,7 +457,7 @@ const FortuneWheel = memo(function FortuneWheel({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
export default FortuneWheel
|
||||
export default FortuneWheel;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { ThemeColors } from '../types/theme'
|
||||
import { ThemeColors } from '../types/theme';
|
||||
|
||||
export interface ColorPreset {
|
||||
id: string
|
||||
name: string
|
||||
nameRu: string
|
||||
description: string
|
||||
descriptionRu: string
|
||||
colors: ThemeColors
|
||||
id: string;
|
||||
name: string;
|
||||
nameRu: string;
|
||||
description: string;
|
||||
descriptionRu: string;
|
||||
colors: ThemeColors;
|
||||
preview: {
|
||||
background: string
|
||||
accent: string
|
||||
text: string
|
||||
}
|
||||
background: string;
|
||||
accent: string;
|
||||
text: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const COLOR_PRESETS: ColorPreset[] = [
|
||||
@@ -275,8 +275,8 @@ export const COLOR_PRESETS: ColorPreset[] = [
|
||||
text: '#f1f5f9',
|
||||
},
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
export function getPresetById(id: string): ColorPreset | undefined {
|
||||
return COLOR_PRESETS.find((preset) => preset.id === id)
|
||||
return COLOR_PRESETS.find((preset) => preset.id === id);
|
||||
}
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { brandingApi } from '../api/branding'
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { brandingApi } from '../api/branding';
|
||||
|
||||
const YM_SCRIPT_ID = 'ym-counter-script'
|
||||
const GTAG_LOADER_ID = 'gtag-loader-script'
|
||||
const GTAG_INIT_ID = 'gtag-init-script'
|
||||
const YM_SCRIPT_ID = 'ym-counter-script';
|
||||
const GTAG_LOADER_ID = 'gtag-loader-script';
|
||||
const GTAG_INIT_ID = 'gtag-init-script';
|
||||
|
||||
function removeElement(id: string) {
|
||||
document.getElementById(id)?.remove()
|
||||
document.getElementById(id)?.remove();
|
||||
}
|
||||
|
||||
function injectYandexMetrika(counterId: string) {
|
||||
if (document.getElementById(YM_SCRIPT_ID)) return
|
||||
if (document.getElementById(YM_SCRIPT_ID)) return;
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.id = YM_SCRIPT_ID
|
||||
script.type = 'text/javascript'
|
||||
const script = document.createElement('script');
|
||||
script.id = YM_SCRIPT_ID;
|
||||
script.type = 'text/javascript';
|
||||
script.textContent = `
|
||||
(function(m,e,t,r,i,k,a){m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
|
||||
m[i].l=1*new Date();
|
||||
@@ -26,30 +26,30 @@ function injectYandexMetrika(counterId: string) {
|
||||
trackLinks:true,
|
||||
accurateTrackBounce:true
|
||||
});
|
||||
`
|
||||
document.head.appendChild(script)
|
||||
`;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
function injectGoogleAds(conversionId: string) {
|
||||
if (document.getElementById(GTAG_LOADER_ID)) return
|
||||
if (document.getElementById(GTAG_LOADER_ID)) return;
|
||||
|
||||
// External gtag.js loader
|
||||
const loader = document.createElement('script')
|
||||
loader.id = GTAG_LOADER_ID
|
||||
loader.async = true
|
||||
loader.src = `https://www.googletagmanager.com/gtag/js?id=${conversionId}`
|
||||
document.head.appendChild(loader)
|
||||
const loader = document.createElement('script');
|
||||
loader.id = GTAG_LOADER_ID;
|
||||
loader.async = true;
|
||||
loader.src = `https://www.googletagmanager.com/gtag/js?id=${conversionId}`;
|
||||
document.head.appendChild(loader);
|
||||
|
||||
// Init script
|
||||
const init = document.createElement('script')
|
||||
init.id = GTAG_INIT_ID
|
||||
const init = document.createElement('script');
|
||||
init.id = GTAG_INIT_ID;
|
||||
init.textContent = `
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${conversionId}');
|
||||
`
|
||||
document.head.appendChild(init)
|
||||
`;
|
||||
document.head.appendChild(init);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -61,24 +61,24 @@ export function useAnalyticsCounters() {
|
||||
queryKey: ['analytics-counters'],
|
||||
queryFn: brandingApi.getAnalyticsCounters,
|
||||
staleTime: 5 * 60 * 1000, // 5 min
|
||||
})
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
if (!data) return;
|
||||
|
||||
// Yandex Metrika
|
||||
if (data.yandex_metrika_id) {
|
||||
injectYandexMetrika(data.yandex_metrika_id)
|
||||
injectYandexMetrika(data.yandex_metrika_id);
|
||||
} else {
|
||||
removeElement(YM_SCRIPT_ID)
|
||||
removeElement(YM_SCRIPT_ID);
|
||||
}
|
||||
|
||||
// Google Ads
|
||||
if (data.google_ads_id) {
|
||||
injectGoogleAds(data.google_ads_id)
|
||||
injectGoogleAds(data.google_ads_id);
|
||||
} else {
|
||||
removeElement(GTAG_LOADER_ID)
|
||||
removeElement(GTAG_INIT_ID)
|
||||
removeElement(GTAG_LOADER_ID);
|
||||
removeElement(GTAG_INIT_ID);
|
||||
}
|
||||
}, [data])
|
||||
}, [data]);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { currencyApi, type ExchangeRates } from '../api/currency'
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { currencyApi, type ExchangeRates } from '../api/currency';
|
||||
|
||||
// Map language to currency
|
||||
const LANGUAGE_CURRENCY_MAP: Record<string, keyof ExchangeRates | 'RUB'> = {
|
||||
@@ -8,17 +8,17 @@ const LANGUAGE_CURRENCY_MAP: Record<string, keyof ExchangeRates | 'RUB'> = {
|
||||
en: 'USD',
|
||||
zh: 'CNY',
|
||||
fa: 'IRR',
|
||||
}
|
||||
};
|
||||
|
||||
// Default rates for fallback
|
||||
const DEFAULT_RATES: ExchangeRates = {
|
||||
USD: 100,
|
||||
CNY: 14,
|
||||
IRR: 0.0024,
|
||||
}
|
||||
};
|
||||
|
||||
export function useCurrency() {
|
||||
const { i18n, t } = useTranslation()
|
||||
const { i18n, t } = useTranslation();
|
||||
|
||||
// Fetch exchange rates
|
||||
const { data: exchangeRates = DEFAULT_RATES } = useQuery({
|
||||
@@ -27,72 +27,68 @@ export function useCurrency() {
|
||||
staleTime: 60 * 60 * 1000, // 1 hour
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 2,
|
||||
})
|
||||
});
|
||||
|
||||
// Get current language and currency
|
||||
const currentLanguage = i18n.language
|
||||
const targetCurrency = LANGUAGE_CURRENCY_MAP[currentLanguage] || 'USD'
|
||||
const currentLanguage = i18n.language;
|
||||
const targetCurrency = LANGUAGE_CURRENCY_MAP[currentLanguage] || 'USD';
|
||||
|
||||
// Check if current language is Russian (no conversion needed)
|
||||
const isRussian = currentLanguage === 'ru'
|
||||
const isRussian = currentLanguage === 'ru';
|
||||
|
||||
// Get currency symbol from translations
|
||||
const currencySymbol = t('common.currency')
|
||||
const currencySymbol = t('common.currency');
|
||||
|
||||
// Format amount with currency conversion
|
||||
const formatAmount = (rubAmount: number, decimals: number = 2): string => {
|
||||
if (isRussian) {
|
||||
return rubAmount.toFixed(decimals)
|
||||
return rubAmount.toFixed(decimals);
|
||||
}
|
||||
|
||||
// Convert to target currency
|
||||
const convertedAmount = currencyApi.convertFromRub(
|
||||
rubAmount,
|
||||
targetCurrency as keyof ExchangeRates,
|
||||
exchangeRates
|
||||
)
|
||||
exchangeRates,
|
||||
);
|
||||
|
||||
// For IRR (Iranian Toman), use no decimals as amounts are large
|
||||
if (targetCurrency === 'IRR') {
|
||||
return Math.round(convertedAmount).toLocaleString('fa-IR')
|
||||
return Math.round(convertedAmount).toLocaleString('fa-IR');
|
||||
}
|
||||
|
||||
return convertedAmount.toFixed(decimals)
|
||||
}
|
||||
return convertedAmount.toFixed(decimals);
|
||||
};
|
||||
|
||||
// Format amount with currency symbol
|
||||
const formatWithCurrency = (rubAmount: number, decimals: number = 2): string => {
|
||||
return `${formatAmount(rubAmount, decimals)} ${currencySymbol}`
|
||||
}
|
||||
return `${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
|
||||
};
|
||||
|
||||
// Format amount with + sign (for earnings/bonuses)
|
||||
const formatPositive = (rubAmount: number, decimals: number = 2): string => {
|
||||
return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`
|
||||
}
|
||||
return `+${formatAmount(rubAmount, decimals)} ${currencySymbol}`;
|
||||
};
|
||||
|
||||
// Get raw converted amount (for calculations)
|
||||
const convertAmount = (rubAmount: number): number => {
|
||||
if (isRussian) {
|
||||
return rubAmount
|
||||
return rubAmount;
|
||||
}
|
||||
return currencyApi.convertFromRub(
|
||||
rubAmount,
|
||||
targetCurrency as keyof ExchangeRates,
|
||||
exchangeRates
|
||||
)
|
||||
}
|
||||
exchangeRates,
|
||||
);
|
||||
};
|
||||
|
||||
// Convert from user's currency back to rubles
|
||||
const convertToRub = (amount: number): number => {
|
||||
if (isRussian) {
|
||||
return amount
|
||||
return amount;
|
||||
}
|
||||
return currencyApi.convertToRub(
|
||||
amount,
|
||||
targetCurrency as keyof ExchangeRates,
|
||||
exchangeRates
|
||||
)
|
||||
}
|
||||
return currencyApi.convertToRub(amount, targetCurrency as keyof ExchangeRates, exchangeRates);
|
||||
};
|
||||
|
||||
return {
|
||||
exchangeRates,
|
||||
@@ -104,5 +100,5 @@ export function useCurrency() {
|
||||
formatPositive,
|
||||
convertAmount,
|
||||
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() {
|
||||
const [favorites, setFavorites] = useState<string[]>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
return stored ? JSON.parse(stored) : []
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// Sync to localStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites))
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(favorites));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}, [favorites])
|
||||
}, [favorites]);
|
||||
|
||||
const toggleFavorite = useCallback((settingKey: string) => {
|
||||
setFavorites(prev => {
|
||||
setFavorites((prev) => {
|
||||
if (prev.includes(settingKey)) {
|
||||
return prev.filter(key => key !== settingKey)
|
||||
return prev.filter((key) => key !== settingKey);
|
||||
} else {
|
||||
return [...prev, settingKey]
|
||||
return [...prev, settingKey];
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
});
|
||||
}, []);
|
||||
|
||||
const isFavorite = useCallback((settingKey: string) => {
|
||||
return favorites.includes(settingKey)
|
||||
}, [favorites])
|
||||
const isFavorite = useCallback(
|
||||
(settingKey: string) => {
|
||||
return favorites.includes(settingKey);
|
||||
},
|
||||
[favorites],
|
||||
);
|
||||
|
||||
const clearFavorites = useCallback(() => {
|
||||
setFavorites([])
|
||||
}, [])
|
||||
setFavorites([]);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
favorites,
|
||||
toggleFavorite,
|
||||
isFavorite,
|
||||
clearFavorites,
|
||||
count: favorites.length
|
||||
}
|
||||
count: favorites.length,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
|
||||
interface UsePullToRefreshOptions {
|
||||
onRefresh?: () => void | Promise<void>
|
||||
threshold?: number // How far to pull before triggering refresh (px)
|
||||
disabled?: boolean
|
||||
onRefresh?: () => void | Promise<void>;
|
||||
threshold?: number; // How far to pull before triggering refresh (px)
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function usePullToRefresh({
|
||||
@@ -11,150 +11,152 @@ export function usePullToRefresh({
|
||||
threshold = 80,
|
||||
disabled = false,
|
||||
}: UsePullToRefreshOptions = {}) {
|
||||
const [isPulling, setIsPulling] = useState(false)
|
||||
const [pullDistance, setPullDistance] = useState(0)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [isPulling, setIsPulling] = useState(false);
|
||||
const [pullDistance, setPullDistance] = useState(0);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
const startY = useRef(0)
|
||||
const currentY = useRef(0)
|
||||
const startY = useRef(0);
|
||||
const currentY = useRef(0);
|
||||
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (onRefresh) {
|
||||
setIsRefreshing(true)
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await onRefresh()
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setIsRefreshing(false)
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
} else {
|
||||
// Default: reload the page
|
||||
window.location.reload()
|
||||
window.location.reload();
|
||||
}
|
||||
}, [onRefresh])
|
||||
}, [onRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (disabled) return
|
||||
if (disabled) return;
|
||||
|
||||
// Check if a modal/overlay is currently open
|
||||
const isModalOpen = (): boolean => {
|
||||
// Check if body scroll is locked (common pattern for modals)
|
||||
if (document.body.style.overflow === 'hidden') return true
|
||||
if (document.body.style.overflow === 'hidden') return true;
|
||||
// Check for high z-index fixed elements (modals typically use z-index > 50)
|
||||
const fixedElements = document.querySelectorAll('[style*="position: fixed"], [style*="position:fixed"]')
|
||||
const fixedElements = document.querySelectorAll(
|
||||
'[style*="position: fixed"], [style*="position:fixed"]',
|
||||
);
|
||||
for (const el of fixedElements) {
|
||||
const style = window.getComputedStyle(el)
|
||||
const zIndex = parseInt(style.zIndex, 10)
|
||||
const style = window.getComputedStyle(el);
|
||||
const zIndex = parseInt(style.zIndex, 10);
|
||||
if (zIndex >= 50 && el.clientHeight > window.innerHeight * 0.5) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Check if element or any parent is scrollable and not at top
|
||||
const isInsideScrollableContainer = (element: HTMLElement | null): boolean => {
|
||||
while (element && element !== document.body) {
|
||||
const style = window.getComputedStyle(element)
|
||||
const overflowY = style.overflowY
|
||||
const isScrollable = overflowY === 'auto' || overflowY === 'scroll'
|
||||
const style = window.getComputedStyle(element);
|
||||
const overflowY = style.overflowY;
|
||||
const isScrollable = overflowY === 'auto' || overflowY === 'scroll';
|
||||
|
||||
if (isScrollable && element.scrollHeight > element.clientHeight) {
|
||||
// Element is scrollable - check if it's at the top
|
||||
if (element.scrollTop > 0) {
|
||||
return true // Not at top, don't trigger pull-to-refresh
|
||||
return true; // Not at top, don't trigger pull-to-refresh
|
||||
}
|
||||
}
|
||||
element = element.parentElement
|
||||
element = element.parentElement;
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleTouchStart = (e: TouchEvent) => {
|
||||
// Don't trigger if a modal is open
|
||||
if (isModalOpen()) return
|
||||
if (isModalOpen()) return;
|
||||
|
||||
// Only trigger if at top of page
|
||||
if (window.scrollY > 5) return
|
||||
if (window.scrollY > 5) return;
|
||||
|
||||
// Don't trigger if touch started inside a scrollable container that's not at top
|
||||
const target = e.target as HTMLElement
|
||||
if (isInsideScrollableContainer(target)) return
|
||||
const target = e.target as HTMLElement;
|
||||
if (isInsideScrollableContainer(target)) return;
|
||||
|
||||
startY.current = e.touches[0].clientY
|
||||
currentY.current = e.touches[0].clientY
|
||||
}
|
||||
startY.current = e.touches[0].clientY;
|
||||
currentY.current = e.touches[0].clientY;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (startY.current === 0) return
|
||||
if (startY.current === 0) return;
|
||||
|
||||
// Cancel if modal opened during gesture
|
||||
if (isModalOpen()) {
|
||||
startY.current = 0
|
||||
setPullDistance(0)
|
||||
setIsPulling(false)
|
||||
return
|
||||
startY.current = 0;
|
||||
setPullDistance(0);
|
||||
setIsPulling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.scrollY > 5) {
|
||||
// User scrolled down, reset
|
||||
startY.current = 0
|
||||
setPullDistance(0)
|
||||
setIsPulling(false)
|
||||
return
|
||||
startY.current = 0;
|
||||
setPullDistance(0);
|
||||
setIsPulling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Also check during move - user might start at top then scroll inside container
|
||||
const target = e.target as HTMLElement
|
||||
const target = e.target as HTMLElement;
|
||||
if (isInsideScrollableContainer(target)) {
|
||||
startY.current = 0
|
||||
setPullDistance(0)
|
||||
setIsPulling(false)
|
||||
return
|
||||
startY.current = 0;
|
||||
setPullDistance(0);
|
||||
setIsPulling(false);
|
||||
return;
|
||||
}
|
||||
|
||||
currentY.current = e.touches[0].clientY
|
||||
const diff = currentY.current - startY.current
|
||||
currentY.current = e.touches[0].clientY;
|
||||
const diff = currentY.current - startY.current;
|
||||
|
||||
// Only track downward pulls
|
||||
if (diff > 0) {
|
||||
// Apply resistance - pull distance is less than actual finger movement
|
||||
const resistance = 0.4
|
||||
const distance = Math.min(diff * resistance, threshold * 1.5)
|
||||
setPullDistance(distance)
|
||||
setIsPulling(true)
|
||||
const resistance = 0.4;
|
||||
const distance = Math.min(diff * resistance, threshold * 1.5);
|
||||
setPullDistance(distance);
|
||||
setIsPulling(true);
|
||||
|
||||
// Prevent default scroll if we're pulling
|
||||
if (distance > 10) {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
if (pullDistance >= threshold && !isRefreshing) {
|
||||
handleRefresh()
|
||||
handleRefresh();
|
||||
}
|
||||
|
||||
startY.current = 0
|
||||
setPullDistance(0)
|
||||
setIsPulling(false)
|
||||
}
|
||||
startY.current = 0;
|
||||
setPullDistance(0);
|
||||
setIsPulling(false);
|
||||
};
|
||||
|
||||
document.addEventListener('touchstart', handleTouchStart, { passive: true })
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false })
|
||||
document.addEventListener('touchend', handleTouchEnd, { passive: true })
|
||||
document.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('touchstart', handleTouchStart)
|
||||
document.removeEventListener('touchmove', handleTouchMove)
|
||||
document.removeEventListener('touchend', handleTouchEnd)
|
||||
}
|
||||
}, [disabled, threshold, pullDistance, isRefreshing, handleRefresh])
|
||||
document.removeEventListener('touchstart', handleTouchStart);
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
};
|
||||
}, [disabled, threshold, pullDistance, isRefreshing, handleRefresh]);
|
||||
|
||||
return {
|
||||
isPulling,
|
||||
pullDistance,
|
||||
isRefreshing,
|
||||
progress: Math.min(pullDistance / threshold, 1),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled'
|
||||
const FULLSCREEN_CACHE_KEY = 'cabinet_fullscreen_enabled';
|
||||
|
||||
// Get cached fullscreen setting
|
||||
export const getCachedFullscreenEnabled = (): boolean => {
|
||||
try {
|
||||
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true'
|
||||
return localStorage.getItem(FULLSCREEN_CACHE_KEY) === 'true';
|
||||
} catch {
|
||||
return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Set cached fullscreen setting
|
||||
export const setCachedFullscreenEnabled = (enabled: boolean) => {
|
||||
try {
|
||||
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled))
|
||||
localStorage.setItem(FULLSCREEN_CACHE_KEY, String(enabled));
|
||||
} catch {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for Telegram WebApp API integration
|
||||
@@ -26,102 +26,112 @@ export const setCachedFullscreenEnabled = (enabled: boolean) => {
|
||||
*/
|
||||
export function useTelegramWebApp() {
|
||||
// Initialize synchronously to avoid flash/flicker on first render
|
||||
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined
|
||||
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined;
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = useState(() => webApp?.isFullscreen || false)
|
||||
const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => !!webApp)
|
||||
const [safeAreaInset, setSafeAreaInset] = useState(() => webApp?.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
const [contentSafeAreaInset, setContentSafeAreaInset] = useState(() => webApp?.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
const [isFullscreen, setIsFullscreen] = useState(() => webApp?.isFullscreen || false);
|
||||
const [isTelegramWebApp, setIsTelegramWebApp] = useState(() => !!webApp);
|
||||
const [safeAreaInset, setSafeAreaInset] = useState(
|
||||
() => webApp?.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
);
|
||||
const [contentSafeAreaInset, setContentSafeAreaInset] = useState(
|
||||
() => webApp?.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!webApp) {
|
||||
setIsTelegramWebApp(false)
|
||||
return
|
||||
setIsTelegramWebApp(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsTelegramWebApp(true)
|
||||
setIsFullscreen(webApp.isFullscreen || false)
|
||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
setIsTelegramWebApp(true);
|
||||
setIsFullscreen(webApp.isFullscreen || false);
|
||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
|
||||
setContentSafeAreaInset(
|
||||
webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
);
|
||||
|
||||
// Expand WebApp to full height
|
||||
webApp.expand()
|
||||
webApp.ready()
|
||||
webApp.expand();
|
||||
webApp.ready();
|
||||
|
||||
// Listen for fullscreen changes
|
||||
const handleFullscreenChanged = () => {
|
||||
setIsFullscreen(webApp.isFullscreen || false)
|
||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
}
|
||||
setIsFullscreen(webApp.isFullscreen || false);
|
||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
|
||||
setContentSafeAreaInset(
|
||||
webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
);
|
||||
};
|
||||
|
||||
// Listen for safe area changes
|
||||
const handleSafeAreaChanged = () => {
|
||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
setContentSafeAreaInset(webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||
}
|
||||
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 });
|
||||
setContentSafeAreaInset(
|
||||
webApp.contentSafeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 },
|
||||
);
|
||||
};
|
||||
|
||||
webApp.onEvent('fullscreenChanged', handleFullscreenChanged)
|
||||
webApp.onEvent('safeAreaChanged', handleSafeAreaChanged)
|
||||
webApp.onEvent('contentSafeAreaChanged', handleSafeAreaChanged)
|
||||
webApp.onEvent('fullscreenChanged', handleFullscreenChanged);
|
||||
webApp.onEvent('safeAreaChanged', handleSafeAreaChanged);
|
||||
webApp.onEvent('contentSafeAreaChanged', handleSafeAreaChanged);
|
||||
|
||||
return () => {
|
||||
webApp.offEvent('fullscreenChanged', handleFullscreenChanged)
|
||||
webApp.offEvent('safeAreaChanged', handleSafeAreaChanged)
|
||||
webApp.offEvent('contentSafeAreaChanged', handleSafeAreaChanged)
|
||||
}
|
||||
}, [webApp])
|
||||
webApp.offEvent('fullscreenChanged', handleFullscreenChanged);
|
||||
webApp.offEvent('safeAreaChanged', handleSafeAreaChanged);
|
||||
webApp.offEvent('contentSafeAreaChanged', handleSafeAreaChanged);
|
||||
};
|
||||
}, [webApp]);
|
||||
|
||||
const requestFullscreen = useCallback(() => {
|
||||
if (webApp?.requestFullscreen) {
|
||||
try {
|
||||
webApp.requestFullscreen()
|
||||
webApp.requestFullscreen();
|
||||
} catch (e) {
|
||||
console.warn('Fullscreen not supported:', e)
|
||||
console.warn('Fullscreen not supported:', e);
|
||||
}
|
||||
}
|
||||
}, [webApp])
|
||||
}, [webApp]);
|
||||
|
||||
const exitFullscreen = useCallback(() => {
|
||||
if (webApp?.exitFullscreen) {
|
||||
try {
|
||||
webApp.exitFullscreen()
|
||||
webApp.exitFullscreen();
|
||||
} catch (e) {
|
||||
console.warn('Exit fullscreen failed:', e)
|
||||
console.warn('Exit fullscreen failed:', e);
|
||||
}
|
||||
}
|
||||
}, [webApp])
|
||||
}, [webApp]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
if (isFullscreen) {
|
||||
exitFullscreen()
|
||||
exitFullscreen();
|
||||
} else {
|
||||
requestFullscreen()
|
||||
requestFullscreen();
|
||||
}
|
||||
}, [isFullscreen, requestFullscreen, exitFullscreen])
|
||||
}, [isFullscreen, requestFullscreen, exitFullscreen]);
|
||||
|
||||
const disableVerticalSwipes = useCallback(() => {
|
||||
try {
|
||||
if (webApp?.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
||||
webApp.disableVerticalSwipes()
|
||||
webApp.disableVerticalSwipes();
|
||||
}
|
||||
} catch {
|
||||
// Not supported in this version
|
||||
}
|
||||
}, [webApp])
|
||||
}, [webApp]);
|
||||
|
||||
const enableVerticalSwipes = useCallback(() => {
|
||||
try {
|
||||
if (webApp?.enableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
||||
webApp.enableVerticalSwipes()
|
||||
webApp.enableVerticalSwipes();
|
||||
}
|
||||
} catch {
|
||||
// Not supported in this version
|
||||
}
|
||||
}, [webApp])
|
||||
}, [webApp]);
|
||||
|
||||
// Check if fullscreen is supported (Bot API 8.0+)
|
||||
const isFullscreenSupported = Boolean(webApp?.requestFullscreen)
|
||||
const isFullscreenSupported = Boolean(webApp?.requestFullscreen);
|
||||
|
||||
return {
|
||||
isTelegramWebApp,
|
||||
@@ -135,7 +145,7 @@ export function useTelegramWebApp() {
|
||||
disableVerticalSwipes,
|
||||
enableVerticalSwipes,
|
||||
webApp,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,27 +153,27 @@ export function useTelegramWebApp() {
|
||||
* Call this in main.tsx or App.tsx
|
||||
*/
|
||||
export function initTelegramWebApp() {
|
||||
const webApp = window.Telegram?.WebApp
|
||||
const webApp = window.Telegram?.WebApp;
|
||||
if (webApp) {
|
||||
webApp.ready()
|
||||
webApp.expand()
|
||||
webApp.ready();
|
||||
webApp.expand();
|
||||
|
||||
// Disable vertical swipes to prevent accidental closing (requires Bot API 7.7+)
|
||||
try {
|
||||
if (webApp.disableVerticalSwipes && webApp.version && webApp.version >= '7.7') {
|
||||
webApp.disableVerticalSwipes()
|
||||
webApp.disableVerticalSwipes();
|
||||
}
|
||||
} catch {
|
||||
// Swipe control not supported in this version
|
||||
}
|
||||
|
||||
// Auto-enter fullscreen if enabled in settings (use cached value for instant response)
|
||||
const fullscreenEnabled = getCachedFullscreenEnabled()
|
||||
const fullscreenEnabled = getCachedFullscreenEnabled();
|
||||
if (fullscreenEnabled && webApp.requestFullscreen && !webApp.isFullscreen) {
|
||||
try {
|
||||
webApp.requestFullscreen()
|
||||
webApp.requestFullscreen();
|
||||
} catch (e) {
|
||||
console.warn('Auto-fullscreen failed:', e)
|
||||
console.warn('Auto-fullscreen failed:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,214 +1,224 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme'
|
||||
import { themeColorsApi } from '../api/themeColors'
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { EnabledThemes, DEFAULT_ENABLED_THEMES } from '../types/theme';
|
||||
import { themeColorsApi } from '../api/themeColors';
|
||||
|
||||
type Theme = 'dark' | 'light'
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
const THEME_KEY = 'cabinet-theme'
|
||||
const ENABLED_THEMES_KEY = 'cabinet-enabled-themes'
|
||||
const THEME_KEY = 'cabinet-theme';
|
||||
const ENABLED_THEMES_KEY = 'cabinet-enabled-themes';
|
||||
|
||||
// Fetch enabled themes from API
|
||||
async function fetchEnabledThemes(): Promise<EnabledThemes> {
|
||||
try {
|
||||
const data = await themeColorsApi.getEnabledThemes()
|
||||
const data = await themeColorsApi.getEnabledThemes();
|
||||
// Cache in localStorage for faster subsequent loads
|
||||
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(data))
|
||||
return data
|
||||
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(data));
|
||||
return data;
|
||||
} catch {
|
||||
// Ignore errors, use cached or default
|
||||
}
|
||||
// Try to get from cache
|
||||
const cached = localStorage.getItem(ENABLED_THEMES_KEY)
|
||||
const cached = localStorage.getItem(ENABLED_THEMES_KEY);
|
||||
if (cached) {
|
||||
try {
|
||||
return JSON.parse(cached)
|
||||
return JSON.parse(cached);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
return DEFAULT_ENABLED_THEMES
|
||||
return DEFAULT_ENABLED_THEMES;
|
||||
}
|
||||
|
||||
// Get cached enabled themes synchronously
|
||||
function getCachedEnabledThemes(): EnabledThemes {
|
||||
const cached = localStorage.getItem(ENABLED_THEMES_KEY)
|
||||
const cached = localStorage.getItem(ENABLED_THEMES_KEY);
|
||||
if (cached) {
|
||||
try {
|
||||
return JSON.parse(cached)
|
||||
return JSON.parse(cached);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
return DEFAULT_ENABLED_THEMES
|
||||
return DEFAULT_ENABLED_THEMES;
|
||||
}
|
||||
|
||||
// Custom event for same-tab updates
|
||||
const ENABLED_THEMES_CHANGED_EVENT = 'enabledThemesChanged'
|
||||
const ENABLED_THEMES_CHANGED_EVENT = 'enabledThemesChanged';
|
||||
|
||||
// Update cache (called from admin settings)
|
||||
export function updateEnabledThemesCache(themes: EnabledThemes) {
|
||||
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(themes))
|
||||
localStorage.setItem(ENABLED_THEMES_KEY, JSON.stringify(themes));
|
||||
// Dispatch custom event for same-tab updates
|
||||
window.dispatchEvent(new CustomEvent(ENABLED_THEMES_CHANGED_EVENT, { detail: themes }))
|
||||
window.dispatchEvent(new CustomEvent(ENABLED_THEMES_CHANGED_EVENT, { detail: themes }));
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [enabledThemes, setEnabledThemes] = useState<EnabledThemes>(getCachedEnabledThemes)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [enabledThemes, setEnabledThemes] = useState<EnabledThemes>(getCachedEnabledThemes);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
const enabled = getCachedEnabledThemes()
|
||||
const enabled = getCachedEnabledThemes();
|
||||
|
||||
// Check localStorage first
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem(THEME_KEY) as Theme | null
|
||||
const stored = localStorage.getItem(THEME_KEY) as Theme | null;
|
||||
if (stored === 'light' && enabled.light) {
|
||||
return 'light'
|
||||
return 'light';
|
||||
}
|
||||
if (stored === 'dark' && enabled.dark) {
|
||||
return 'dark'
|
||||
return 'dark';
|
||||
}
|
||||
// If stored theme is disabled, use the enabled one
|
||||
if (stored && !enabled[stored]) {
|
||||
return enabled.dark ? 'dark' : 'light'
|
||||
return enabled.dark ? 'dark' : 'light';
|
||||
}
|
||||
// Check system preference
|
||||
if (window.matchMedia('(prefers-color-scheme: light)').matches && enabled.light) {
|
||||
return 'light'
|
||||
return 'light';
|
||||
}
|
||||
}
|
||||
// Default to dark if enabled, otherwise light
|
||||
return enabled.dark ? 'dark' : 'light'
|
||||
})
|
||||
return enabled.dark ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
// Fetch enabled themes on mount
|
||||
useEffect(() => {
|
||||
fetchEnabledThemes().then((data) => {
|
||||
setEnabledThemes(data)
|
||||
setIsLoading(false)
|
||||
setEnabledThemes(data);
|
||||
setIsLoading(false);
|
||||
// If current theme is disabled, switch to enabled one
|
||||
if (!data[theme]) {
|
||||
const newTheme = data.dark ? 'dark' : 'light'
|
||||
setThemeState(newTheme)
|
||||
const newTheme = data.dark ? 'dark' : 'light';
|
||||
setThemeState(newTheme);
|
||||
}
|
||||
})
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Listen for localStorage changes (when admin updates enabled themes from other tabs)
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
if (e.key === ENABLED_THEMES_KEY && e.newValue) {
|
||||
try {
|
||||
const data = JSON.parse(e.newValue) as EnabledThemes
|
||||
setEnabledThemes(data)
|
||||
const data = JSON.parse(e.newValue) as EnabledThemes;
|
||||
setEnabledThemes(data);
|
||||
// If current theme is now disabled, switch to enabled one
|
||||
if (!data[theme]) {
|
||||
const newTheme = data.dark ? 'dark' : 'light'
|
||||
setThemeState(newTheme)
|
||||
const newTheme = data.dark ? 'dark' : 'light';
|
||||
setThemeState(newTheme);
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange)
|
||||
return () => window.removeEventListener('storage', handleStorageChange)
|
||||
}, [theme])
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme]);
|
||||
|
||||
// Listen for same-tab enabled themes changes (from admin settings)
|
||||
useEffect(() => {
|
||||
const handleEnabledThemesChange = (e: CustomEvent<EnabledThemes>) => {
|
||||
const data = e.detail
|
||||
setEnabledThemes(data)
|
||||
const data = e.detail;
|
||||
setEnabledThemes(data);
|
||||
// If current theme is now disabled, switch to enabled one
|
||||
if (!data[theme]) {
|
||||
const newTheme = data.dark ? 'dark' : 'light'
|
||||
setThemeState(newTheme)
|
||||
const newTheme = data.dark ? 'dark' : 'light';
|
||||
setThemeState(newTheme);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(ENABLED_THEMES_CHANGED_EVENT, handleEnabledThemesChange as EventListener)
|
||||
return () => window.removeEventListener(ENABLED_THEMES_CHANGED_EVENT, handleEnabledThemesChange as EventListener)
|
||||
}, [theme])
|
||||
window.addEventListener(
|
||||
ENABLED_THEMES_CHANGED_EVENT,
|
||||
handleEnabledThemesChange as EventListener,
|
||||
);
|
||||
return () =>
|
||||
window.removeEventListener(
|
||||
ENABLED_THEMES_CHANGED_EVENT,
|
||||
handleEnabledThemesChange as EventListener,
|
||||
);
|
||||
}, [theme]);
|
||||
|
||||
// Apply theme to document - also check if theme is disabled and switch
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
const root = document.documentElement;
|
||||
|
||||
// If current theme is disabled, switch to the enabled one
|
||||
if (!enabledThemes[theme]) {
|
||||
const newTheme = enabledThemes.dark ? 'dark' : 'light'
|
||||
const newTheme = enabledThemes.dark ? 'dark' : 'light';
|
||||
if (newTheme !== theme) {
|
||||
setThemeState(newTheme)
|
||||
return // Will re-run with correct theme
|
||||
setThemeState(newTheme);
|
||||
return; // Will re-run with correct theme
|
||||
}
|
||||
}
|
||||
|
||||
if (theme === 'light') {
|
||||
root.classList.remove('dark')
|
||||
root.classList.add('light')
|
||||
root.classList.remove('dark');
|
||||
root.classList.add('light');
|
||||
} else {
|
||||
root.classList.remove('light')
|
||||
root.classList.add('dark')
|
||||
root.classList.remove('light');
|
||||
root.classList.add('dark');
|
||||
}
|
||||
|
||||
localStorage.setItem(THEME_KEY, theme)
|
||||
}, [theme, enabledThemes])
|
||||
localStorage.setItem(THEME_KEY, theme);
|
||||
}, [theme, enabledThemes]);
|
||||
|
||||
// Listen for system theme changes
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)')
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: light)');
|
||||
|
||||
const handleChange = (e: MediaQueryListEvent) => {
|
||||
const stored = localStorage.getItem(THEME_KEY)
|
||||
const stored = localStorage.getItem(THEME_KEY);
|
||||
// Only auto-switch if user hasn't set a preference and theme is enabled
|
||||
if (!stored) {
|
||||
const newTheme = e.matches ? 'light' : 'dark'
|
||||
const newTheme = e.matches ? 'light' : 'dark';
|
||||
if (enabledThemes[newTheme]) {
|
||||
setThemeState(newTheme)
|
||||
setThemeState(newTheme);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange)
|
||||
return () => mediaQuery.removeEventListener('change', handleChange)
|
||||
}, [enabledThemes])
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [enabledThemes]);
|
||||
|
||||
const setTheme = useCallback((newTheme: Theme) => {
|
||||
// Only allow setting if theme is enabled
|
||||
if (enabledThemes[newTheme]) {
|
||||
setThemeState(newTheme)
|
||||
}
|
||||
}, [enabledThemes])
|
||||
const setTheme = useCallback(
|
||||
(newTheme: Theme) => {
|
||||
// Only allow setting if theme is enabled
|
||||
if (enabledThemes[newTheme]) {
|
||||
setThemeState(newTheme);
|
||||
}
|
||||
},
|
||||
[enabledThemes],
|
||||
);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setThemeState((prev) => {
|
||||
const newTheme = prev === 'dark' ? 'light' : 'dark'
|
||||
const newTheme = prev === 'dark' ? 'light' : 'dark';
|
||||
// Only toggle if the new theme is enabled
|
||||
if (enabledThemes[newTheme]) {
|
||||
return newTheme
|
||||
return newTheme;
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}, [enabledThemes])
|
||||
return prev;
|
||||
});
|
||||
}, [enabledThemes]);
|
||||
|
||||
const isDark = theme === 'dark'
|
||||
const isLight = theme === 'light'
|
||||
const isDark = theme === 'dark';
|
||||
const isLight = theme === 'light';
|
||||
|
||||
// Check if theme switching is available (both themes enabled and loaded)
|
||||
const canToggle = !isLoading && enabledThemes.dark && enabledThemes.light
|
||||
const canToggle = !isLoading && enabledThemes.dark && enabledThemes.light;
|
||||
|
||||
// Refresh enabled themes from API
|
||||
const refreshEnabledThemes = useCallback(() => {
|
||||
fetchEnabledThemes().then((data) => {
|
||||
setEnabledThemes(data)
|
||||
setEnabledThemes(data);
|
||||
if (!data[theme]) {
|
||||
const newTheme = data.dark ? 'dark' : 'light'
|
||||
setThemeState(newTheme)
|
||||
const newTheme = data.dark ? 'dark' : 'light';
|
||||
setThemeState(newTheme);
|
||||
}
|
||||
})
|
||||
}, [theme])
|
||||
});
|
||||
}, [theme]);
|
||||
|
||||
return {
|
||||
theme,
|
||||
@@ -220,5 +230,5 @@ export function useTheme() {
|
||||
canToggle,
|
||||
isLoading,
|
||||
refreshEnabledThemes,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,76 +1,76 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { themeColorsApi } from '../api/themeColors'
|
||||
import { ThemeColors, DEFAULT_THEME_COLORS, SHADE_LEVELS, ColorPalette } from '../types/theme'
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { themeColorsApi } from '../api/themeColors';
|
||||
import { ThemeColors, DEFAULT_THEME_COLORS, SHADE_LEVELS, ColorPalette } from '../types/theme';
|
||||
|
||||
// Convert hex to RGB values
|
||||
function hexToRgb(hex: string): { r: number; g: number; b: number } {
|
||||
// Handle shorthand hex
|
||||
if (hex.length === 4) {
|
||||
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]
|
||||
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];
|
||||
}
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return { r, g, b }
|
||||
const r = parseInt(hex.slice(1, 3), 16);
|
||||
const g = parseInt(hex.slice(3, 5), 16);
|
||||
const b = parseInt(hex.slice(5, 7), 16);
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
// Convert hex to HSL
|
||||
function hexToHsl(hex: string): { h: number; s: number; l: number } {
|
||||
const { r, g, b } = hexToRgb(hex)
|
||||
const rNorm = r / 255
|
||||
const gNorm = g / 255
|
||||
const bNorm = b / 255
|
||||
const { r, g, b } = hexToRgb(hex);
|
||||
const rNorm = r / 255;
|
||||
const gNorm = g / 255;
|
||||
const bNorm = b / 255;
|
||||
|
||||
const max = Math.max(rNorm, gNorm, bNorm)
|
||||
const min = Math.min(rNorm, gNorm, bNorm)
|
||||
let h = 0
|
||||
let s = 0
|
||||
const l = (max + min) / 2
|
||||
const max = Math.max(rNorm, gNorm, bNorm);
|
||||
const min = Math.min(rNorm, gNorm, bNorm);
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
const l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
switch (max) {
|
||||
case rNorm:
|
||||
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6
|
||||
break
|
||||
h = ((gNorm - bNorm) / d + (gNorm < bNorm ? 6 : 0)) / 6;
|
||||
break;
|
||||
case gNorm:
|
||||
h = ((bNorm - rNorm) / d + 2) / 6
|
||||
break
|
||||
h = ((bNorm - rNorm) / d + 2) / 6;
|
||||
break;
|
||||
case bNorm:
|
||||
h = ((rNorm - gNorm) / d + 4) / 6
|
||||
break
|
||||
h = ((rNorm - gNorm) / d + 4) / 6;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { h: h * 360, s: s * 100, l: l * 100 }
|
||||
return { h: h * 360, s: s * 100, l: l * 100 };
|
||||
}
|
||||
|
||||
// Convert HSL to RGB values
|
||||
function hslToRgb(h: number, s: number, l: number): { r: number; g: number; b: number } {
|
||||
s /= 100
|
||||
l /= 100
|
||||
s /= 100;
|
||||
l /= 100;
|
||||
|
||||
const a = s * Math.min(l, 1 - l)
|
||||
const a = s * Math.min(l, 1 - l);
|
||||
const f = (n: number) => {
|
||||
const k = (n + h / 30) % 12
|
||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1)
|
||||
return Math.round(255 * color)
|
||||
}
|
||||
const k = (n + h / 30) % 12;
|
||||
const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
|
||||
return Math.round(255 * color);
|
||||
};
|
||||
|
||||
return { r: f(0), g: f(8), b: f(4) }
|
||||
return { r: f(0), g: f(8), b: f(4) };
|
||||
}
|
||||
|
||||
// Convert RGB to string format for CSS variable
|
||||
function rgbToString(r: number, g: number, b: number): string {
|
||||
return `${r}, ${g}, ${b}`
|
||||
return `${r}, ${g}, ${b}`;
|
||||
}
|
||||
|
||||
// Generate color palette from base color (returns RGB strings)
|
||||
function generatePalette(baseHex: string): ColorPalette {
|
||||
const { h, s } = hexToHsl(baseHex)
|
||||
const { h, s } = hexToHsl(baseHex);
|
||||
|
||||
// Lightness values for each shade level (from light to dark)
|
||||
const lightnessMap: Record<number, number> = {
|
||||
@@ -85,118 +85,151 @@ function generatePalette(baseHex: string): ColorPalette {
|
||||
800: 26,
|
||||
900: 18,
|
||||
950: 10,
|
||||
}
|
||||
};
|
||||
|
||||
const palette: Partial<ColorPalette> = {}
|
||||
const palette: Partial<ColorPalette> = {};
|
||||
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
const lightness = lightnessMap[shade]
|
||||
const lightness = lightnessMap[shade];
|
||||
// Adjust saturation slightly for very light/dark shades
|
||||
const adjustedS = shade <= 100 ? s * 0.7 : shade >= 900 ? s * 0.8 : s
|
||||
const { r, g, b } = hslToRgb(h, adjustedS, lightness)
|
||||
palette[shade] = rgbToString(r, g, b)
|
||||
const adjustedS = shade <= 100 ? s * 0.7 : shade >= 900 ? s * 0.8 : s;
|
||||
const { r, g, b } = hslToRgb(h, adjustedS, lightness);
|
||||
palette[shade] = rgbToString(r, g, b);
|
||||
}
|
||||
|
||||
return palette as ColorPalette
|
||||
return palette as ColorPalette;
|
||||
}
|
||||
|
||||
// Interpolate between two RGB colors
|
||||
function interpolateRgb(
|
||||
rgb1: { r: number; g: number; b: number },
|
||||
rgb2: { r: number; g: number; b: number },
|
||||
factor: number
|
||||
factor: number,
|
||||
): string {
|
||||
return rgbToString(
|
||||
Math.round(rgb1.r + (rgb2.r - rgb1.r) * factor),
|
||||
Math.round(rgb1.g + (rgb2.g - rgb1.g) * factor),
|
||||
Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor)
|
||||
)
|
||||
Math.round(rgb1.b + (rgb2.b - rgb1.b) * factor),
|
||||
);
|
||||
}
|
||||
|
||||
// Apply theme colors as CSS variables (RGB format for Tailwind opacity support)
|
||||
export function applyThemeColors(colors: ThemeColors): void {
|
||||
const root = document.documentElement
|
||||
const root = document.documentElement;
|
||||
|
||||
// Generate palettes from status colors
|
||||
const accentPalette = generatePalette(colors.accent)
|
||||
const successPalette = generatePalette(colors.success)
|
||||
const warningPalette = generatePalette(colors.warning)
|
||||
const errorPalette = generatePalette(colors.error)
|
||||
const accentPalette = generatePalette(colors.accent);
|
||||
const successPalette = generatePalette(colors.success);
|
||||
const warningPalette = generatePalette(colors.warning);
|
||||
const errorPalette = generatePalette(colors.error);
|
||||
|
||||
// === DARK THEME PALETTE ===
|
||||
// Convert hex colors to RGB
|
||||
const darkBgRgb = hexToRgb(colors.darkBackground)
|
||||
const darkSurfaceRgb = hexToRgb(colors.darkSurface)
|
||||
const darkTextRgb = hexToRgb(colors.darkText)
|
||||
const darkTextSecRgb = hexToRgb(colors.darkTextSecondary)
|
||||
const darkBgRgb = hexToRgb(colors.darkBackground);
|
||||
const darkSurfaceRgb = hexToRgb(colors.darkSurface);
|
||||
const darkTextRgb = hexToRgb(colors.darkText);
|
||||
const darkTextSecRgb = hexToRgb(colors.darkTextSecondary);
|
||||
|
||||
// Apply dark palette with actual user colors:
|
||||
// Text colors (light shades): 50-100 = primary text, 200-300 = mixed, 400 = secondary text
|
||||
root.style.setProperty('--color-dark-50', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b))
|
||||
root.style.setProperty('--color-dark-100', rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b))
|
||||
root.style.setProperty('--color-dark-200', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.33))
|
||||
root.style.setProperty('--color-dark-300', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.66))
|
||||
root.style.setProperty('--color-dark-400', rgbToString(darkTextSecRgb.r, darkTextSecRgb.g, darkTextSecRgb.b))
|
||||
root.style.setProperty(
|
||||
'--color-dark-50',
|
||||
rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b),
|
||||
);
|
||||
root.style.setProperty(
|
||||
'--color-dark-100',
|
||||
rgbToString(darkTextRgb.r, darkTextRgb.g, darkTextRgb.b),
|
||||
);
|
||||
root.style.setProperty('--color-dark-200', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.33));
|
||||
root.style.setProperty('--color-dark-300', interpolateRgb(darkTextRgb, darkTextSecRgb, 0.66));
|
||||
root.style.setProperty(
|
||||
'--color-dark-400',
|
||||
rgbToString(darkTextSecRgb.r, darkTextSecRgb.g, darkTextSecRgb.b),
|
||||
);
|
||||
|
||||
// Transition colors (500-700): interpolate between secondary text and surface
|
||||
root.style.setProperty('--color-dark-500', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.4))
|
||||
root.style.setProperty('--color-dark-600', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.6))
|
||||
root.style.setProperty('--color-dark-700', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.8))
|
||||
root.style.setProperty('--color-dark-500', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.4));
|
||||
root.style.setProperty('--color-dark-600', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.6));
|
||||
root.style.setProperty('--color-dark-700', interpolateRgb(darkTextSecRgb, darkSurfaceRgb, 0.8));
|
||||
|
||||
// Surface/card colors (800-850): surface color
|
||||
root.style.setProperty('--color-dark-800', rgbToString(darkSurfaceRgb.r, darkSurfaceRgb.g, darkSurfaceRgb.b))
|
||||
root.style.setProperty('--color-dark-850', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.5))
|
||||
root.style.setProperty(
|
||||
'--color-dark-800',
|
||||
rgbToString(darkSurfaceRgb.r, darkSurfaceRgb.g, darkSurfaceRgb.b),
|
||||
);
|
||||
root.style.setProperty('--color-dark-850', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.5));
|
||||
|
||||
// Background colors (900-950): background color
|
||||
root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7))
|
||||
root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b))
|
||||
root.style.setProperty('--color-dark-900', interpolateRgb(darkSurfaceRgb, darkBgRgb, 0.7));
|
||||
root.style.setProperty('--color-dark-950', rgbToString(darkBgRgb.r, darkBgRgb.g, darkBgRgb.b));
|
||||
|
||||
// === LIGHT THEME PALETTE ===
|
||||
const lightBgRgb = hexToRgb(colors.lightBackground)
|
||||
const lightSurfaceRgb = hexToRgb(colors.lightSurface)
|
||||
const lightTextRgb = hexToRgb(colors.lightText)
|
||||
const lightTextSecRgb = hexToRgb(colors.lightTextSecondary)
|
||||
const lightBgRgb = hexToRgb(colors.lightBackground);
|
||||
const lightSurfaceRgb = hexToRgb(colors.lightSurface);
|
||||
const lightTextRgb = hexToRgb(colors.lightText);
|
||||
const lightTextSecRgb = hexToRgb(colors.lightTextSecondary);
|
||||
|
||||
// Apply champagne palette with actual user colors:
|
||||
// Background colors (light shades): 50-100 = surface, 200-400 = background tones
|
||||
root.style.setProperty('--color-champagne-50', rgbToString(lightSurfaceRgb.r, lightSurfaceRgb.g, lightSurfaceRgb.b))
|
||||
root.style.setProperty('--color-champagne-100', interpolateRgb(lightSurfaceRgb, lightBgRgb, 0.3))
|
||||
root.style.setProperty('--color-champagne-200', rgbToString(lightBgRgb.r, lightBgRgb.g, lightBgRgb.b))
|
||||
root.style.setProperty('--color-champagne-300', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.2))
|
||||
root.style.setProperty('--color-champagne-400', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.4))
|
||||
root.style.setProperty(
|
||||
'--color-champagne-50',
|
||||
rgbToString(lightSurfaceRgb.r, lightSurfaceRgb.g, lightSurfaceRgb.b),
|
||||
);
|
||||
root.style.setProperty('--color-champagne-100', interpolateRgb(lightSurfaceRgb, lightBgRgb, 0.3));
|
||||
root.style.setProperty(
|
||||
'--color-champagne-200',
|
||||
rgbToString(lightBgRgb.r, lightBgRgb.g, lightBgRgb.b),
|
||||
);
|
||||
root.style.setProperty('--color-champagne-300', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.2));
|
||||
root.style.setProperty('--color-champagne-400', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.4));
|
||||
|
||||
// Transition colors (500-600): between bg and text
|
||||
root.style.setProperty('--color-champagne-500', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.6))
|
||||
root.style.setProperty('--color-champagne-600', rgbToString(lightTextSecRgb.r, lightTextSecRgb.g, lightTextSecRgb.b))
|
||||
root.style.setProperty('--color-champagne-500', interpolateRgb(lightBgRgb, lightTextSecRgb, 0.6));
|
||||
root.style.setProperty(
|
||||
'--color-champagne-600',
|
||||
rgbToString(lightTextSecRgb.r, lightTextSecRgb.g, lightTextSecRgb.b),
|
||||
);
|
||||
|
||||
// Text colors (700-950): secondary to primary text
|
||||
root.style.setProperty('--color-champagne-700', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.33))
|
||||
root.style.setProperty('--color-champagne-800', interpolateRgb(lightTextSecRgb, lightTextRgb, 0.66))
|
||||
root.style.setProperty('--color-champagne-900', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b))
|
||||
root.style.setProperty('--color-champagne-950', rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b))
|
||||
root.style.setProperty(
|
||||
'--color-champagne-700',
|
||||
interpolateRgb(lightTextSecRgb, lightTextRgb, 0.33),
|
||||
);
|
||||
root.style.setProperty(
|
||||
'--color-champagne-800',
|
||||
interpolateRgb(lightTextSecRgb, lightTextRgb, 0.66),
|
||||
);
|
||||
root.style.setProperty(
|
||||
'--color-champagne-900',
|
||||
rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b),
|
||||
);
|
||||
root.style.setProperty(
|
||||
'--color-champagne-950',
|
||||
rgbToString(lightTextRgb.r, lightTextRgb.g, lightTextRgb.b),
|
||||
);
|
||||
|
||||
// === STATUS COLOR PALETTES ===
|
||||
for (const shade of SHADE_LEVELS) {
|
||||
root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade])
|
||||
root.style.setProperty(`--color-success-${shade}`, successPalette[shade])
|
||||
root.style.setProperty(`--color-warning-${shade}`, warningPalette[shade])
|
||||
root.style.setProperty(`--color-error-${shade}`, errorPalette[shade])
|
||||
root.style.setProperty(`--color-accent-${shade}`, accentPalette[shade]);
|
||||
root.style.setProperty(`--color-success-${shade}`, successPalette[shade]);
|
||||
root.style.setProperty(`--color-warning-${shade}`, warningPalette[shade]);
|
||||
root.style.setProperty(`--color-error-${shade}`, errorPalette[shade]);
|
||||
}
|
||||
|
||||
// Apply semantic colors (hex for direct use)
|
||||
root.style.setProperty('--color-dark-bg', colors.darkBackground)
|
||||
root.style.setProperty('--color-dark-surface', colors.darkSurface)
|
||||
root.style.setProperty('--color-dark-text', colors.darkText)
|
||||
root.style.setProperty('--color-dark-text-secondary', colors.darkTextSecondary)
|
||||
root.style.setProperty('--color-dark-bg', colors.darkBackground);
|
||||
root.style.setProperty('--color-dark-surface', colors.darkSurface);
|
||||
root.style.setProperty('--color-dark-text', colors.darkText);
|
||||
root.style.setProperty('--color-dark-text-secondary', colors.darkTextSecondary);
|
||||
|
||||
root.style.setProperty('--color-light-bg', colors.lightBackground)
|
||||
root.style.setProperty('--color-light-surface', colors.lightSurface)
|
||||
root.style.setProperty('--color-light-text', colors.lightText)
|
||||
root.style.setProperty('--color-light-text-secondary', colors.lightTextSecondary)
|
||||
root.style.setProperty('--color-light-bg', colors.lightBackground);
|
||||
root.style.setProperty('--color-light-surface', colors.lightSurface);
|
||||
root.style.setProperty('--color-light-text', colors.lightText);
|
||||
root.style.setProperty('--color-light-text-secondary', colors.lightTextSecondary);
|
||||
}
|
||||
|
||||
export function useThemeColors() {
|
||||
const queryClient = useQueryClient()
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const {
|
||||
data: colors,
|
||||
@@ -208,22 +241,22 @@ export function useThemeColors() {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchOnWindowFocus: false,
|
||||
retry: 1,
|
||||
})
|
||||
});
|
||||
|
||||
// Apply colors when loaded or changed
|
||||
useEffect(() => {
|
||||
const colorsToApply = colors || DEFAULT_THEME_COLORS
|
||||
applyThemeColors(colorsToApply)
|
||||
}, [colors])
|
||||
const colorsToApply = colors || DEFAULT_THEME_COLORS;
|
||||
applyThemeColors(colorsToApply);
|
||||
}, [colors]);
|
||||
|
||||
const invalidateColors = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] })
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['theme-colors'] });
|
||||
};
|
||||
|
||||
return {
|
||||
colors: colors || DEFAULT_THEME_COLORS,
|
||||
isLoading,
|
||||
error,
|
||||
invalidateColors,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,156 +1,159 @@
|
||||
import { useEffect, useRef, useCallback, useState } from 'react'
|
||||
import { useAuthStore } from '../store/auth'
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
const isDev = import.meta.env.DEV
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
export interface WSMessage {
|
||||
type: string
|
||||
ticket_id?: number
|
||||
message?: string
|
||||
title?: string
|
||||
user_id?: number
|
||||
is_admin?: boolean
|
||||
type: string;
|
||||
ticket_id?: number;
|
||||
message?: string;
|
||||
title?: string;
|
||||
user_id?: number;
|
||||
is_admin?: boolean;
|
||||
}
|
||||
|
||||
interface UseWebSocketOptions {
|
||||
onMessage?: (message: WSMessage) => void
|
||||
onConnect?: () => void
|
||||
onDisconnect?: () => void
|
||||
onMessage?: (message: WSMessage) => void;
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
}
|
||||
|
||||
export function useWebSocket(options: UseWebSocketOptions = {}) {
|
||||
const { accessToken, isAuthenticated } = useAuthStore()
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const reconnectAttemptsRef = useRef(0)
|
||||
const maxReconnectAttempts = 5
|
||||
const optionsRef = useRef(options)
|
||||
const { accessToken, isAuthenticated } = useAuthStore();
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const maxReconnectAttempts = 5;
|
||||
const optionsRef = useRef(options);
|
||||
|
||||
// Update options ref when they change
|
||||
useEffect(() => {
|
||||
optionsRef.current = options
|
||||
}, [options])
|
||||
optionsRef.current = options;
|
||||
}, [options]);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current)
|
||||
reconnectTimeoutRef.current = null
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
pingIntervalRef.current = null
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!accessToken || !isAuthenticated) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't reconnect if already connected
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
cleanup()
|
||||
cleanup();
|
||||
|
||||
// Build WebSocket URL
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
let host = window.location.host
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
let host = window.location.host;
|
||||
|
||||
// Handle VITE_API_URL - can be absolute URL or relative path
|
||||
const apiUrl = import.meta.env.VITE_API_URL
|
||||
const apiUrl = import.meta.env.VITE_API_URL;
|
||||
if (apiUrl && (apiUrl.startsWith('http://') || apiUrl.startsWith('https://'))) {
|
||||
try {
|
||||
host = new URL(apiUrl).host
|
||||
host = new URL(apiUrl).host;
|
||||
} catch {
|
||||
// If URL parsing fails, use window.location.host
|
||||
}
|
||||
}
|
||||
// If apiUrl is relative (like /api), use window.location.host
|
||||
|
||||
const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}`
|
||||
const wsUrl = `${protocol}//${host}/cabinet/ws?token=${accessToken}`;
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
wsRef.current = ws
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (isDev) console.log('[WS] Connected')
|
||||
setIsConnected(true)
|
||||
reconnectAttemptsRef.current = 0
|
||||
optionsRef.current.onConnect?.()
|
||||
if (isDev) console.log('[WS] Connected');
|
||||
setIsConnected(true);
|
||||
reconnectAttemptsRef.current = 0;
|
||||
optionsRef.current.onConnect?.();
|
||||
|
||||
// Setup ping interval (every 25 seconds)
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'ping' }))
|
||||
ws.send(JSON.stringify({ type: 'ping' }));
|
||||
}
|
||||
}, 25000)
|
||||
}
|
||||
}, 25000);
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data) as WSMessage
|
||||
const message = JSON.parse(event.data) as WSMessage;
|
||||
|
||||
// Ignore pong messages
|
||||
if (message.type === 'pong' || message.type === 'connected') {
|
||||
return
|
||||
return;
|
||||
}
|
||||
|
||||
optionsRef.current.onMessage?.(message)
|
||||
optionsRef.current.onMessage?.(message);
|
||||
} catch (e) {
|
||||
if (isDev) console.error('[WS] Failed to parse message:', e)
|
||||
if (isDev) console.error('[WS] Failed to parse message:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
if (isDev) console.log('[WS] Disconnected:', event.code, event.reason)
|
||||
setIsConnected(false)
|
||||
optionsRef.current.onDisconnect?.()
|
||||
if (isDev) console.log('[WS] Disconnected:', event.code, event.reason);
|
||||
setIsConnected(false);
|
||||
optionsRef.current.onDisconnect?.();
|
||||
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current)
|
||||
pingIntervalRef.current = null
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// Attempt to reconnect if not closed intentionally
|
||||
if (event.code !== 1000 && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000)
|
||||
if (isDev) console.log(`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`)
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttemptsRef.current), 30000);
|
||||
if (isDev)
|
||||
console.log(
|
||||
`[WS] Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current + 1})`,
|
||||
);
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
reconnectAttemptsRef.current++
|
||||
connect()
|
||||
}, delay)
|
||||
reconnectAttemptsRef.current++;
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
if (isDev) console.error('[WS] Error:', error)
|
||||
}
|
||||
if (isDev) console.error('[WS] Error:', error);
|
||||
};
|
||||
} catch (e) {
|
||||
if (isDev) console.error('[WS] Failed to connect:', e)
|
||||
if (isDev) console.error('[WS] Failed to connect:', e);
|
||||
}
|
||||
}, [accessToken, isAuthenticated, cleanup])
|
||||
}, [accessToken, isAuthenticated, cleanup]);
|
||||
|
||||
// Connect when authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated && accessToken) {
|
||||
connect()
|
||||
connect();
|
||||
} else {
|
||||
cleanup()
|
||||
setIsConnected(false)
|
||||
cleanup();
|
||||
setIsConnected(false);
|
||||
}
|
||||
|
||||
return cleanup
|
||||
}, [isAuthenticated, accessToken, connect, cleanup])
|
||||
return cleanup;
|
||||
}, [isAuthenticated, accessToken, connect, cleanup]);
|
||||
|
||||
return { isConnected }
|
||||
return { isConnected };
|
||||
}
|
||||
|
||||
20
src/i18n.ts
20
src/i18n.ts
@@ -1,18 +1,18 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||
|
||||
import ru from './locales/ru.json'
|
||||
import en from './locales/en.json'
|
||||
import zh from './locales/zh.json'
|
||||
import fa from './locales/fa.json'
|
||||
import ru from './locales/ru.json';
|
||||
import en from './locales/en.json';
|
||||
import zh from './locales/zh.json';
|
||||
import fa from './locales/fa.json';
|
||||
|
||||
const resources = {
|
||||
ru: { translation: ru },
|
||||
en: { translation: en },
|
||||
zh: { translation: zh },
|
||||
fa: { translation: fa },
|
||||
}
|
||||
};
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
@@ -35,6 +35,6 @@ i18n
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
export default i18n
|
||||
export default i18n;
|
||||
|
||||
30
src/main.tsx
30
src/main.tsx
@@ -1,20 +1,20 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import { ThemeColorsProvider } from './providers/ThemeColorsProvider'
|
||||
import { ToastProvider } from './components/Toast'
|
||||
import { initLogoPreload } from './api/branding'
|
||||
import { initTelegramWebApp } from './hooks/useTelegramWebApp'
|
||||
import './i18n'
|
||||
import './styles/globals.css'
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import { ThemeColorsProvider } from './providers/ThemeColorsProvider';
|
||||
import { ToastProvider } from './components/Toast';
|
||||
import { initLogoPreload } from './api/branding';
|
||||
import { initTelegramWebApp } from './hooks/useTelegramWebApp';
|
||||
import './i18n';
|
||||
import './styles/globals.css';
|
||||
|
||||
// Initialize Telegram WebApp (expand, disable swipes)
|
||||
initTelegramWebApp()
|
||||
initTelegramWebApp();
|
||||
|
||||
// Preload logo from cache immediately on page load
|
||||
initLogoPreload()
|
||||
initLogoPreload();
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -23,7 +23,7 @@ const queryClient = new QueryClient({
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
@@ -37,4 +37,4 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,81 +1,115 @@
|
||||
import { useState, useRef, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useState, useRef, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
adminBroadcastsApi,
|
||||
Broadcast,
|
||||
BroadcastFilter,
|
||||
TariffFilter,
|
||||
BroadcastCreateRequest,
|
||||
} from '../api/adminBroadcasts'
|
||||
} from '../api/adminBroadcasts';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const BroadcastIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const PlusIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const XIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const RefreshIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const StopIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5.25 7.5A2.25 2.25 0 017.5 5.25h9a2.25 2.25 0 012.25 2.25v9a2.25 2.25 0 01-2.25 2.25h-9a2.25 2.25 0 01-2.25-2.25v-9z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const PhotoIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const VideoIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 002.25-2.25v-9a2.25 2.25 0 00-2.25-2.25h-9A2.25 2.25 0 002.25 7.5v9a2.25 2.25 0 002.25 2.25z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const DocumentIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const UsersIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ChevronDownIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
// Status badge component
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
@@ -87,13 +121,13 @@ function StatusBadge({ status }: { status: string }) {
|
||||
failed: { bg: 'bg-red-500/20', text: 'text-red-400', label: 'Ошибка' },
|
||||
cancelled: { bg: 'bg-gray-500/20', text: 'text-gray-400', label: 'Отменено' },
|
||||
cancelling: { bg: 'bg-yellow-500/20', text: 'text-yellow-400', label: 'Отменяется' },
|
||||
}
|
||||
const config = statusConfig[status] || statusConfig.queued
|
||||
};
|
||||
const config = statusConfig[status] || statusConfig.queued;
|
||||
return (
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${config.bg} ${config.text}`}>
|
||||
<span className={`rounded-full px-2 py-1 text-xs font-medium ${config.bg} ${config.text}`}>
|
||||
{config.label}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Filter labels
|
||||
@@ -105,201 +139,203 @@ const FILTER_GROUP_LABELS: Record<string, string> = {
|
||||
activity: 'По активности',
|
||||
source: 'По источнику',
|
||||
tariff: 'По тарифу',
|
||||
}
|
||||
};
|
||||
|
||||
// Create broadcast modal
|
||||
interface CreateModalProps {
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [target, setTarget] = useState('')
|
||||
const [messageText, setMessageText] = useState('')
|
||||
const [selectedButtons, setSelectedButtons] = useState<string[]>(['home'])
|
||||
const [mediaFile, setMediaFile] = useState<File | null>(null)
|
||||
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo')
|
||||
const [mediaPreview, setMediaPreview] = useState<string | null>(null)
|
||||
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [target, setTarget] = useState('');
|
||||
const [messageText, setMessageText] = useState('');
|
||||
const [selectedButtons, setSelectedButtons] = useState<string[]>(['home']);
|
||||
const [mediaFile, setMediaFile] = useState<File | null>(null);
|
||||
const [mediaType, setMediaType] = useState<'photo' | 'video' | 'document'>('photo');
|
||||
const [mediaPreview, setMediaPreview] = useState<string | null>(null);
|
||||
const [uploadedFileId, setUploadedFileId] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
// Fetch filters
|
||||
const { data: filtersData, isLoading: filtersLoading } = useQuery({
|
||||
queryKey: ['admin', 'broadcasts', 'filters'],
|
||||
queryFn: adminBroadcastsApi.getFilters,
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch buttons
|
||||
const { data: buttonsData } = useQuery({
|
||||
queryKey: ['admin', 'broadcasts', 'buttons'],
|
||||
queryFn: adminBroadcastsApi.getButtons,
|
||||
})
|
||||
});
|
||||
|
||||
// Preview mutation
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: adminBroadcastsApi.preview,
|
||||
})
|
||||
});
|
||||
|
||||
// Create mutation
|
||||
const createMutation = useMutation({
|
||||
mutationFn: adminBroadcastsApi.create,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] })
|
||||
onSuccess()
|
||||
onClose()
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
|
||||
onSuccess();
|
||||
onClose();
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Group filters
|
||||
const groupedFilters = useMemo(() => {
|
||||
if (!filtersData) return {}
|
||||
const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {}
|
||||
if (!filtersData) return {};
|
||||
const groups: Record<string, (BroadcastFilter | TariffFilter)[]> = {};
|
||||
|
||||
// Basic filters
|
||||
filtersData.filters.forEach(f => {
|
||||
const group = f.group || 'basic'
|
||||
if (!groups[group]) groups[group] = []
|
||||
groups[group].push(f)
|
||||
})
|
||||
filtersData.filters.forEach((f) => {
|
||||
const group = f.group || 'basic';
|
||||
if (!groups[group]) groups[group] = [];
|
||||
groups[group].push(f);
|
||||
});
|
||||
|
||||
// Tariff filters
|
||||
if (filtersData.tariff_filters.length > 0) {
|
||||
groups['tariff'] = filtersData.tariff_filters
|
||||
groups['tariff'] = filtersData.tariff_filters;
|
||||
}
|
||||
|
||||
// Custom filters
|
||||
filtersData.custom_filters.forEach(f => {
|
||||
const group = f.group || 'custom'
|
||||
if (!groups[group]) groups[group] = []
|
||||
groups[group].push(f)
|
||||
})
|
||||
filtersData.custom_filters.forEach((f) => {
|
||||
const group = f.group || 'custom';
|
||||
if (!groups[group]) groups[group] = [];
|
||||
groups[group].push(f);
|
||||
});
|
||||
|
||||
return groups
|
||||
}, [filtersData])
|
||||
return groups;
|
||||
}, [filtersData]);
|
||||
|
||||
// Selected filter info
|
||||
const selectedFilter = useMemo(() => {
|
||||
if (!target || !filtersData) return null
|
||||
if (!target || !filtersData) return null;
|
||||
const all = [
|
||||
...filtersData.filters,
|
||||
...filtersData.tariff_filters,
|
||||
...filtersData.custom_filters,
|
||||
]
|
||||
return all.find(f => f.key === target)
|
||||
}, [target, filtersData])
|
||||
];
|
||||
return all.find((f) => f.key === target);
|
||||
}, [target, filtersData]);
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setMediaFile(file)
|
||||
setMediaFile(file);
|
||||
|
||||
// Determine media type
|
||||
if (file.type.startsWith('image/')) {
|
||||
setMediaType('photo')
|
||||
setMediaPreview(URL.createObjectURL(file))
|
||||
setMediaType('photo');
|
||||
setMediaPreview(URL.createObjectURL(file));
|
||||
} else if (file.type.startsWith('video/')) {
|
||||
setMediaType('video')
|
||||
setMediaPreview(null)
|
||||
setMediaType('video');
|
||||
setMediaPreview(null);
|
||||
} else {
|
||||
setMediaType('document')
|
||||
setMediaPreview(null)
|
||||
setMediaType('document');
|
||||
setMediaPreview(null);
|
||||
}
|
||||
|
||||
// Upload file
|
||||
setIsUploading(true)
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const result = await adminBroadcastsApi.uploadMedia(file, mediaType)
|
||||
setUploadedFileId(result.file_id)
|
||||
const result = await adminBroadcastsApi.uploadMedia(file, mediaType);
|
||||
setUploadedFileId(result.file_id);
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
setMediaFile(null)
|
||||
setMediaPreview(null)
|
||||
console.error('Upload failed:', err);
|
||||
setMediaFile(null);
|
||||
setMediaPreview(null);
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Remove media
|
||||
const handleRemoveMedia = () => {
|
||||
setMediaFile(null)
|
||||
setMediaPreview(null)
|
||||
setUploadedFileId(null)
|
||||
setMediaFile(null);
|
||||
setMediaPreview(null);
|
||||
setUploadedFileId(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ''
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle button
|
||||
const toggleButton = (key: string) => {
|
||||
setSelectedButtons(prev =>
|
||||
prev.includes(key) ? prev.filter(b => b !== key) : [...prev, key]
|
||||
)
|
||||
}
|
||||
setSelectedButtons((prev) =>
|
||||
prev.includes(key) ? prev.filter((b) => b !== key) : [...prev, key],
|
||||
);
|
||||
};
|
||||
|
||||
// Submit
|
||||
const handleSubmit = () => {
|
||||
if (!target || !messageText.trim()) return
|
||||
if (!target || !messageText.trim()) return;
|
||||
|
||||
const data: BroadcastCreateRequest = {
|
||||
target,
|
||||
message_text: messageText,
|
||||
selected_buttons: selectedButtons,
|
||||
}
|
||||
};
|
||||
|
||||
if (uploadedFileId) {
|
||||
data.media = {
|
||||
type: mediaType,
|
||||
file_id: uploadedFileId,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
createMutation.mutate(data)
|
||||
}
|
||||
createMutation.mutate(data);
|
||||
};
|
||||
|
||||
const recipientsCount = previewMutation.data?.count ?? selectedFilter?.count ?? null
|
||||
const recipientsCount = previewMutation.data?.count ?? selectedFilter?.count ?? null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4 overflow-y-auto">
|
||||
<div className="bg-dark-800 rounded-xl w-full max-w-2xl my-4">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4">
|
||||
<div className="my-4 w-full max-w-2xl rounded-xl bg-dark-800">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-accent-500/20 rounded-lg text-accent-400">
|
||||
<div className="rounded-lg bg-accent-500/20 p-2 text-accent-400">
|
||||
<BroadcastIcon />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('admin.broadcasts.create')}</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-dark-700 rounded-lg transition-colors">
|
||||
<button onClick={onClose} className="rounded-lg p-2 transition-colors hover:bg-dark-700">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4 max-h-[70vh] overflow-y-auto">
|
||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto p-4">
|
||||
{/* Filter selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.selectFilter')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="w-full p-3 bg-dark-700 rounded-lg text-left flex items-center justify-between hover:bg-dark-600 transition-colors"
|
||||
className="flex w-full items-center justify-between rounded-lg bg-dark-700 p-3 text-left transition-colors hover:bg-dark-600"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<UsersIcon />
|
||||
<span className={selectedFilter ? 'text-dark-100' : 'text-dark-400'}>
|
||||
{selectedFilter ? selectedFilter.label : t('admin.broadcasts.selectFilterPlaceholder')}
|
||||
{selectedFilter
|
||||
? selectedFilter.label
|
||||
: t('admin.broadcasts.selectFilterPlaceholder')}
|
||||
</span>
|
||||
{recipientsCount !== null && (
|
||||
<span className="px-2 py-0.5 bg-accent-500/20 text-accent-400 rounded-full text-xs">
|
||||
<span className="rounded-full bg-accent-500/20 px-2 py-0.5 text-xs text-accent-400">
|
||||
{recipientsCount} {t('admin.broadcasts.recipients')}
|
||||
</span>
|
||||
)}
|
||||
@@ -308,24 +344,24 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
||||
</button>
|
||||
|
||||
{showFilters && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-dark-700 rounded-lg shadow-xl z-10 max-h-64 overflow-y-auto">
|
||||
<div className="absolute left-0 right-0 top-full z-10 mt-1 max-h-64 overflow-y-auto rounded-lg bg-dark-700 shadow-xl">
|
||||
{filtersLoading ? (
|
||||
<div className="p-4 text-center text-dark-400">Loading...</div>
|
||||
) : (
|
||||
Object.entries(groupedFilters).map(([group, filters]) => (
|
||||
<div key={group}>
|
||||
<div className="px-3 py-2 text-xs font-medium text-dark-400 bg-dark-800 sticky top-0">
|
||||
<div className="sticky top-0 bg-dark-800 px-3 py-2 text-xs font-medium text-dark-400">
|
||||
{FILTER_GROUP_LABELS[group] || group}
|
||||
</div>
|
||||
{filters.map(filter => (
|
||||
{filters.map((filter) => (
|
||||
<button
|
||||
key={filter.key}
|
||||
onClick={() => {
|
||||
setTarget(filter.key)
|
||||
setShowFilters(false)
|
||||
previewMutation.mutate(filter.key)
|
||||
setTarget(filter.key);
|
||||
setShowFilters(false);
|
||||
previewMutation.mutate(filter.key);
|
||||
}}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-dark-600 transition-colors flex items-center justify-between ${
|
||||
className={`flex w-full items-center justify-between px-3 py-2 text-left transition-colors hover:bg-dark-600 ${
|
||||
target === filter.key ? 'bg-accent-500/20' : ''
|
||||
}`}
|
||||
>
|
||||
@@ -345,29 +381,27 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
||||
|
||||
{/* Message text */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.messageText')}
|
||||
</label>
|
||||
<textarea
|
||||
value={messageText}
|
||||
onChange={e => setMessageText(e.target.value)}
|
||||
onChange={(e) => setMessageText(e.target.value)}
|
||||
placeholder={t('admin.broadcasts.messageTextPlaceholder')}
|
||||
rows={5}
|
||||
maxLength={4000}
|
||||
className="w-full p-3 bg-dark-700 rounded-lg text-dark-100 placeholder-dark-400 resize-none focus:outline-none focus:ring-2 focus:ring-accent-500"
|
||||
className="w-full resize-none rounded-lg bg-dark-700 p-3 text-dark-100 placeholder-dark-400 focus:outline-none focus:ring-2 focus:ring-accent-500"
|
||||
/>
|
||||
<div className="text-xs text-dark-400 mt-1 text-right">
|
||||
{messageText.length}/4000
|
||||
</div>
|
||||
<div className="mt-1 text-right text-xs text-dark-400">{messageText.length}/4000</div>
|
||||
</div>
|
||||
|
||||
{/* Media upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.media')}
|
||||
</label>
|
||||
{mediaFile ? (
|
||||
<div className="p-3 bg-dark-700 rounded-lg">
|
||||
<div className="rounded-lg bg-dark-700 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{mediaType === 'photo' && <PhotoIcon />}
|
||||
@@ -382,7 +416,7 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRemoveMedia}
|
||||
className="p-2 hover:bg-dark-600 rounded-lg text-dark-400 hover:text-red-400"
|
||||
className="rounded-lg p-2 text-dark-400 hover:bg-dark-600 hover:text-red-400"
|
||||
disabled={isUploading}
|
||||
>
|
||||
<XIcon />
|
||||
@@ -396,7 +430,9 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
||||
/>
|
||||
)}
|
||||
{isUploading && (
|
||||
<div className="mt-2 text-sm text-accent-400">{t('admin.broadcasts.uploading')}</div>
|
||||
<div className="mt-2 text-sm text-accent-400">
|
||||
{t('admin.broadcasts.uploading')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -410,7 +446,7 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex-1 p-3 bg-dark-700 rounded-lg text-dark-400 hover:bg-dark-600 hover:text-dark-100 transition-colors flex items-center justify-center gap-2"
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-dark-700 p-3 text-dark-400 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||
>
|
||||
<PhotoIcon />
|
||||
<span>{t('admin.broadcasts.addMedia')}</span>
|
||||
@@ -421,15 +457,15 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
||||
|
||||
{/* Buttons selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-300 mb-2">
|
||||
<label className="mb-2 block text-sm font-medium text-dark-300">
|
||||
{t('admin.broadcasts.buttons')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{buttonsData?.buttons.map(button => (
|
||||
{buttonsData?.buttons.map((button) => (
|
||||
<button
|
||||
key={button.key}
|
||||
onClick={() => toggleButton(button.key)}
|
||||
className={`px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
className={`rounded-lg px-3 py-2 text-sm transition-colors ${
|
||||
selectedButtons.includes(button.key)
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
|
||||
@@ -443,76 +479,74 @@ function CreateBroadcastModal({ onClose, onSuccess }: CreateModalProps) {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-4 border-t border-dark-700">
|
||||
<div className="flex items-center justify-between border-t border-dark-700 p-4">
|
||||
<div className="text-sm text-dark-400">
|
||||
{recipientsCount !== null && (
|
||||
<span>
|
||||
{t('admin.broadcasts.willBeSent')}: <strong className="text-accent-400">{recipientsCount}</strong> {t('admin.broadcasts.users')}
|
||||
{t('admin.broadcasts.willBeSent')}:{' '}
|
||||
<strong className="text-accent-400">{recipientsCount}</strong>{' '}
|
||||
{t('admin.broadcasts.users')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 transition-colors"
|
||||
className="rounded-lg bg-dark-700 px-4 py-2 text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!target || !messageText.trim() || createMutation.isPending || isUploading}
|
||||
className="px-4 py-2 bg-accent-500 rounded-lg text-white hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<RefreshIcon />
|
||||
) : (
|
||||
<BroadcastIcon />
|
||||
)}
|
||||
{createMutation.isPending ? <RefreshIcon /> : <BroadcastIcon />}
|
||||
{t('admin.broadcasts.send')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Broadcast detail modal
|
||||
interface DetailModalProps {
|
||||
broadcast: Broadcast
|
||||
onClose: () => void
|
||||
onStop: () => void
|
||||
isStopping: boolean
|
||||
broadcast: Broadcast;
|
||||
onClose: () => void;
|
||||
onStop: () => void;
|
||||
isStopping: boolean;
|
||||
}
|
||||
|
||||
function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: DetailModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const isRunning = ['queued', 'in_progress', 'cancelling'].includes(broadcast.status)
|
||||
const { t } = useTranslation();
|
||||
const isRunning = ['queued', 'in_progress', 'cancelling'].includes(broadcast.status);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl w-full max-w-lg">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-lg rounded-xl bg-dark-800">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge status={broadcast.status} />
|
||||
<span className="text-dark-400">#{broadcast.id}</span>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 hover:bg-dark-700 rounded-lg transition-colors">
|
||||
<button onClick={onClose} className="rounded-lg p-2 transition-colors hover:bg-dark-700">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* Progress */}
|
||||
{isRunning && (
|
||||
<div>
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<div className="mb-1 flex justify-between text-sm">
|
||||
<span className="text-dark-400">{t('admin.broadcasts.progress')}</span>
|
||||
<span className="text-dark-100">{broadcast.progress_percent.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-dark-700 rounded-full overflow-hidden">
|
||||
<div className="h-2 overflow-hidden rounded-full bg-dark-700">
|
||||
<div
|
||||
className="h-full bg-accent-500 transition-all duration-300"
|
||||
style={{ width: `${broadcast.progress_percent}%` }}
|
||||
@@ -523,15 +557,15 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="p-3 bg-dark-700 rounded-lg text-center">
|
||||
<div className="rounded-lg bg-dark-700 p-3 text-center">
|
||||
<p className="text-2xl font-bold text-dark-100">{broadcast.total_count}</p>
|
||||
<p className="text-xs text-dark-400">{t('admin.broadcasts.total')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-700 rounded-lg text-center">
|
||||
<div className="rounded-lg bg-dark-700 p-3 text-center">
|
||||
<p className="text-2xl font-bold text-green-400">{broadcast.sent_count}</p>
|
||||
<p className="text-xs text-dark-400">{t('admin.broadcasts.sent')}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-dark-700 rounded-lg text-center">
|
||||
<div className="rounded-lg bg-dark-700 p-3 text-center">
|
||||
<p className="text-2xl font-bold text-red-400">{broadcast.failed_count}</p>
|
||||
<p className="text-xs text-dark-400">{t('admin.broadcasts.failed')}</p>
|
||||
</div>
|
||||
@@ -539,14 +573,14 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
|
||||
|
||||
{/* Target */}
|
||||
<div>
|
||||
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.filter')}</p>
|
||||
<p className="mb-1 text-sm text-dark-400">{t('admin.broadcasts.filter')}</p>
|
||||
<p className="text-dark-100">{broadcast.target_type}</p>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.message')}</p>
|
||||
<div className="p-3 bg-dark-700 rounded-lg text-dark-100 whitespace-pre-wrap text-sm max-h-40 overflow-y-auto">
|
||||
<p className="mb-1 text-sm text-dark-400">{t('admin.broadcasts.message')}</p>
|
||||
<div className="max-h-40 overflow-y-auto whitespace-pre-wrap rounded-lg bg-dark-700 p-3 text-sm text-dark-100">
|
||||
{broadcast.message_text}
|
||||
</div>
|
||||
</div>
|
||||
@@ -554,7 +588,7 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
|
||||
{/* Media */}
|
||||
{broadcast.has_media && (
|
||||
<div>
|
||||
<p className="text-sm text-dark-400 mb-1">{t('admin.broadcasts.media')}</p>
|
||||
<p className="mb-1 text-sm text-dark-400">{t('admin.broadcasts.media')}</p>
|
||||
<div className="flex items-center gap-2 text-dark-100">
|
||||
{broadcast.media_type === 'photo' && <PhotoIcon />}
|
||||
{broadcast.media_type === 'video' && <VideoIcon />}
|
||||
@@ -573,11 +607,11 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
|
||||
|
||||
{/* Footer */}
|
||||
{isRunning && broadcast.status !== 'cancelling' && (
|
||||
<div className="p-4 border-t border-dark-700">
|
||||
<div className="border-t border-dark-700 p-4">
|
||||
<button
|
||||
onClick={onStop}
|
||||
disabled={isStopping}
|
||||
className="w-full py-2 bg-red-500/20 text-red-400 rounded-lg hover:bg-red-500/30 transition-colors flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-red-500/20 py-2 text-red-400 transition-colors hover:bg-red-500/30 disabled:opacity-50"
|
||||
>
|
||||
<StopIcon />
|
||||
{t('admin.broadcasts.stop')}
|
||||
@@ -586,48 +620,48 @@ function BroadcastDetailModal({ broadcast, onClose, onStop, isStopping }: Detail
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Main component
|
||||
export default function AdminBroadcasts() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [selectedBroadcast, setSelectedBroadcast] = useState<Broadcast | null>(null)
|
||||
const [page, setPage] = useState(0)
|
||||
const limit = 20
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [selectedBroadcast, setSelectedBroadcast] = useState<Broadcast | null>(null);
|
||||
const [page, setPage] = useState(0);
|
||||
const limit = 20;
|
||||
|
||||
// Fetch broadcasts
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['admin', 'broadcasts', 'list', page],
|
||||
queryFn: () => adminBroadcastsApi.list(limit, page * limit),
|
||||
refetchInterval: 5000, // Auto refresh every 5s
|
||||
})
|
||||
});
|
||||
|
||||
// Stop mutation
|
||||
const stopMutation = useMutation({
|
||||
mutationFn: adminBroadcastsApi.stop,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] })
|
||||
setSelectedBroadcast(null)
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'broadcasts'] });
|
||||
setSelectedBroadcast(null);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const broadcasts = data?.items || []
|
||||
const total = data?.total || 0
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
const broadcasts = data?.items || [];
|
||||
const total = data?.total || 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-dark-900 p-4 pb-20 md:pb-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
@@ -639,13 +673,13 @@ export default function AdminBroadcasts() {
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="p-2 bg-dark-800 rounded-lg text-dark-400 hover:text-dark-100 transition-colors"
|
||||
className="rounded-lg bg-dark-800 p-2 text-dark-400 transition-colors hover:text-dark-100"
|
||||
>
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="px-4 py-2 bg-accent-500 rounded-lg text-white hover:bg-accent-600 transition-colors flex items-center gap-2"
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<PlusIcon />
|
||||
<span className="hidden sm:inline">{t('admin.broadcasts.create')}</span>
|
||||
@@ -654,7 +688,7 @@ export default function AdminBroadcasts() {
|
||||
</div>
|
||||
|
||||
{/* Broadcasts list */}
|
||||
<div className="bg-dark-800 rounded-xl overflow-hidden">
|
||||
<div className="overflow-hidden rounded-xl bg-dark-800">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-dark-400">
|
||||
<RefreshIcon />
|
||||
@@ -667,15 +701,15 @@ export default function AdminBroadcasts() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-dark-700">
|
||||
{broadcasts.map(broadcast => (
|
||||
{broadcasts.map((broadcast) => (
|
||||
<button
|
||||
key={broadcast.id}
|
||||
onClick={() => setSelectedBroadcast(broadcast)}
|
||||
className="w-full p-4 hover:bg-dark-700/50 transition-colors text-left"
|
||||
className="w-full p-4 text-left transition-colors hover:bg-dark-700/50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<StatusBadge status={broadcast.status} />
|
||||
<span className="text-xs text-dark-400">#{broadcast.id}</span>
|
||||
{broadcast.has_media && (
|
||||
@@ -686,10 +720,8 @@ export default function AdminBroadcasts() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-dark-100 text-sm truncate">
|
||||
{broadcast.message_text}
|
||||
</p>
|
||||
<div className="flex items-center gap-4 mt-2 text-xs text-dark-400">
|
||||
<p className="truncate text-sm text-dark-100">{broadcast.message_text}</p>
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-dark-400">
|
||||
<span>{broadcast.target_type}</span>
|
||||
<span>
|
||||
{broadcast.sent_count}/{broadcast.total_count}
|
||||
@@ -699,13 +731,13 @@ export default function AdminBroadcasts() {
|
||||
</div>
|
||||
{['queued', 'in_progress'].includes(broadcast.status) && (
|
||||
<div className="w-16">
|
||||
<div className="h-1.5 bg-dark-600 rounded-full overflow-hidden">
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-dark-600">
|
||||
<div
|
||||
className="h-full bg-accent-500"
|
||||
style={{ width: `${broadcast.progress_percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-dark-400 text-center mt-1">
|
||||
<p className="mt-1 text-center text-xs text-dark-400">
|
||||
{broadcast.progress_percent.toFixed(0)}%
|
||||
</p>
|
||||
</div>
|
||||
@@ -718,11 +750,11 @@ export default function AdminBroadcasts() {
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 p-4 border-t border-dark-700">
|
||||
<div className="flex items-center justify-center gap-2 border-t border-dark-700 p-4">
|
||||
<button
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
className="px-3 py-1 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="rounded-lg bg-dark-700 px-3 py-1 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('common.prev')}
|
||||
</button>
|
||||
@@ -730,9 +762,9 @@ export default function AdminBroadcasts() {
|
||||
{page + 1} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
className="px-3 py-1 bg-dark-700 rounded-lg text-dark-300 hover:bg-dark-600 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="rounded-lg bg-dark-700 px-3 py-1 text-dark-300 hover:bg-dark-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{t('common.next')}
|
||||
</button>
|
||||
@@ -746,7 +778,7 @@ export default function AdminBroadcasts() {
|
||||
<CreateBroadcastModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSuccess={() => {
|
||||
refetch()
|
||||
refetch();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -760,5 +792,5 @@ export default function AdminBroadcasts() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,71 +1,93 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
adminEmailTemplatesApi,
|
||||
EmailTemplateType,
|
||||
EmailTemplateDetail,
|
||||
EmailTemplateLanguageData,
|
||||
} from '../api/adminEmailTemplates'
|
||||
} from '../api/adminEmailTemplates';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const MailIcon = () => (
|
||||
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const SaveIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const EyeIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const SendIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ResetIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const XIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const LANG_LABELS: Record<string, string> = {
|
||||
ru: 'RU',
|
||||
en: 'EN',
|
||||
zh: 'ZH',
|
||||
ua: 'UA',
|
||||
}
|
||||
};
|
||||
|
||||
const LANG_FULL_LABELS: Record<string, string> = {
|
||||
ru: 'Русский',
|
||||
en: 'English',
|
||||
zh: '中文',
|
||||
ua: 'Українська',
|
||||
}
|
||||
};
|
||||
|
||||
// ============ Template List View ============
|
||||
|
||||
@@ -74,31 +96,31 @@ function TemplateCard({
|
||||
currentLang,
|
||||
onClick,
|
||||
}: {
|
||||
template: EmailTemplateType
|
||||
currentLang: string
|
||||
onClick: () => void
|
||||
template: EmailTemplateType;
|
||||
currentLang: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const label = template.label[currentLang] || template.label['en'] || template.type
|
||||
const description = template.description[currentLang] || template.description['en'] || ''
|
||||
const customCount = Object.values(template.languages).filter(l => l.has_custom).length
|
||||
const label = template.label[currentLang] || template.label['en'] || template.type;
|
||||
const description = template.description[currentLang] || template.description['en'] || '';
|
||||
const customCount = Object.values(template.languages).filter((l) => l.has_custom).length;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="w-full text-left p-3 sm:p-4 bg-dark-800 rounded-xl border border-dark-700 hover:border-accent-500/50 transition-all duration-200 group"
|
||||
className="group w-full rounded-xl border border-dark-700 bg-dark-800 p-3 text-left transition-all duration-200 hover:border-accent-500/50 sm:p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2 sm:gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-dark-100 group-hover:text-accent-400 transition-colors truncate">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-sm font-medium text-dark-100 transition-colors group-hover:text-accent-400">
|
||||
{label}
|
||||
</h3>
|
||||
<p className="text-xs text-dark-400 mt-1 line-clamp-2">{description}</p>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-dark-400">{description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 sm:gap-1.5 flex-shrink-0 mt-0.5">
|
||||
<div className="mt-0.5 flex flex-shrink-0 items-center gap-1 sm:gap-1.5">
|
||||
{Object.entries(template.languages).map(([lang, status]) => (
|
||||
<span
|
||||
key={lang}
|
||||
className={`inline-flex items-center justify-center w-6 sm:w-7 h-5 rounded text-2xs font-medium ${
|
||||
className={`inline-flex h-5 w-6 items-center justify-center rounded text-2xs font-medium sm:w-7 ${
|
||||
status.has_custom
|
||||
? 'bg-accent-500/20 text-accent-400 ring-1 ring-accent-500/30'
|
||||
: 'bg-dark-700 text-dark-400'
|
||||
@@ -112,14 +134,14 @@ function TemplateCard({
|
||||
</div>
|
||||
{customCount > 0 && (
|
||||
<div className="mt-2">
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-2xs bg-accent-500/10 text-accent-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent-400" />
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent-500/10 px-2 py-0.5 text-2xs text-accent-400">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-accent-400" />
|
||||
{customCount} custom
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Template Editor ============
|
||||
@@ -129,225 +151,249 @@ function TemplateEditor({
|
||||
onClose,
|
||||
currentLang: interfaceLang,
|
||||
}: {
|
||||
detail: EmailTemplateDetail
|
||||
onClose: () => void
|
||||
currentLang: string
|
||||
detail: EmailTemplateDetail;
|
||||
onClose: () => void;
|
||||
currentLang: string;
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [activeLang, setActiveLang] = useState('ru')
|
||||
const [editSubject, setEditSubject] = useState('')
|
||||
const [editBody, setEditBody] = useState('')
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [previewHtml, setPreviewHtml] = useState('')
|
||||
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeLang, setActiveLang] = useState('ru');
|
||||
const [editSubject, setEditSubject] = useState('');
|
||||
const [editBody, setEditBody] = useState('');
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [previewHtml, setPreviewHtml] = useState('');
|
||||
const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang]
|
||||
const langData: EmailTemplateLanguageData | undefined = detail.languages[activeLang];
|
||||
|
||||
// Load data for current language
|
||||
useEffect(() => {
|
||||
if (langData) {
|
||||
setEditSubject(langData.subject)
|
||||
setEditBody(langData.is_default ? langData.body_html : langData.body_html)
|
||||
setIsDirty(false)
|
||||
setEditSubject(langData.subject);
|
||||
setEditBody(langData.is_default ? langData.body_html : langData.body_html);
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [activeLang, langData])
|
||||
}, [activeLang, langData]);
|
||||
|
||||
const showToast = useCallback((type: 'success' | 'error', message: string) => {
|
||||
setToast({ type, message })
|
||||
setTimeout(() => setToast(null), 3000)
|
||||
}, [])
|
||||
setToast({ type, message });
|
||||
setTimeout(() => setToast(null), 3000);
|
||||
}, []);
|
||||
|
||||
// Extract body content from full HTML (strip base template wrapper)
|
||||
const extractBodyContent = useCallback((html: string): string => {
|
||||
// If it's wrapped in the base template, extract just the content div
|
||||
const contentMatch = html.match(/<div class="content">\s*([\s\S]*?)\s*<\/div>\s*<div class="footer">/)
|
||||
const contentMatch = html.match(
|
||||
/<div class="content">\s*([\s\S]*?)\s*<\/div>\s*<div class="footer">/,
|
||||
);
|
||||
if (contentMatch) {
|
||||
return contentMatch[1].trim()
|
||||
return contentMatch[1].trim();
|
||||
}
|
||||
return html
|
||||
}, [])
|
||||
return html;
|
||||
}, []);
|
||||
|
||||
// When langData changes (e.g., after refetch), update the body content
|
||||
useEffect(() => {
|
||||
if (langData) {
|
||||
if (langData.is_default) {
|
||||
// For default templates, extract just the content portion
|
||||
setEditBody(extractBodyContent(langData.body_html))
|
||||
setEditBody(extractBodyContent(langData.body_html));
|
||||
} else {
|
||||
setEditBody(langData.body_html)
|
||||
setEditBody(langData.body_html);
|
||||
}
|
||||
setEditSubject(langData.subject)
|
||||
setIsDirty(false)
|
||||
setEditSubject(langData.subject);
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [activeLang, langData, extractBodyContent])
|
||||
}, [activeLang, langData, extractBodyContent]);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => adminEmailTemplatesApi.updateTemplate(detail.notification_type, activeLang, {
|
||||
subject: editSubject,
|
||||
body_html: editBody,
|
||||
}),
|
||||
mutationFn: () =>
|
||||
adminEmailTemplatesApi.updateTemplate(detail.notification_type, activeLang, {
|
||||
subject: editSubject,
|
||||
body_html: editBody,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'email-template', detail.notification_type] })
|
||||
setIsDirty(false)
|
||||
showToast('success', t('admin.emailTemplates.saved', 'Template saved'))
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'email-template', detail.notification_type],
|
||||
});
|
||||
setIsDirty(false);
|
||||
showToast('success', t('admin.emailTemplates.saved', 'Template saved'));
|
||||
},
|
||||
onError: () => {
|
||||
showToast('error', t('common.error', 'Error'))
|
||||
showToast('error', t('common.error', 'Error'));
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Reset mutation
|
||||
const resetMutation = useMutation({
|
||||
mutationFn: () => adminEmailTemplatesApi.deleteTemplate(detail.notification_type, activeLang),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'email-template', detail.notification_type] })
|
||||
setIsDirty(false)
|
||||
showToast('success', t('admin.emailTemplates.resetted', 'Template reset to default'))
|
||||
queryClient.invalidateQueries({ queryKey: ['admin', 'email-templates'] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin', 'email-template', detail.notification_type],
|
||||
});
|
||||
setIsDirty(false);
|
||||
showToast('success', t('admin.emailTemplates.resetted', 'Template reset to default'));
|
||||
},
|
||||
onError: () => {
|
||||
showToast('error', t('common.error', 'Error'))
|
||||
showToast('error', t('common.error', 'Error'));
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Preview mutation
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: () => adminEmailTemplatesApi.previewTemplate(detail.notification_type, {
|
||||
language: activeLang,
|
||||
subject: editSubject,
|
||||
body_html: editBody,
|
||||
}),
|
||||
mutationFn: () =>
|
||||
adminEmailTemplatesApi.previewTemplate(detail.notification_type, {
|
||||
language: activeLang,
|
||||
subject: editSubject,
|
||||
body_html: editBody,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
setPreviewHtml(data.body_html)
|
||||
setShowPreview(true)
|
||||
setPreviewHtml(data.body_html);
|
||||
setShowPreview(true);
|
||||
},
|
||||
onError: () => {
|
||||
showToast('error', t('common.error', 'Error'))
|
||||
showToast('error', t('common.error', 'Error'));
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Send test mutation
|
||||
const testMutation = useMutation({
|
||||
mutationFn: () => adminEmailTemplatesApi.sendTestEmail(detail.notification_type, {
|
||||
language: activeLang,
|
||||
}),
|
||||
mutationFn: () =>
|
||||
adminEmailTemplatesApi.sendTestEmail(detail.notification_type, {
|
||||
language: activeLang,
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
showToast('success', `${t('admin.emailTemplates.testSent', 'Test email sent')} → ${data.sent_to}`)
|
||||
showToast(
|
||||
'success',
|
||||
`${t('admin.emailTemplates.testSent', 'Test email sent')} → ${data.sent_to}`,
|
||||
);
|
||||
},
|
||||
onError: () => {
|
||||
showToast('error', t('common.error', 'Error'))
|
||||
showToast('error', t('common.error', 'Error'));
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Write preview HTML into iframe
|
||||
useEffect(() => {
|
||||
if (showPreview && iframeRef.current && previewHtml) {
|
||||
const doc = iframeRef.current.contentDocument
|
||||
const doc = iframeRef.current.contentDocument;
|
||||
if (doc) {
|
||||
doc.open()
|
||||
doc.write(previewHtml)
|
||||
doc.close()
|
||||
doc.open();
|
||||
doc.write(previewHtml);
|
||||
doc.close();
|
||||
}
|
||||
}
|
||||
}, [showPreview, previewHtml])
|
||||
}, [showPreview, previewHtml]);
|
||||
|
||||
const handleSubjectChange = (value: string) => {
|
||||
setEditSubject(value)
|
||||
setIsDirty(true)
|
||||
}
|
||||
setEditSubject(value);
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const handleBodyChange = (value: string) => {
|
||||
setEditBody(value)
|
||||
setIsDirty(true)
|
||||
}
|
||||
setEditBody(value);
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const label = detail.label[interfaceLang] || detail.label['en'] || detail.notification_type
|
||||
const label = detail.label[interfaceLang] || detail.label['en'] || detail.notification_type;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 sm:space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start sm:items-center justify-between gap-2">
|
||||
<div className="flex items-start sm:items-center gap-2 sm:gap-3 min-w-0">
|
||||
<button onClick={onClose} className="p-1 rounded-lg hover:bg-dark-700 transition-colors flex-shrink-0 mt-0.5 sm:mt-0">
|
||||
<div className="flex items-start justify-between gap-2 sm:items-center">
|
||||
<div className="flex min-w-0 items-start gap-2 sm:items-center sm:gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="mt-0.5 flex-shrink-0 rounded-lg p-1 transition-colors hover:bg-dark-700 sm:mt-0"
|
||||
>
|
||||
<BackIcon />
|
||||
</button>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base sm:text-lg font-semibold text-dark-100 truncate">{label}</h2>
|
||||
<p className="text-xs text-dark-400 line-clamp-2">
|
||||
<h2 className="truncate text-base font-semibold text-dark-100 sm:text-lg">{label}</h2>
|
||||
<p className="line-clamp-2 text-xs text-dark-400">
|
||||
{detail.description[interfaceLang] || detail.description['en'] || ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{langData && !langData.is_default && (
|
||||
<span className="px-2 sm:px-2.5 py-1 rounded-full text-2xs sm:text-xs font-medium bg-accent-500/15 text-accent-400 ring-1 ring-accent-500/25 flex-shrink-0">
|
||||
<span className="flex-shrink-0 rounded-full bg-accent-500/15 px-2 py-1 text-2xs font-medium text-accent-400 ring-1 ring-accent-500/25 sm:px-2.5 sm:text-xs">
|
||||
Custom
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Language tabs */}
|
||||
<div className="flex items-center gap-1 p-1 bg-dark-900 rounded-lg overflow-x-auto">
|
||||
{Object.keys(detail.languages).map(lang => {
|
||||
const isActive = lang === activeLang
|
||||
const langInfo = detail.languages[lang]
|
||||
<div className="flex items-center gap-1 overflow-x-auto rounded-lg bg-dark-900 p-1">
|
||||
{Object.keys(detail.languages).map((lang) => {
|
||||
const isActive = lang === activeLang;
|
||||
const langInfo = detail.languages[lang];
|
||||
return (
|
||||
<button
|
||||
key={lang}
|
||||
onClick={() => {
|
||||
if (isDirty && !window.confirm(t('admin.emailTemplates.unsavedWarning', 'Unsaved changes will be lost. Continue?'))) return
|
||||
setActiveLang(lang)
|
||||
if (
|
||||
isDirty &&
|
||||
!window.confirm(
|
||||
t(
|
||||
'admin.emailTemplates.unsavedWarning',
|
||||
'Unsaved changes will be lost. Continue?',
|
||||
),
|
||||
)
|
||||
)
|
||||
return;
|
||||
setActiveLang(lang);
|
||||
}}
|
||||
className={`flex-1 px-2 sm:px-3 py-2 rounded-md text-xs sm:text-sm font-medium transition-all duration-150 flex items-center justify-center gap-1 sm:gap-1.5 whitespace-nowrap ${
|
||||
className={`flex flex-1 items-center justify-center gap-1 whitespace-nowrap rounded-md px-2 py-2 text-xs font-medium transition-all duration-150 sm:gap-1.5 sm:px-3 sm:text-sm ${
|
||||
isActive
|
||||
? 'bg-dark-700 text-dark-100 shadow-sm'
|
||||
: 'text-dark-400 hover:text-dark-200 hover:bg-dark-800'
|
||||
: 'text-dark-400 hover:bg-dark-800 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
<span className="sm:hidden">{LANG_LABELS[lang] || lang}</span>
|
||||
<span className="hidden sm:inline">{LANG_FULL_LABELS[lang] || lang}</span>
|
||||
{!langInfo.is_default && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-accent-400 flex-shrink-0" />
|
||||
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-accent-400" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-dark-300 mb-1.5">
|
||||
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||
{t('admin.emailTemplates.subject', 'Subject')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={editSubject}
|
||||
onChange={e => handleSubjectChange(e.target.value)}
|
||||
className="w-full px-3 py-2.5 bg-dark-900 border border-dark-600 rounded-lg text-sm text-dark-100 placeholder-dark-500 focus:outline-none focus:ring-1 focus:ring-accent-500 focus:border-accent-500 transition-colors"
|
||||
onChange={(e) => handleSubjectChange(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-900 px-3 py-2.5 text-sm text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500"
|
||||
placeholder={t('admin.emailTemplates.subjectPlaceholder', 'Email subject line...')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Context variables hint */}
|
||||
{detail.context_vars.length > 0 && (
|
||||
<div className="p-2.5 sm:p-3 bg-dark-900/60 border border-dark-700 rounded-lg">
|
||||
<p className="text-xs font-medium text-dark-300 mb-1.5">
|
||||
<div className="rounded-lg border border-dark-700 bg-dark-900/60 p-2.5 sm:p-3">
|
||||
<p className="mb-1.5 text-xs font-medium text-dark-300">
|
||||
{t('admin.emailTemplates.variables', 'Available Variables')}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1 sm:gap-1.5">
|
||||
{detail.context_vars.map(v => (
|
||||
{detail.context_vars.map((v) => (
|
||||
<code
|
||||
key={v}
|
||||
className="px-2 py-0.5 rounded bg-dark-700 text-accent-400 text-xs font-mono cursor-pointer hover:bg-dark-600 transition-colors"
|
||||
className="cursor-pointer rounded bg-dark-700 px-2 py-0.5 font-mono text-xs text-accent-400 transition-colors hover:bg-dark-600"
|
||||
title={t('admin.emailTemplates.clickToCopy', 'Click to copy')}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(`{${v}}`)
|
||||
showToast('success', `Copied {${v}}`)
|
||||
navigator.clipboard.writeText(`{${v}}`);
|
||||
showToast('success', `Copied {${v}}`);
|
||||
}}
|
||||
>
|
||||
{`{${v}}`}
|
||||
@@ -359,30 +405,33 @@ function TemplateEditor({
|
||||
|
||||
{/* Body HTML editor */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-dark-300 mb-1.5">
|
||||
<label className="mb-1.5 block text-xs font-medium text-dark-300">
|
||||
{t('admin.emailTemplates.body', 'Body (HTML)')}
|
||||
</label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={editBody}
|
||||
onChange={e => handleBodyChange(e.target.value)}
|
||||
onChange={(e) => handleBodyChange(e.target.value)}
|
||||
rows={12}
|
||||
className="w-full px-3 py-2.5 bg-dark-900 border border-dark-600 rounded-lg text-xs sm:text-sm text-dark-100 placeholder-dark-500 font-mono leading-relaxed focus:outline-none focus:ring-1 focus:ring-accent-500 focus:border-accent-500 transition-colors resize-y min-h-[200px] sm:min-h-[300px]"
|
||||
className="min-h-[200px] w-full resize-y rounded-lg border border-dark-600 bg-dark-900 px-3 py-2.5 font-mono text-xs leading-relaxed text-dark-100 placeholder-dark-500 transition-colors focus:border-accent-500 focus:outline-none focus:ring-1 focus:ring-accent-500 sm:min-h-[300px] sm:text-sm"
|
||||
placeholder="<h2>Title</h2><p>Content...</p>"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="text-2xs text-dark-500 mt-1">
|
||||
{t('admin.emailTemplates.bodyHint', 'HTML content that will be wrapped in the base email template with header and footer.')}
|
||||
<p className="mt-1 text-2xs text-dark-500">
|
||||
{t(
|
||||
'admin.emailTemplates.bodyHint',
|
||||
'HTML content that will be wrapped in the base email template with header and footer.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap sm:items-center gap-2">
|
||||
<div className="grid grid-cols-2 sm:flex gap-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex">
|
||||
<button
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={!isDirty || saveMutation.isPending}
|
||||
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-accent-500 text-white hover:bg-accent-600 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-accent-500 px-3 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-40 sm:px-4 sm:py-2"
|
||||
>
|
||||
<SaveIcon />
|
||||
{saveMutation.isPending ? t('common.loading', 'Loading...') : t('common.save', 'Save')}
|
||||
@@ -391,35 +440,46 @@ function TemplateEditor({
|
||||
<button
|
||||
onClick={() => previewMutation.mutate()}
|
||||
disabled={previewMutation.isPending}
|
||||
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-dark-700 text-dark-200 hover:bg-dark-600 disabled:opacity-40 transition-colors"
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:px-4 sm:py-2"
|
||||
>
|
||||
<EyeIcon />
|
||||
{t('admin.emailTemplates.preview', 'Preview')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:flex gap-2">
|
||||
<div className="grid grid-cols-2 gap-2 sm:flex">
|
||||
<button
|
||||
onClick={() => testMutation.mutate()}
|
||||
disabled={testMutation.isPending}
|
||||
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-dark-700 text-dark-200 hover:bg-dark-600 disabled:opacity-40 transition-colors"
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-dark-200 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:px-4 sm:py-2"
|
||||
>
|
||||
<SendIcon />
|
||||
{testMutation.isPending ? t('common.loading', 'Loading...') : t('admin.emailTemplates.sendTest', 'Send Test')}
|
||||
{testMutation.isPending
|
||||
? t('common.loading', 'Loading...')
|
||||
: t('admin.emailTemplates.sendTest', 'Send Test')}
|
||||
</button>
|
||||
|
||||
{langData && !langData.is_default && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm(t('admin.emailTemplates.resetConfirm', 'Reset this template to the default version?'))) {
|
||||
resetMutation.mutate()
|
||||
if (
|
||||
window.confirm(
|
||||
t(
|
||||
'admin.emailTemplates.resetConfirm',
|
||||
'Reset this template to the default version?',
|
||||
),
|
||||
)
|
||||
) {
|
||||
resetMutation.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={resetMutation.isPending}
|
||||
className="inline-flex items-center justify-center gap-1.5 px-3 sm:px-4 py-2.5 sm:py-2 rounded-lg text-sm font-medium bg-dark-700 text-warning-400 hover:bg-dark-600 disabled:opacity-40 transition-colors sm:ml-auto"
|
||||
className="inline-flex items-center justify-center gap-1.5 rounded-lg bg-dark-700 px-3 py-2.5 text-sm font-medium text-warning-400 transition-colors hover:bg-dark-600 disabled:opacity-40 sm:ml-auto sm:px-4 sm:py-2"
|
||||
>
|
||||
<ResetIcon />
|
||||
<span className="truncate">{t('admin.emailTemplates.resetDefault', 'Reset to Default')}</span>
|
||||
<span className="truncate">
|
||||
{t('admin.emailTemplates.resetDefault', 'Reset to Default')}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -427,26 +487,26 @@ function TemplateEditor({
|
||||
|
||||
{/* Toast */}
|
||||
{toast && (
|
||||
<div className={`fixed bottom-4 left-4 right-4 sm:left-auto sm:right-6 sm:bottom-6 z-50 px-4 py-3 rounded-xl text-sm font-medium shadow-lg animate-fade-in text-center sm:text-left ${
|
||||
toast.type === 'success'
|
||||
? 'bg-emerald-500/90 text-white'
|
||||
: 'bg-red-500/90 text-white'
|
||||
}`}>
|
||||
<div
|
||||
className={`fixed bottom-4 left-4 right-4 z-50 animate-fade-in rounded-xl px-4 py-3 text-center text-sm font-medium shadow-lg sm:bottom-6 sm:left-auto sm:right-6 sm:text-left ${
|
||||
toast.type === 'success' ? 'bg-emerald-500/90 text-white' : 'bg-red-500/90 text-white'
|
||||
}`}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview Modal */}
|
||||
{showPreview && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center sm:p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div className="bg-dark-800 rounded-t-2xl sm:rounded-2xl w-full sm:max-w-2xl max-h-[90vh] sm:max-h-[85vh] flex flex-col border border-dark-600 shadow-2xl">
|
||||
<div className="flex items-center justify-between px-4 sm:px-5 py-3 sm:py-4 border-b border-dark-700">
|
||||
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/60 backdrop-blur-sm sm:items-center sm:p-4">
|
||||
<div className="flex max-h-[90vh] w-full flex-col rounded-t-2xl border border-dark-600 bg-dark-800 shadow-2xl sm:max-h-[85vh] sm:max-w-2xl sm:rounded-2xl">
|
||||
<div className="flex items-center justify-between border-b border-dark-700 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<h3 className="text-base font-semibold text-dark-100">
|
||||
{t('admin.emailTemplates.preview', 'Preview')}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setShowPreview(false)}
|
||||
className="p-1.5 rounded-lg hover:bg-dark-700 transition-colors"
|
||||
className="rounded-lg p-1.5 transition-colors hover:bg-dark-700"
|
||||
>
|
||||
<XIcon />
|
||||
</button>
|
||||
@@ -454,7 +514,7 @@ function TemplateEditor({
|
||||
<div className="flex-1 overflow-hidden p-1">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="w-full h-full min-h-[50vh] sm:min-h-[400px] rounded-lg bg-white"
|
||||
className="h-full min-h-[50vh] w-full rounded-lg bg-white sm:min-h-[400px]"
|
||||
sandbox="allow-same-origin"
|
||||
title="Email Preview"
|
||||
/>
|
||||
@@ -463,48 +523,48 @@ function TemplateEditor({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Main Page ============
|
||||
|
||||
export default function AdminEmailTemplates() {
|
||||
const { t, i18n } = useTranslation()
|
||||
const currentLang = i18n.language || 'ru'
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null)
|
||||
const { t, i18n } = useTranslation();
|
||||
const currentLang = i18n.language || 'ru';
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||
|
||||
// Fetch template types list
|
||||
const { data: typesData, isLoading: typesLoading } = useQuery({
|
||||
queryKey: ['admin', 'email-templates'],
|
||||
queryFn: adminEmailTemplatesApi.getTemplateTypes,
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch detail for selected type
|
||||
const { data: detailData, isLoading: detailLoading } = useQuery({
|
||||
queryKey: ['admin', 'email-template', selectedType],
|
||||
queryFn: () => adminEmailTemplatesApi.getTemplate(selectedType!),
|
||||
enabled: !!selectedType,
|
||||
})
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-3 sm:px-4 py-4 sm:py-6 space-y-4 sm:space-y-6">
|
||||
<div className="mx-auto max-w-4xl space-y-4 px-3 py-4 sm:space-y-6 sm:px-4 sm:py-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="p-1.5 sm:p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors border border-dark-700 flex-shrink-0"
|
||||
className="flex-shrink-0 rounded-xl border border-dark-700 bg-dark-800 p-1.5 transition-colors hover:bg-dark-700 sm:p-2"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 sm:gap-2.5 min-w-0">
|
||||
<div className="p-1.5 sm:p-2 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 text-blue-400 flex-shrink-0">
|
||||
<div className="flex min-w-0 items-center gap-2 sm:gap-2.5">
|
||||
<div className="flex-shrink-0 rounded-xl bg-gradient-to-br from-blue-500/20 to-blue-600/10 p-1.5 text-blue-400 sm:p-2">
|
||||
<MailIcon />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-lg sm:text-xl font-bold text-dark-100 truncate">
|
||||
<h1 className="truncate text-lg font-bold text-dark-100 sm:text-xl">
|
||||
{t('admin.emailTemplates.title', 'Email Templates')}
|
||||
</h1>
|
||||
<p className="text-xs text-dark-400 truncate">
|
||||
<p className="truncate text-xs text-dark-400">
|
||||
{t('admin.emailTemplates.description', 'Manage email notification templates')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -524,12 +584,12 @@ export default function AdminEmailTemplates() {
|
||||
{typesLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="h-20 bg-dark-800 rounded-xl animate-pulse" />
|
||||
<div key={i} className="h-20 animate-pulse rounded-xl bg-dark-800" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2 sm:gap-3 grid-cols-1 sm:grid-cols-2">
|
||||
{typesData?.items.map(template => (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 sm:gap-3">
|
||||
{typesData?.items.map((template) => (
|
||||
<TemplateCard
|
||||
key={template.type}
|
||||
template={template}
|
||||
@@ -545,9 +605,9 @@ export default function AdminEmailTemplates() {
|
||||
{/* Detail loading overlay */}
|
||||
{selectedType && detailLoading && (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,210 +1,298 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// Group header icons
|
||||
const AnalyticsGroupIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 3v11.25A2.25 2.25 0 006 16.5h2.25M3.75 3h-1.5m1.5 0h16.5m0 0h1.5m-1.5 0v11.25A2.25 2.25 0 0118 16.5h-2.25m-7.5 0h7.5m-7.5 0l-1 3m8.5-3l1 3m0 0l.5 1.5m-.5-1.5h-9.5m0 0l-.5 1.5m.75-9l3-3 2.148 2.148A12.061 12.061 0 0116.5 7.605"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const UsersGroupIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const TariffsGroupIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6v12m-3-2.818l.879.659c1.171.879 3.07.879 4.242 0 1.172-.879 1.172-2.303 0-3.182C13.536 12.219 12.768 12 12 12c-.725 0-1.45-.22-2.003-.659-1.106-.879-1.106-2.303 0-3.182s2.9-.879 4.006 0l.415.33M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const MarketingGroupIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const SystemGroupIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
// Modern icons with consistent styling
|
||||
const ChartBarIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 013 19.875v-6.75zM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V8.625zM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 01-1.125-1.125V4.125z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const BanknotesIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const UsersIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ChatBubbleIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M8.625 12a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H8.25m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0H12m4.125 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 01-2.555-.337A5.972 5.972 0 015.41 20.97a5.969 5.969 0 01-.474-.065 4.48 4.48 0 00.978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const NoSymbolIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CreditCardIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const TicketIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.5 6v.75m0 3v.75m0 3v.75m0 3V18m-9-5.25h5.25M7.5 15h3M3.375 5.25c-.621 0-1.125.504-1.125 1.125v3.026a2.999 2.999 0 010 5.198v3.026c0 .621.504 1.125 1.125 1.125h17.25c.621 0 1.125-.504 1.125-1.125v-3.026a2.999 2.999 0 010-5.198V6.375c0-.621-.504-1.125-1.125-1.125H3.375z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const GiftIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 11.25v8.25a1.5 1.5 0 01-1.5 1.5H5.25a1.5 1.5 0 01-1.5-1.5v-8.25M12 4.875A2.625 2.625 0 109.375 7.5H12m0-2.625V7.5m0-2.625A2.625 2.625 0 1114.625 7.5H12m0 0V21m-8.625-9.75h18c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125h-18c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const MegaphoneIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.34 15.84c-.688-.06-1.386-.09-2.09-.09H7.5a4.5 4.5 0 110-9h.75c.704 0 1.402-.03 2.09-.09m0 9.18c.253.962.584 1.892.985 2.783.247.55.06 1.21-.463 1.511l-.657.38c-.551.318-1.26.117-1.527-.461a20.845 20.845 0 01-1.44-4.282m3.102.069a18.03 18.03 0 01-.59-4.59c0-1.586.205-3.124.59-4.59m0 9.18a23.848 23.848 0 018.835 2.535M10.34 6.66a23.847 23.847 0 008.835-2.535m0 0A23.74 23.74 0 0018.795 3m.38 1.125a23.91 23.91 0 011.014 5.395m-1.014 8.855c-.118.38-.245.754-.38 1.125m.38-1.125a23.91 23.91 0 001.014-5.395m0-3.46c.495.413.811 1.035.811 1.73 0 .695-.316 1.317-.811 1.73m0-3.46a24.347 24.347 0 010 3.46"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const PaperAirplaneIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const SparklesIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CogIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"
|
||||
/>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const DevicePhoneMobileIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ServerStackIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5.25 14.25h13.5m-13.5 0a3 3 0 01-3-3m3 3a3 3 0 100 6h13.5a3 3 0 100-6m-16.5-3a3 3 0 013-3h13.5a3 3 0 013 3m-19.5 0a4.5 4.5 0 01.9-2.7L5.737 5.1a3.375 3.375 0 012.7-1.35h7.126c1.062 0 2.062.5 2.7 1.35l2.587 3.45a4.5 4.5 0 01.9 2.7m0 0a3 3 0 01-3 3m0 3h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008zm-3 6h.008v.008h-.008v-.008zm0-6h.008v.008h-.008v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const EnvelopeIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CubeTransparentIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ChevronRightIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
interface AdminItem {
|
||||
to: string
|
||||
icon: React.ReactNode
|
||||
title: string
|
||||
description: string
|
||||
to: string;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface AdminGroup {
|
||||
id: string
|
||||
title: string
|
||||
icon: React.ReactNode
|
||||
gradient: string
|
||||
borderColor: string
|
||||
iconBg: string
|
||||
iconColor: string
|
||||
items: AdminItem[]
|
||||
id: string;
|
||||
title: string;
|
||||
icon: React.ReactNode;
|
||||
gradient: string;
|
||||
borderColor: string;
|
||||
iconBg: string;
|
||||
iconColor: string;
|
||||
items: AdminItem[];
|
||||
}
|
||||
|
||||
function AdminCard({ to, icon, title, description, iconBg, iconColor }: AdminItem & { iconBg: string; iconColor: string }) {
|
||||
function AdminCard({
|
||||
to,
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
iconBg,
|
||||
iconColor,
|
||||
}: AdminItem & { iconBg: string; iconColor: string }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="group flex items-center gap-3 p-3 rounded-xl bg-dark-800/40 border border-dark-700/50 hover:bg-dark-800/80 hover:border-dark-600 transition-all duration-200"
|
||||
className="group flex items-center gap-3 rounded-xl border border-dark-700/50 bg-dark-800/40 p-3 transition-all duration-200 hover:border-dark-600 hover:bg-dark-800/80"
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg ${iconBg} ${iconColor} flex items-center justify-center shrink-0 group-hover:scale-105 transition-transform`}>
|
||||
<div
|
||||
className={`h-10 w-10 rounded-lg ${iconBg} ${iconColor} flex shrink-0 items-center justify-center transition-transform group-hover:scale-105`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-sm font-medium text-dark-100 group-hover:text-white transition-colors">{title}</h3>
|
||||
<p className="text-xs text-dark-500 truncate">{description}</p>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-medium text-dark-100 transition-colors group-hover:text-white">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="truncate text-xs text-dark-500">{description}</p>
|
||||
</div>
|
||||
<div className="text-dark-600 group-hover:text-dark-400 transition-colors">
|
||||
<div className="text-dark-600 transition-colors group-hover:text-dark-400">
|
||||
<ChevronRightIcon />
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function GroupSection({ group }: { group: AdminGroup }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-dark-700/50 overflow-hidden bg-dark-900/30 backdrop-blur">
|
||||
<div className="overflow-hidden rounded-2xl border border-dark-700/50 bg-dark-900/30 backdrop-blur">
|
||||
{/* Group Header */}
|
||||
<div className={`px-4 py-3 bg-gradient-to-r ${group.gradient} border-b ${group.borderColor}`}>
|
||||
<div className={`bg-gradient-to-r px-4 py-3 ${group.gradient} border-b ${group.borderColor}`}>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className={`p-1.5 rounded-lg ${group.iconBg} ${group.iconColor}`}>
|
||||
{group.icon}
|
||||
</div>
|
||||
<div className={`rounded-lg p-1.5 ${group.iconBg} ${group.iconColor}`}>{group.icon}</div>
|
||||
<h2 className="text-sm font-semibold text-dark-100">{group.title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Group Items */}
|
||||
<div className="p-2 space-y-1.5">
|
||||
<div className="space-y-1.5 p-2">
|
||||
{group.items.map((item) => (
|
||||
<AdminCard
|
||||
key={item.to}
|
||||
{...item}
|
||||
iconBg={group.iconBg}
|
||||
iconColor={group.iconColor}
|
||||
/>
|
||||
<AdminCard key={item.to} {...item} iconBg={group.iconBg} iconColor={group.iconColor} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPanel() {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
const groups: AdminGroup[] = [
|
||||
{
|
||||
@@ -364,22 +452,22 @@ export default function AdminPanel() {
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in space-y-4">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-dark-100">{t('admin.panel.title')}</h1>
|
||||
<p className="text-sm text-dark-400 mt-1">{t('admin.panel.subtitle')}</p>
|
||||
<p className="mt-1 text-sm text-dark-400">{t('admin.panel.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Groups Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{groups.map((group) => (
|
||||
<GroupSection key={group.id} group={group} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
@@ -11,55 +11,69 @@ import {
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from '@dnd-kit/core'
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable'
|
||||
import { CSS } from '@dnd-kit/utilities'
|
||||
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods'
|
||||
import type { PaymentMethodConfig, PromoGroupSimple } from '../types'
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
||||
import type { PaymentMethodConfig, PromoGroupSimple } from '../types';
|
||||
|
||||
// ============ Icons ============
|
||||
|
||||
const BackIcon = () => (
|
||||
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const GripIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ChevronRightIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CloseIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const SaveIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
// ============ Method icon by type ============
|
||||
|
||||
@@ -75,7 +89,7 @@ const METHOD_ICONS: Record<string, string> = {
|
||||
wata: '\uD83D\uDCA7',
|
||||
freekassa: '\uD83D\uDCB5',
|
||||
cloudpayments: '\u2601\uFE0F',
|
||||
}
|
||||
};
|
||||
|
||||
const METHOD_LABELS: Record<string, string> = {
|
||||
telegram_stars: 'Telegram Stars',
|
||||
@@ -89,53 +103,56 @@ const METHOD_LABELS: Record<string, string> = {
|
||||
wata: 'WATA',
|
||||
freekassa: 'Freekassa',
|
||||
cloudpayments: 'CloudPayments',
|
||||
}
|
||||
};
|
||||
|
||||
// ============ Sortable Card ============
|
||||
|
||||
interface SortableCardProps {
|
||||
config: PaymentMethodConfig
|
||||
onClick: () => void
|
||||
config: PaymentMethodConfig;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: config.method_id })
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: config.method_id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 50 : undefined,
|
||||
opacity: isDragging ? 0.85 : 1,
|
||||
}
|
||||
};
|
||||
|
||||
const displayName = config.display_name || config.default_display_name
|
||||
const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3'
|
||||
const displayName = config.display_name || config.default_display_name;
|
||||
const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3';
|
||||
|
||||
// Build condition summary chips
|
||||
const chips: string[] = []
|
||||
if (config.user_type_filter === 'telegram') chips.push(t('admin.paymentMethods.userTypeTelegram', 'Telegram'))
|
||||
if (config.user_type_filter === 'email') chips.push(t('admin.paymentMethods.userTypeEmail', 'Email'))
|
||||
if (config.first_topup_filter === 'yes') chips.push(t('admin.paymentMethods.firstTopupYes', 'C пополнением'))
|
||||
if (config.first_topup_filter === 'no') chips.push(t('admin.paymentMethods.firstTopupNo', 'Без пополнения'))
|
||||
const chips: string[] = [];
|
||||
if (config.user_type_filter === 'telegram')
|
||||
chips.push(t('admin.paymentMethods.userTypeTelegram', 'Telegram'));
|
||||
if (config.user_type_filter === 'email')
|
||||
chips.push(t('admin.paymentMethods.userTypeEmail', 'Email'));
|
||||
if (config.first_topup_filter === 'yes')
|
||||
chips.push(t('admin.paymentMethods.firstTopupYes', 'C пополнением'));
|
||||
if (config.first_topup_filter === 'no')
|
||||
chips.push(t('admin.paymentMethods.firstTopupNo', 'Без пополнения'));
|
||||
if (config.promo_group_filter_mode === 'selected' && config.allowed_promo_group_ids.length > 0) {
|
||||
chips.push(`${config.allowed_promo_group_ids.length} ${t('admin.paymentMethods.promoGroupsShort', 'групп')}`)
|
||||
chips.push(
|
||||
`${config.allowed_promo_group_ids.length} ${t('admin.paymentMethods.promoGroupsShort', 'групп')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Count enabled sub-options
|
||||
let subOptionsInfo = ''
|
||||
let subOptionsInfo = '';
|
||||
if (config.available_sub_options && config.sub_options) {
|
||||
const enabledCount = config.available_sub_options.filter(o => config.sub_options?.[o.id] !== false).length
|
||||
const totalCount = config.available_sub_options.length
|
||||
const enabledCount = config.available_sub_options.filter(
|
||||
(o) => config.sub_options?.[o.id] !== false,
|
||||
).length;
|
||||
const totalCount = config.available_sub_options.length;
|
||||
if (enabledCount < totalCount) {
|
||||
subOptionsInfo = `${enabledCount}/${totalCount}`
|
||||
subOptionsInfo = `${enabledCount}/${totalCount}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,49 +160,49 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`group flex items-center gap-3 p-4 rounded-xl border transition-all ${
|
||||
className={`group flex items-center gap-3 rounded-xl border p-4 transition-all ${
|
||||
isDragging
|
||||
? 'bg-dark-700/80 border-accent-500/50 shadow-xl shadow-accent-500/10'
|
||||
? 'border-accent-500/50 bg-dark-700/80 shadow-xl shadow-accent-500/10'
|
||||
: config.is_enabled
|
||||
? 'bg-dark-800/50 border-dark-700/50 hover:border-dark-600'
|
||||
: 'bg-dark-900/30 border-dark-800/50 opacity-60'
|
||||
? 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
|
||||
: 'border-dark-800/50 bg-dark-900/30 opacity-60'
|
||||
}`}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="flex-shrink-0 p-1.5 rounded-lg text-dark-500 hover:text-dark-300 hover:bg-dark-700/50 cursor-grab active:cursor-grabbing touch-manipulation"
|
||||
className="flex-shrink-0 cursor-grab touch-manipulation rounded-lg p-1.5 text-dark-500 hover:bg-dark-700/50 hover:text-dark-300 active:cursor-grabbing"
|
||||
title={t('admin.paymentMethods.dragToReorder', 'Перетащите для изменения порядка')}
|
||||
>
|
||||
<GripIcon />
|
||||
</button>
|
||||
|
||||
{/* Method icon */}
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-dark-700/50 flex items-center justify-center text-xl">
|
||||
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl bg-dark-700/50 text-xl">
|
||||
{icon}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 cursor-pointer" onClick={onClick}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-dark-100 truncate">{displayName}</span>
|
||||
<div className="min-w-0 flex-1 cursor-pointer" onClick={onClick}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate font-semibold text-dark-100">{displayName}</span>
|
||||
{config.is_enabled ? (
|
||||
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-success-500/15 text-success-400 border border-success-500/20">
|
||||
<span className="flex-shrink-0 rounded-full border border-success-500/20 bg-success-500/15 px-2 py-0.5 text-xs text-success-400">
|
||||
{t('admin.paymentMethods.enabled', 'Вкл')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-dark-700/50 text-dark-500 border border-dark-700/30">
|
||||
<span className="flex-shrink-0 rounded-full border border-dark-700/30 bg-dark-700/50 px-2 py-0.5 text-xs text-dark-500">
|
||||
{t('admin.paymentMethods.disabled', 'Выкл')}
|
||||
</span>
|
||||
)}
|
||||
{!config.is_provider_configured && (
|
||||
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-warning-500/15 text-warning-400 border border-warning-500/20">
|
||||
<span className="flex-shrink-0 rounded-full border border-warning-500/20 bg-warning-500/15 px-2 py-0.5 text-xs text-warning-400">
|
||||
{t('admin.paymentMethods.notConfigured', 'Не настроен')}
|
||||
</span>
|
||||
)}
|
||||
{subOptionsInfo && (
|
||||
<span className="flex-shrink-0 text-xs px-2 py-0.5 rounded-full bg-dark-700/50 text-dark-400">
|
||||
<span className="flex-shrink-0 rounded-full bg-dark-700/50 px-2 py-0.5 text-xs text-dark-400">
|
||||
{subOptionsInfo}
|
||||
</span>
|
||||
)}
|
||||
@@ -193,9 +210,12 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
||||
|
||||
{/* Condition chips */}
|
||||
{chips.length > 0 && (
|
||||
<div className="flex items-center gap-1.5 mt-1.5 flex-wrap">
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-1.5">
|
||||
{chips.map((chip, i) => (
|
||||
<span key={i} className="text-xs px-2 py-0.5 rounded-md bg-accent-500/10 text-accent-400 border border-accent-500/15">
|
||||
<span
|
||||
key={i}
|
||||
className="rounded-md border border-accent-500/15 bg-accent-500/10 px-2 py-0.5 text-xs text-accent-400"
|
||||
>
|
||||
{chip}
|
||||
</span>
|
||||
))}
|
||||
@@ -206,54 +226,64 @@ function SortablePaymentCard({ config, onClick }: SortableCardProps) {
|
||||
{/* Chevron */}
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="flex-shrink-0 p-1 text-dark-500 hover:text-dark-300 transition-colors"
|
||||
className="flex-shrink-0 p-1 text-dark-500 transition-colors hover:text-dark-300"
|
||||
>
|
||||
<ChevronRightIcon />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Detail Modal ============
|
||||
|
||||
interface DetailModalProps {
|
||||
config: PaymentMethodConfig
|
||||
promoGroups: PromoGroupSimple[]
|
||||
onClose: () => void
|
||||
onSave: (methodId: string, data: Record<string, unknown>) => void
|
||||
isSaving: boolean
|
||||
config: PaymentMethodConfig;
|
||||
promoGroups: PromoGroupSimple[];
|
||||
onClose: () => void;
|
||||
onSave: (methodId: string, data: Record<string, unknown>) => void;
|
||||
isSaving: boolean;
|
||||
}
|
||||
|
||||
function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSaving }: DetailModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const displayName = config.display_name || config.default_display_name
|
||||
const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3'
|
||||
function PaymentMethodDetailModal({
|
||||
config,
|
||||
promoGroups,
|
||||
onClose,
|
||||
onSave,
|
||||
isSaving,
|
||||
}: DetailModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const displayName = config.display_name || config.default_display_name;
|
||||
const icon = METHOD_ICONS[config.method_id] || '\uD83D\uDCB3';
|
||||
|
||||
// Local state for editing
|
||||
const [isEnabled, setIsEnabled] = useState(config.is_enabled)
|
||||
const [customName, setCustomName] = useState(config.display_name || '')
|
||||
const [subOptions, setSubOptions] = useState<Record<string, boolean>>(config.sub_options || {})
|
||||
const [minAmount, setMinAmount] = useState(config.min_amount_kopeks?.toString() || '')
|
||||
const [maxAmount, setMaxAmount] = useState(config.max_amount_kopeks?.toString() || '')
|
||||
const [userTypeFilter, setUserTypeFilter] = useState(config.user_type_filter)
|
||||
const [firstTopupFilter, setFirstTopupFilter] = useState(config.first_topup_filter)
|
||||
const [promoGroupFilterMode, setPromoGroupFilterMode] = useState(config.promo_group_filter_mode)
|
||||
const [selectedPromoGroupIds, setSelectedPromoGroupIds] = useState<number[]>(config.allowed_promo_group_ids)
|
||||
const [isEnabled, setIsEnabled] = useState(config.is_enabled);
|
||||
const [customName, setCustomName] = useState(config.display_name || '');
|
||||
const [subOptions, setSubOptions] = useState<Record<string, boolean>>(config.sub_options || {});
|
||||
const [minAmount, setMinAmount] = useState(config.min_amount_kopeks?.toString() || '');
|
||||
const [maxAmount, setMaxAmount] = useState(config.max_amount_kopeks?.toString() || '');
|
||||
const [userTypeFilter, setUserTypeFilter] = useState(config.user_type_filter);
|
||||
const [firstTopupFilter, setFirstTopupFilter] = useState(config.first_topup_filter);
|
||||
const [promoGroupFilterMode, setPromoGroupFilterMode] = useState(config.promo_group_filter_mode);
|
||||
const [selectedPromoGroupIds, setSelectedPromoGroupIds] = useState<number[]>(
|
||||
config.allowed_promo_group_ids,
|
||||
);
|
||||
|
||||
// Escape to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose])
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
// Scroll lock
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [])
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSave = () => {
|
||||
const data: Record<string, unknown> = {
|
||||
@@ -262,129 +292,148 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
|
||||
first_topup_filter: firstTopupFilter,
|
||||
promo_group_filter_mode: promoGroupFilterMode,
|
||||
allowed_promo_group_ids: promoGroupFilterMode === 'selected' ? selectedPromoGroupIds : [],
|
||||
}
|
||||
};
|
||||
|
||||
// Display name
|
||||
if (customName.trim()) {
|
||||
data.display_name = customName.trim()
|
||||
data.display_name = customName.trim();
|
||||
} else {
|
||||
data.reset_display_name = true
|
||||
data.reset_display_name = true;
|
||||
}
|
||||
|
||||
// Sub-options
|
||||
if (config.available_sub_options) {
|
||||
data.sub_options = subOptions
|
||||
data.sub_options = subOptions;
|
||||
}
|
||||
|
||||
// Amounts
|
||||
if (minAmount.trim()) {
|
||||
data.min_amount_kopeks = parseInt(minAmount, 10) || null
|
||||
data.min_amount_kopeks = parseInt(minAmount, 10) || null;
|
||||
} else {
|
||||
data.reset_min_amount = true
|
||||
data.reset_min_amount = true;
|
||||
}
|
||||
if (maxAmount.trim()) {
|
||||
data.max_amount_kopeks = parseInt(maxAmount, 10) || null
|
||||
data.max_amount_kopeks = parseInt(maxAmount, 10) || null;
|
||||
} else {
|
||||
data.reset_max_amount = true
|
||||
data.reset_max_amount = true;
|
||||
}
|
||||
|
||||
onSave(config.method_id, data)
|
||||
}
|
||||
onSave(config.method_id, data);
|
||||
};
|
||||
|
||||
const togglePromoGroup = (id: number) => {
|
||||
setSelectedPromoGroupIds(prev =>
|
||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
|
||||
)
|
||||
}
|
||||
setSelectedPromoGroupIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center sm:items-center" onClick={onClose}>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center sm:items-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
<div
|
||||
className="relative w-full max-w-lg max-h-[90vh] overflow-y-auto m-4 rounded-2xl bg-dark-800 border border-dark-700 shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="relative m-4 max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-dark-700 bg-dark-800 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between p-5 border-b border-dark-700 bg-dark-800 rounded-t-2xl">
|
||||
<div className="sticky top-0 z-10 flex items-center justify-between rounded-t-2xl border-b border-dark-700 bg-dark-800 p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-dark-700/50 flex items-center justify-center text-xl">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-dark-700/50 text-xl">
|
||||
{icon}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-dark-50">{displayName}</h2>
|
||||
<p className="text-xs text-dark-500">{METHOD_LABELS[config.method_id] || config.method_id}</p>
|
||||
<p className="text-xs text-dark-500">
|
||||
{METHOD_LABELS[config.method_id] || config.method_id}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 rounded-lg hover:bg-dark-700 text-dark-400 hover:text-dark-200 transition-colors">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg p-2 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200"
|
||||
>
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-5 space-y-6">
|
||||
<div className="space-y-6 p-5">
|
||||
{/* Enable toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-dark-200">{t('admin.paymentMethods.methodEnabled', 'Метод включён')}</div>
|
||||
<div className="text-sm font-medium text-dark-200">
|
||||
{t('admin.paymentMethods.methodEnabled', 'Метод включён')}
|
||||
</div>
|
||||
{!config.is_provider_configured && (
|
||||
<div className="text-xs text-warning-400 mt-0.5">{t('admin.paymentMethods.providerNotConfigured', 'Провайдер не настроен в env')}</div>
|
||||
<div className="mt-0.5 text-xs text-warning-400">
|
||||
{t('admin.paymentMethods.providerNotConfigured', 'Провайдер не настроен в env')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsEnabled(!isEnabled)}
|
||||
className={`relative w-12 h-7 rounded-full transition-colors ${
|
||||
className={`relative h-7 w-12 rounded-full transition-colors ${
|
||||
isEnabled ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 w-6 h-6 rounded-full bg-white shadow transition-transform ${
|
||||
isEnabled ? 'left-[calc(100%-1.625rem)]' : 'left-0.5'
|
||||
}`} />
|
||||
<span
|
||||
className={`absolute top-0.5 h-6 w-6 rounded-full bg-white shadow transition-transform ${
|
||||
isEnabled ? 'left-[calc(100%-1.625rem)]' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Display name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-200 mb-2">
|
||||
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||
{t('admin.paymentMethods.displayName', 'Отображаемое имя')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customName}
|
||||
onChange={e => setCustomName(e.target.value)}
|
||||
onChange={(e) => setCustomName(e.target.value)}
|
||||
placeholder={config.default_display_name}
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-dark-900/50 border border-dark-700 text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||
className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none"
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">
|
||||
{t('admin.paymentMethods.displayNameHint', 'Оставьте пустым для имени по умолчанию')}: {config.default_display_name}
|
||||
<p className="mt-1 text-xs text-dark-500">
|
||||
{t('admin.paymentMethods.displayNameHint', 'Оставьте пустым для имени по умолчанию')}:{' '}
|
||||
{config.default_display_name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sub-options */}
|
||||
{config.available_sub_options && config.available_sub_options.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-200 mb-2">
|
||||
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||
{t('admin.paymentMethods.subOptions', 'Варианты оплаты')}
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{config.available_sub_options.map(opt => {
|
||||
const enabled = subOptions[opt.id] !== false
|
||||
{config.available_sub_options.map((opt) => {
|
||||
const enabled = subOptions[opt.id] !== false;
|
||||
return (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => setSubOptions(prev => ({ ...prev, [opt.id]: !enabled }))}
|
||||
className={`w-full flex items-center justify-between p-3 rounded-xl border transition-all ${
|
||||
onClick={() => setSubOptions((prev) => ({ ...prev, [opt.id]: !enabled }))}
|
||||
className={`flex w-full items-center justify-between rounded-xl border p-3 transition-all ${
|
||||
enabled
|
||||
? 'bg-dark-700/30 border-accent-500/30 text-dark-100'
|
||||
: 'bg-dark-900/30 border-dark-800 text-dark-500'
|
||||
? 'border-accent-500/30 bg-dark-700/30 text-dark-100'
|
||||
: 'border-dark-800 bg-dark-900/30 text-dark-500'
|
||||
}`}
|
||||
>
|
||||
<span className="text-sm">{opt.name}</span>
|
||||
<div className={`w-5 h-5 rounded flex items-center justify-center ${
|
||||
enabled ? 'bg-accent-500 text-white' : 'bg-dark-700 border border-dark-600'
|
||||
}`}>
|
||||
<div
|
||||
className={`flex h-5 w-5 items-center justify-center rounded ${
|
||||
enabled
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'border border-dark-600 bg-dark-700'
|
||||
}`}
|
||||
>
|
||||
{enabled && <CheckIcon />}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -393,55 +442,58 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
|
||||
{/* Min/Max amounts */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-200 mb-2">
|
||||
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||
{t('admin.paymentMethods.minAmount', 'Мин. сумма (коп.)')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={minAmount}
|
||||
onChange={e => setMinAmount(e.target.value)}
|
||||
onChange={(e) => setMinAmount(e.target.value)}
|
||||
placeholder={config.default_min_amount_kopeks.toString()}
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-dark-900/50 border border-dark-700 text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||
className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-dark-200 mb-2">
|
||||
<label className="mb-2 block text-sm font-medium text-dark-200">
|
||||
{t('admin.paymentMethods.maxAmount', 'Макс. сумма (коп.)')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={maxAmount}
|
||||
onChange={e => setMaxAmount(e.target.value)}
|
||||
onChange={(e) => setMaxAmount(e.target.value)}
|
||||
placeholder={config.default_max_amount_kopeks.toString()}
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-dark-900/50 border border-dark-700 text-dark-100 placeholder:text-dark-500 focus:outline-none focus:border-accent-500/50 transition-colors"
|
||||
className="w-full rounded-xl border border-dark-700 bg-dark-900/50 px-4 py-2.5 text-dark-100 transition-colors placeholder:text-dark-500 focus:border-accent-500/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display conditions */}
|
||||
<div className="pt-3 border-t border-dark-700">
|
||||
<h3 className="text-sm font-semibold text-dark-200 mb-4">
|
||||
<div className="border-t border-dark-700 pt-3">
|
||||
<h3 className="mb-4 text-sm font-semibold text-dark-200">
|
||||
{t('admin.paymentMethods.conditions', 'Условия отображения')}
|
||||
</h3>
|
||||
|
||||
{/* User type filter */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm text-dark-300 mb-2">
|
||||
<label className="mb-2 block text-sm text-dark-300">
|
||||
{t('admin.paymentMethods.userTypeFilter', 'Тип пользователя')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(['all', 'telegram', 'email'] as const).map(val => (
|
||||
{(['all', 'telegram', 'email'] as const).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setUserTypeFilter(val)}
|
||||
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
|
||||
userTypeFilter === val
|
||||
? 'bg-accent-500/20 border border-accent-500/40 text-accent-300'
|
||||
: 'bg-dark-900/50 border border-dark-700 text-dark-400 hover:border-dark-600'
|
||||
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
|
||||
: 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
|
||||
}`}
|
||||
>
|
||||
{val === 'all' ? t('admin.paymentMethods.userTypeAll', 'Все') :
|
||||
val === 'telegram' ? 'Telegram' : 'Email'}
|
||||
{val === 'all'
|
||||
? t('admin.paymentMethods.userTypeAll', 'Все')
|
||||
: val === 'telegram'
|
||||
? 'Telegram'
|
||||
: 'Email'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -449,23 +501,25 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
|
||||
|
||||
{/* First topup filter */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm text-dark-300 mb-2">
|
||||
<label className="mb-2 block text-sm text-dark-300">
|
||||
{t('admin.paymentMethods.firstTopupFilter', 'Первое пополнение')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(['any', 'yes', 'no'] as const).map(val => (
|
||||
{(['any', 'yes', 'no'] as const).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setFirstTopupFilter(val)}
|
||||
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
|
||||
firstTopupFilter === val
|
||||
? 'bg-accent-500/20 border border-accent-500/40 text-accent-300'
|
||||
: 'bg-dark-900/50 border border-dark-700 text-dark-400 hover:border-dark-600'
|
||||
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
|
||||
: 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
|
||||
}`}
|
||||
>
|
||||
{val === 'any' ? t('admin.paymentMethods.firstTopupAny', 'Не важно') :
|
||||
val === 'yes' ? t('admin.paymentMethods.firstTopupYes', 'Было') :
|
||||
t('admin.paymentMethods.firstTopupNo', 'Не было')}
|
||||
{val === 'any'
|
||||
? t('admin.paymentMethods.firstTopupAny', 'Не важно')
|
||||
: val === 'yes'
|
||||
? t('admin.paymentMethods.firstTopupYes', 'Было')
|
||||
: t('admin.paymentMethods.firstTopupNo', 'Не было')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -473,55 +527,56 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
|
||||
|
||||
{/* Promo groups filter */}
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-2">
|
||||
<label className="mb-2 block text-sm text-dark-300">
|
||||
{t('admin.paymentMethods.promoGroupFilter', 'Промо-группы')}
|
||||
</label>
|
||||
<div className="flex gap-2 mb-3">
|
||||
{(['all', 'selected'] as const).map(val => (
|
||||
<div className="mb-3 flex gap-2">
|
||||
{(['all', 'selected'] as const).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setPromoGroupFilterMode(val)}
|
||||
className={`flex-1 px-3 py-2 rounded-xl text-sm font-medium transition-all ${
|
||||
className={`flex-1 rounded-xl px-3 py-2 text-sm font-medium transition-all ${
|
||||
promoGroupFilterMode === val
|
||||
? 'bg-accent-500/20 border border-accent-500/40 text-accent-300'
|
||||
: 'bg-dark-900/50 border border-dark-700 text-dark-400 hover:border-dark-600'
|
||||
? 'border border-accent-500/40 bg-accent-500/20 text-accent-300'
|
||||
: 'border border-dark-700 bg-dark-900/50 text-dark-400 hover:border-dark-600'
|
||||
}`}
|
||||
>
|
||||
{val === 'all'
|
||||
? t('admin.paymentMethods.promoGroupAll', 'Все группы')
|
||||
: t('admin.paymentMethods.promoGroupSelected', 'Выбранные')
|
||||
}
|
||||
: t('admin.paymentMethods.promoGroupSelected', 'Выбранные')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{promoGroupFilterMode === 'selected' && (
|
||||
<div className="max-h-48 overflow-y-auto space-y-1.5 p-3 rounded-xl bg-dark-900/30 border border-dark-700/50">
|
||||
<div className="max-h-48 space-y-1.5 overflow-y-auto rounded-xl border border-dark-700/50 bg-dark-900/30 p-3">
|
||||
{promoGroups.length === 0 ? (
|
||||
<p className="text-sm text-dark-500 text-center py-2">
|
||||
<p className="py-2 text-center text-sm text-dark-500">
|
||||
{t('admin.paymentMethods.noPromoGroups', 'Нет промо-групп')}
|
||||
</p>
|
||||
) : (
|
||||
promoGroups.map(group => {
|
||||
const selected = selectedPromoGroupIds.includes(group.id)
|
||||
promoGroups.map((group) => {
|
||||
const selected = selectedPromoGroupIds.includes(group.id);
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
onClick={() => togglePromoGroup(group.id)}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-all ${
|
||||
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-all ${
|
||||
selected
|
||||
? 'bg-accent-500/15 text-accent-300'
|
||||
: 'text-dark-400 hover:bg-dark-800/50'
|
||||
}`}
|
||||
>
|
||||
<span>{group.name}</span>
|
||||
<div className={`w-4 h-4 rounded flex items-center justify-center ${
|
||||
selected ? 'bg-accent-500 text-white' : 'border border-dark-600'
|
||||
}`}>
|
||||
<div
|
||||
className={`flex h-4 w-4 items-center justify-center rounded ${
|
||||
selected ? 'bg-accent-500 text-white' : 'border border-dark-600'
|
||||
}`}
|
||||
>
|
||||
{selected && <CheckIcon />}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
@@ -531,20 +586,20 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="sticky bottom-0 flex items-center gap-3 p-5 border-t border-dark-700 bg-dark-800 rounded-b-2xl">
|
||||
<div className="sticky bottom-0 flex items-center gap-3 rounded-b-2xl border-t border-dark-700 bg-dark-800 p-5">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-dark-700 text-dark-300 hover:bg-dark-600 transition-colors font-medium"
|
||||
className="flex-1 rounded-xl bg-dark-700 px-4 py-2.5 font-medium text-dark-300 transition-colors hover:bg-dark-600"
|
||||
>
|
||||
{t('common.cancel', 'Отмена')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="flex-1 px-4 py-2.5 rounded-xl bg-accent-500 text-white hover:bg-accent-400 disabled:opacity-50 transition-colors font-medium flex items-center justify-center gap-2"
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-xl bg-accent-500 px-4 py-2.5 font-medium text-white transition-colors hover:bg-accent-400 disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
) : (
|
||||
<SaveIcon />
|
||||
)}
|
||||
@@ -553,126 +608,130 @@ function PaymentMethodDetailModal({ config, promoGroups, onClose, onSave, isSavi
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Toast ============
|
||||
|
||||
function Toast({ message, onClose }: { message: string; onClose: () => void }) {
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(onClose, 3000)
|
||||
return () => clearTimeout(timer)
|
||||
}, [onClose])
|
||||
const timer = setTimeout(onClose, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 px-5 py-3 rounded-xl bg-success-500/90 text-white text-sm font-medium shadow-lg backdrop-blur-sm animate-fade-in flex items-center gap-2">
|
||||
<div className="fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 animate-fade-in items-center gap-2 rounded-xl bg-success-500/90 px-5 py-3 text-sm font-medium text-white shadow-lg backdrop-blur-sm">
|
||||
<CheckIcon />
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Main Page ============
|
||||
|
||||
export default function AdminPaymentMethods() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [methods, setMethods] = useState<PaymentMethodConfig[]>([])
|
||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethodConfig | null>(null)
|
||||
const [orderChanged, setOrderChanged] = useState(false)
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null)
|
||||
const [methods, setMethods] = useState<PaymentMethodConfig[]>([]);
|
||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethodConfig | null>(null);
|
||||
const [orderChanged, setOrderChanged] = useState(false);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
|
||||
// Fetch payment methods
|
||||
const { data: fetchedMethods, isLoading } = useQuery({
|
||||
queryKey: ['admin-payment-methods'],
|
||||
queryFn: adminPaymentMethodsApi.getAll,
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch promo groups
|
||||
const { data: promoGroups = [] } = useQuery({
|
||||
queryKey: ['admin-payment-methods-promo-groups'],
|
||||
queryFn: adminPaymentMethodsApi.getPromoGroups,
|
||||
})
|
||||
});
|
||||
|
||||
// Sync fetched data to local state
|
||||
useEffect(() => {
|
||||
if (fetchedMethods && !orderChanged) {
|
||||
setMethods(fetchedMethods)
|
||||
setMethods(fetchedMethods);
|
||||
}
|
||||
}, [fetchedMethods, orderChanged])
|
||||
}, [fetchedMethods, orderChanged]);
|
||||
|
||||
// Save order mutation
|
||||
const saveOrderMutation = useMutation({
|
||||
mutationFn: (methodIds: string[]) => adminPaymentMethodsApi.updateOrder(methodIds),
|
||||
onSuccess: () => {
|
||||
setOrderChanged(false)
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] })
|
||||
setToastMessage(t('admin.paymentMethods.orderSaved', 'Порядок сохранён'))
|
||||
setOrderChanged(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] });
|
||||
setToastMessage(t('admin.paymentMethods.orderSaved', 'Порядок сохранён'));
|
||||
},
|
||||
onError: () => {
|
||||
setToastMessage(t('common.error', 'Ошибка'))
|
||||
setToastMessage(t('common.error', 'Ошибка'));
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Update method mutation
|
||||
const updateMethodMutation = useMutation({
|
||||
mutationFn: ({ methodId, data }: { methodId: string; data: Record<string, unknown> }) =>
|
||||
adminPaymentMethodsApi.update(methodId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] })
|
||||
setSelectedMethod(null)
|
||||
setToastMessage(t('admin.paymentMethods.saved', 'Настройки сохранены'))
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-payment-methods'] });
|
||||
setSelectedMethod(null);
|
||||
setToastMessage(t('admin.paymentMethods.saved', 'Настройки сохранены'));
|
||||
},
|
||||
onError: () => {
|
||||
setToastMessage(t('common.error', 'Ошибка'))
|
||||
setToastMessage(t('common.error', 'Ошибка'));
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// DnD sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
)
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
const { active, over } = event;
|
||||
if (over && active.id !== over.id) {
|
||||
setMethods(prev => {
|
||||
const oldIndex = prev.findIndex(m => m.method_id === active.id)
|
||||
const newIndex = prev.findIndex(m => m.method_id === over.id)
|
||||
if (oldIndex === -1 || newIndex === -1) return prev
|
||||
return arrayMove(prev, oldIndex, newIndex)
|
||||
})
|
||||
setOrderChanged(true)
|
||||
setMethods((prev) => {
|
||||
const oldIndex = prev.findIndex((m) => m.method_id === active.id);
|
||||
const newIndex = prev.findIndex((m) => m.method_id === over.id);
|
||||
if (oldIndex === -1 || newIndex === -1) return prev;
|
||||
return arrayMove(prev, oldIndex, newIndex);
|
||||
});
|
||||
setOrderChanged(true);
|
||||
}
|
||||
}, [])
|
||||
}, []);
|
||||
|
||||
const handleSaveOrder = () => {
|
||||
saveOrderMutation.mutate(methods.map(m => m.method_id))
|
||||
}
|
||||
saveOrderMutation.mutate(methods.map((m) => m.method_id));
|
||||
};
|
||||
|
||||
const handleSaveMethod = (methodId: string, data: Record<string, unknown>) => {
|
||||
updateMethodMutation.mutate({ methodId, data })
|
||||
}
|
||||
updateMethodMutation.mutate({ methodId, data });
|
||||
};
|
||||
|
||||
const handleCloseToast = useCallback(() => setToastMessage(null), [])
|
||||
const handleCloseToast = useCallback(() => setToastMessage(null), []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-dark-50">{t('admin.paymentMethods.title', 'Платёжные методы')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.paymentMethods.description', 'Настройка порядка и условий отображения')}</p>
|
||||
<h1 className="text-2xl font-bold text-dark-50">
|
||||
{t('admin.paymentMethods.title', 'Платёжные методы')}
|
||||
</h1>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('admin.paymentMethods.description', 'Настройка порядка и условий отображения')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{orderChanged && (
|
||||
@@ -682,7 +741,7 @@ export default function AdminPaymentMethods() {
|
||||
className="btn-primary flex items-center gap-2"
|
||||
>
|
||||
{saveOrderMutation.isPending ? (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||
) : (
|
||||
<SaveIcon />
|
||||
)}
|
||||
@@ -692,22 +751,32 @@ export default function AdminPaymentMethods() {
|
||||
</div>
|
||||
|
||||
{/* Drag hint */}
|
||||
<div className="text-sm text-dark-500 flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 text-sm text-dark-500">
|
||||
<GripIcon />
|
||||
{t('admin.paymentMethods.dragHint', 'Перетаскивайте карточки для изменения порядка. Нажмите для настройки.')}
|
||||
{t(
|
||||
'admin.paymentMethods.dragHint',
|
||||
'Перетаскивайте карточки для изменения порядка. Нажмите для настройки.',
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Methods list */}
|
||||
<div className="card">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : methods.length > 0 ? (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||||
<SortableContext items={methods.map(m => m.method_id)} strategy={verticalListSortingStrategy}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={methods.map((m) => m.method_id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{methods.map(config => (
|
||||
{methods.map((config) => (
|
||||
<SortablePaymentCard
|
||||
key={config.method_id}
|
||||
config={config}
|
||||
@@ -718,11 +787,13 @@ export default function AdminPaymentMethods() {
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
|
||||
<div className="py-12 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
|
||||
<span className="text-3xl">{'\uD83D\uDCB3'}</span>
|
||||
</div>
|
||||
<div className="text-dark-400">{t('admin.paymentMethods.noMethods', 'Нет настроенных платёжных методов')}</div>
|
||||
<div className="text-dark-400">
|
||||
{t('admin.paymentMethods.noMethods', 'Нет настроенных платёжных методов')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -739,9 +810,7 @@ export default function AdminPaymentMethods() {
|
||||
)}
|
||||
|
||||
{/* Toast */}
|
||||
{toastMessage && (
|
||||
<Toast message={toastMessage} onClose={handleCloseToast} />
|
||||
)}
|
||||
{toastMessage && <Toast message={toastMessage} onClose={handleCloseToast} />}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,37 +1,42 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { adminPaymentsApi } from '../api/adminPayments'
|
||||
import { useCurrency } from '../hooks/useCurrency'
|
||||
import type { PendingPayment, PaginatedResponse } from '../types'
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { adminPaymentsApi } from '../api/adminPayments';
|
||||
import { useCurrency } from '../hooks/useCurrency';
|
||||
import type { PendingPayment, PaginatedResponse } from '../types';
|
||||
|
||||
export default function AdminPayments() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { formatAmount, currencySymbol } = useCurrency()
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { formatAmount, currencySymbol } = useCurrency();
|
||||
|
||||
const [page, setPage] = useState(1)
|
||||
const [methodFilter, setMethodFilter] = useState<string>('')
|
||||
const [checkingPaymentId, setCheckingPaymentId] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1);
|
||||
const [methodFilter, setMethodFilter] = useState<string>('');
|
||||
const [checkingPaymentId, setCheckingPaymentId] = useState<string | null>(null);
|
||||
|
||||
// Fetch payments
|
||||
const { data: payments, isLoading, refetch } = useQuery<PaginatedResponse<PendingPayment>>({
|
||||
const {
|
||||
data: payments,
|
||||
isLoading,
|
||||
refetch,
|
||||
} = useQuery<PaginatedResponse<PendingPayment>>({
|
||||
queryKey: ['admin-payments', page, methodFilter],
|
||||
queryFn: () => adminPaymentsApi.getPendingPayments({
|
||||
page,
|
||||
per_page: 20,
|
||||
method_filter: methodFilter || undefined,
|
||||
}),
|
||||
queryFn: () =>
|
||||
adminPaymentsApi.getPendingPayments({
|
||||
page,
|
||||
per_page: 20,
|
||||
method_filter: methodFilter || undefined,
|
||||
}),
|
||||
refetchInterval: 30000, // Auto-refresh every 30 seconds
|
||||
})
|
||||
});
|
||||
|
||||
// Fetch stats
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['admin-payments-stats'],
|
||||
queryFn: adminPaymentsApi.getStats,
|
||||
refetchInterval: 30000,
|
||||
})
|
||||
});
|
||||
|
||||
// Check payment mutation
|
||||
const checkPaymentMutation = useMutation({
|
||||
@@ -39,49 +44,66 @@ export default function AdminPayments() {
|
||||
adminPaymentsApi.checkPaymentStatus(method, paymentId),
|
||||
onSuccess: async (result) => {
|
||||
if (result.status_changed) {
|
||||
await refetch()
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-payments-stats'] })
|
||||
await refetch();
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-payments-stats'] });
|
||||
} else {
|
||||
await refetch()
|
||||
await refetch();
|
||||
}
|
||||
},
|
||||
onSettled: () => {
|
||||
setCheckingPaymentId(null)
|
||||
setCheckingPaymentId(null);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleCheckPayment = (payment: PendingPayment) => {
|
||||
setCheckingPaymentId(`${payment.method}_${payment.id}`)
|
||||
checkPaymentMutation.mutate({ method: payment.method, paymentId: payment.id })
|
||||
}
|
||||
setCheckingPaymentId(`${payment.method}_${payment.id}`);
|
||||
checkPaymentMutation.mutate({ method: payment.method, paymentId: payment.id });
|
||||
};
|
||||
|
||||
// Get unique methods from stats for filter
|
||||
const methodOptions = stats?.by_method ? Object.keys(stats.by_method) : []
|
||||
const methodOptions = stats?.by_method ? Object.keys(stats.by_method) : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
</Link>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-dark-50">{t('admin.payments.title', 'Проверка платежей')}</h1>
|
||||
<p className="text-sm text-dark-400">{t('admin.payments.description', 'Ожидающие платежи за последние 24 часа')}</p>
|
||||
<h1 className="text-2xl font-bold text-dark-50">
|
||||
{t('admin.payments.title', 'Проверка платежей')}
|
||||
</h1>
|
||||
<p className="text-sm text-dark-400">
|
||||
{t('admin.payments.description', 'Ожидающие платежи за последние 24 часа')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
<button onClick={() => refetch()} className="btn-secondary flex items-center gap-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"
|
||||
/>
|
||||
</svg>
|
||||
{t('common.refresh', 'Обновить')}
|
||||
</button>
|
||||
@@ -89,18 +111,20 @@ export default function AdminPayments() {
|
||||
|
||||
{/* Stats cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
<div className="p-4 rounded-xl bg-dark-800/50 border border-dark-700/50">
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<div className="rounded-xl border border-dark-700/50 bg-dark-800/50 p-4">
|
||||
<div className="text-2xl font-bold text-dark-50">{stats.total_pending}</div>
|
||||
<div className="text-sm text-dark-400">{t('admin.payments.totalPending', 'Всего ожидает')}</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
{t('admin.payments.totalPending', 'Всего ожидает')}
|
||||
</div>
|
||||
</div>
|
||||
{Object.entries(stats.by_method).map(([method, count]) => (
|
||||
<div
|
||||
key={method}
|
||||
className={`p-4 rounded-xl border cursor-pointer transition-all ${
|
||||
className={`cursor-pointer rounded-xl border p-4 transition-all ${
|
||||
methodFilter === method
|
||||
? 'bg-accent-500/20 border-accent-500/50'
|
||||
: 'bg-dark-800/50 border-dark-700/50 hover:border-dark-600'
|
||||
? 'border-accent-500/50 bg-accent-500/20'
|
||||
: 'border-dark-700/50 bg-dark-800/50 hover:border-dark-600'
|
||||
}`}
|
||||
onClick={() => setMethodFilter(methodFilter === method ? '' : method)}
|
||||
>
|
||||
@@ -113,11 +137,13 @@ export default function AdminPayments() {
|
||||
|
||||
{/* Filter */}
|
||||
{methodOptions.length > 0 && (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-sm text-dark-400">{t('admin.payments.filterByMethod', 'Фильтр по методу')}:</span>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm text-dark-400">
|
||||
{t('admin.payments.filterByMethod', 'Фильтр по методу')}:
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setMethodFilter('')}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${
|
||||
className={`rounded-lg px-3 py-1.5 text-sm transition-all ${
|
||||
methodFilter === ''
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
@@ -129,7 +155,7 @@ export default function AdminPayments() {
|
||||
<button
|
||||
key={method}
|
||||
onClick={() => setMethodFilter(method)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm transition-all ${
|
||||
className={`rounded-lg px-3 py-1.5 text-sm transition-all ${
|
||||
methodFilter === method
|
||||
? 'bg-accent-500 text-white'
|
||||
: 'bg-dark-800 text-dark-300 hover:bg-dark-700'
|
||||
@@ -145,28 +171,30 @@ export default function AdminPayments() {
|
||||
<div className="card">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : payments?.items && payments.items.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{payments.items.map((payment) => {
|
||||
const paymentKey = `${payment.method}_${payment.id}`
|
||||
const isChecking = checkingPaymentId === paymentKey
|
||||
const paymentKey = `${payment.method}_${payment.id}`;
|
||||
const isChecking = checkingPaymentId === paymentKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={paymentKey}
|
||||
className="p-4 rounded-xl bg-dark-800/30 border border-dark-700/30"
|
||||
className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="font-semibold text-dark-100">{payment.method_display}</span>
|
||||
<span className="text-sm px-2 py-0.5 rounded-full bg-dark-700/50 text-dark-300">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-dark-100">
|
||||
{payment.method_display}
|
||||
</span>
|
||||
<span className="rounded-full bg-dark-700/50 px-2 py-0.5 text-sm text-dark-300">
|
||||
{payment.status_emoji} {payment.status_text}
|
||||
</span>
|
||||
{payment.is_paid && (
|
||||
<span className="text-sm px-2 py-0.5 rounded-full bg-success-500/20 text-success-400">
|
||||
<span className="rounded-full bg-success-500/20 px-2 py-0.5 text-sm text-success-400">
|
||||
{t('admin.payments.paid', 'Оплачено')}
|
||||
</span>
|
||||
)}
|
||||
@@ -174,16 +202,18 @@ export default function AdminPayments() {
|
||||
<div className="text-lg font-semibold text-dark-50">
|
||||
{formatAmount(payment.amount_rubles)} {currencySymbol}
|
||||
</div>
|
||||
<div className="text-sm text-dark-400 mt-1">
|
||||
<div className="mt-1 text-sm text-dark-400">
|
||||
ID: <code className="text-dark-300">{payment.identifier}</code>
|
||||
</div>
|
||||
<div className="text-xs text-dark-500 mt-1">
|
||||
<div className="mt-1 text-xs text-dark-500">
|
||||
{new Date(payment.created_at).toLocaleString()}
|
||||
</div>
|
||||
{/* User info */}
|
||||
{(payment.user_username || payment.user_telegram_id) && (
|
||||
<div className="text-sm text-dark-400 mt-2">
|
||||
<span className="text-dark-500">{t('admin.payments.user', 'Пользователь')}:</span>{' '}
|
||||
<div className="mt-2 text-sm text-dark-400">
|
||||
<span className="text-dark-500">
|
||||
{t('admin.payments.user', 'Пользователь')}:
|
||||
</span>{' '}
|
||||
{payment.user_username ? (
|
||||
<span className="text-dark-200">@{payment.user_username}</span>
|
||||
) : (
|
||||
@@ -198,7 +228,7 @@ export default function AdminPayments() {
|
||||
href={payment.payment_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-secondary text-xs px-3 py-1.5"
|
||||
className="btn-secondary px-3 py-1.5 text-xs"
|
||||
>
|
||||
{t('admin.payments.openLink', 'Открыть ссылку')}
|
||||
</a>
|
||||
@@ -207,13 +237,24 @@ export default function AdminPayments() {
|
||||
<button
|
||||
onClick={() => handleCheckPayment(payment)}
|
||||
disabled={isChecking}
|
||||
className="btn-primary text-xs px-3 py-1.5"
|
||||
className="btn-primary px-3 py-1.5 text-xs"
|
||||
>
|
||||
{isChecking ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
<svg className="h-3 w-3 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
{t('admin.payments.checking', 'Проверка...')}
|
||||
</span>
|
||||
@@ -225,52 +266,70 @@ export default function AdminPayments() {
|
||||
</div>
|
||||
</div>
|
||||
{/* Show result after check */}
|
||||
{checkPaymentMutation.isSuccess && checkPaymentMutation.variables?.paymentId === payment.id && (
|
||||
<div className={`mt-3 p-2 rounded-lg text-sm ${
|
||||
checkPaymentMutation.data?.status_changed
|
||||
? 'bg-success-500/10 border border-success-500/30 text-success-400'
|
||||
: 'bg-dark-700/30 text-dark-400'
|
||||
}`}>
|
||||
{checkPaymentMutation.data?.message}
|
||||
</div>
|
||||
)}
|
||||
{checkPaymentMutation.isSuccess &&
|
||||
checkPaymentMutation.variables?.paymentId === payment.id && (
|
||||
<div
|
||||
className={`mt-3 rounded-lg p-2 text-sm ${
|
||||
checkPaymentMutation.data?.status_changed
|
||||
? 'border border-success-500/30 bg-success-500/10 text-success-400'
|
||||
: 'bg-dark-700/30 text-dark-400'
|
||||
}`}
|
||||
>
|
||||
{checkPaymentMutation.data?.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-dark-800 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-dark-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<div className="py-12 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-dark-800">
|
||||
<svg
|
||||
className="h-8 w-8 text-dark-500"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="text-dark-400">{t('admin.payments.noPayments', 'Нет ожидающих платежей')}</div>
|
||||
<div className="text-dark-400">
|
||||
{t('admin.payments.noPayments', 'Нет ожидающих платежей')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{payments && payments.pages > 1 && (
|
||||
<div className="mt-4 flex items-center gap-3 flex-wrap text-sm text-dark-500">
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3 text-sm text-dark-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((prev) => Math.max(1, prev - 1))}
|
||||
disabled={payments.page <= 1}
|
||||
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[100px] ${
|
||||
payments.page <= 1 ? 'opacity-50 cursor-not-allowed' : ''
|
||||
className={`btn-secondary min-w-[100px] flex-1 text-xs sm:flex-none sm:text-sm ${
|
||||
payments.page <= 1 ? 'cursor-not-allowed opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
{t('common.back', 'Назад')}
|
||||
</button>
|
||||
<div className="flex-1 text-center">
|
||||
{t('balance.page', '{current} / {total}', { current: payments.page, total: payments.pages })}
|
||||
{t('balance.page', '{current} / {total}', {
|
||||
current: payments.page,
|
||||
total: payments.pages,
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((prev) => Math.min(payments.pages, prev + 1))}
|
||||
disabled={payments.page >= payments.pages}
|
||||
className={`btn-secondary text-xs sm:text-sm flex-1 sm:flex-none min-w-[100px] ${
|
||||
payments.page >= payments.pages ? 'opacity-50 cursor-not-allowed' : ''
|
||||
className={`btn-secondary min-w-[100px] flex-1 text-xs sm:flex-none sm:text-sm ${
|
||||
payments.page >= payments.pages ? 'cursor-not-allowed opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
{t('common.next', 'Далее')}
|
||||
@@ -279,5 +338,5 @@ export default function AdminPayments() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,77 +1,104 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
promoOffersApi,
|
||||
PromoOfferTemplate,
|
||||
PromoOfferTemplateUpdateRequest,
|
||||
PromoOfferBroadcastRequest,
|
||||
PromoOfferLog,
|
||||
TARGET_SEGMENTS,
|
||||
TargetSegment,
|
||||
OFFER_TYPE_CONFIG,
|
||||
OfferType,
|
||||
} from '../api/promoOffers'
|
||||
} from '../api/promoOffers';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const EditIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const XIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const SendIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const ClockIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 6v6h4.5m4.5 0a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const UserIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const UsersIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
// Helper functions
|
||||
const formatDateTime = (date: string | null): string => {
|
||||
if (!date) return '-'
|
||||
if (!date) return '-';
|
||||
return new Date(date).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getActionLabel = (action: string): string => {
|
||||
const labels: Record<string, string> = {
|
||||
@@ -79,9 +106,9 @@ const getActionLabel = (action: string): string => {
|
||||
claimed: 'Активировано',
|
||||
consumed: 'Использовано',
|
||||
disabled: 'Деактивировано',
|
||||
}
|
||||
return labels[action] || action
|
||||
}
|
||||
};
|
||||
return labels[action] || action;
|
||||
};
|
||||
|
||||
const getActionColor = (action: string): string => {
|
||||
const colors: Record<string, string> = {
|
||||
@@ -89,37 +116,39 @@ const getActionColor = (action: string): string => {
|
||||
claimed: 'bg-emerald-500/20 text-emerald-400',
|
||||
consumed: 'bg-purple-500/20 text-purple-400',
|
||||
disabled: 'bg-dark-600 text-dark-400',
|
||||
}
|
||||
return colors[action] || 'bg-dark-600 text-dark-400'
|
||||
}
|
||||
};
|
||||
return colors[action] || 'bg-dark-600 text-dark-400';
|
||||
};
|
||||
|
||||
const getOfferTypeIcon = (offerType: string): string => {
|
||||
return OFFER_TYPE_CONFIG[offerType as OfferType]?.icon || '🎁'
|
||||
}
|
||||
return OFFER_TYPE_CONFIG[offerType as OfferType]?.icon || '🎁';
|
||||
};
|
||||
|
||||
const getOfferTypeLabel = (offerType: string): string => {
|
||||
return OFFER_TYPE_CONFIG[offerType as OfferType]?.label || offerType
|
||||
}
|
||||
return OFFER_TYPE_CONFIG[offerType as OfferType]?.label || offerType;
|
||||
};
|
||||
|
||||
// Template Edit Modal
|
||||
interface TemplateEditModalProps {
|
||||
template: PromoOfferTemplate
|
||||
onSave: (data: PromoOfferTemplateUpdateRequest) => void
|
||||
onClose: () => void
|
||||
isLoading?: boolean
|
||||
template: PromoOfferTemplate;
|
||||
onSave: (data: PromoOfferTemplateUpdateRequest) => void;
|
||||
onClose: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEditModalProps) {
|
||||
const [name, setName] = useState(template.name)
|
||||
const [messageText, setMessageText] = useState(template.message_text)
|
||||
const [buttonText, setButtonText] = useState(template.button_text)
|
||||
const [validHours, setValidHours] = useState(template.valid_hours)
|
||||
const [discountPercent, setDiscountPercent] = useState(template.discount_percent)
|
||||
const [activeDiscountHours, setActiveDiscountHours] = useState(template.active_discount_hours || 0)
|
||||
const [testDurationHours, setTestDurationHours] = useState(template.test_duration_hours || 0)
|
||||
const [isActive, setIsActive] = useState(template.is_active)
|
||||
const [name, setName] = useState(template.name);
|
||||
const [messageText, setMessageText] = useState(template.message_text);
|
||||
const [buttonText, setButtonText] = useState(template.button_text);
|
||||
const [validHours, setValidHours] = useState(template.valid_hours);
|
||||
const [discountPercent, setDiscountPercent] = useState(template.discount_percent);
|
||||
const [activeDiscountHours, setActiveDiscountHours] = useState(
|
||||
template.active_discount_hours || 0,
|
||||
);
|
||||
const [testDurationHours, setTestDurationHours] = useState(template.test_duration_hours || 0);
|
||||
const [isActive, setIsActive] = useState(template.is_active);
|
||||
|
||||
const isTestAccess = template.offer_type === 'test_access'
|
||||
const isTestAccess = template.offer_type === 'test_access';
|
||||
|
||||
const handleSubmit = () => {
|
||||
const data: PromoOfferTemplateUpdateRequest = {
|
||||
@@ -129,84 +158,84 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
|
||||
valid_hours: validHours,
|
||||
discount_percent: discountPercent,
|
||||
is_active: isActive,
|
||||
}
|
||||
};
|
||||
if (isTestAccess) {
|
||||
data.test_duration_hours = testDurationHours > 0 ? testDurationHours : undefined
|
||||
data.test_duration_hours = testDurationHours > 0 ? testDurationHours : undefined;
|
||||
} else {
|
||||
data.active_discount_hours = activeDiscountHours > 0 ? activeDiscountHours : undefined
|
||||
data.active_discount_hours = activeDiscountHours > 0 ? activeDiscountHours : undefined;
|
||||
}
|
||||
onSave(data)
|
||||
}
|
||||
onSave(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-xl bg-dark-800">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
Редактирование шаблона
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold text-dark-100">Редактирование шаблона</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
|
||||
<button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Название шаблона</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">Название шаблона</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Текст сообщения</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">Текст сообщения</label>
|
||||
<textarea
|
||||
value={messageText}
|
||||
onChange={e => setMessageText(e.target.value)}
|
||||
onChange={(e) => setMessageText(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500 resize-none"
|
||||
className="w-full resize-none rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Текст кнопки</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">Текст кнопки</label>
|
||||
<input
|
||||
type="text"
|
||||
value={buttonText}
|
||||
onChange={e => setButtonText(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
onChange={(e) => setButtonText(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Срок предложения (часы)</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">Срок предложения (часы)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={validHours}
|
||||
onChange={e => setValidHours(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
onChange={(e) => setValidHours(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={1}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">Время на активацию</p>
|
||||
<p className="mt-1 text-xs text-dark-500">Время на активацию</p>
|
||||
</div>
|
||||
|
||||
{!isTestAccess && (
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Скидка (%)</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">Скидка (%)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={discountPercent}
|
||||
onChange={e => setDiscountPercent(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
onChange={(e) =>
|
||||
setDiscountPercent(Math.min(100, Math.max(0, parseInt(e.target.value) || 0)))
|
||||
}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
@@ -216,40 +245,42 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
|
||||
|
||||
{isTestAccess ? (
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Длительность доступа (часы)</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
Длительность доступа (часы)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={testDurationHours}
|
||||
onChange={e => setTestDurationHours(Math.max(0, parseInt(e.target.value) || 0))}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
onChange={(e) => setTestDurationHours(Math.max(0, parseInt(e.target.value) || 0))}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">0 = по умолчанию</p>
|
||||
<p className="mt-1 text-xs text-dark-500">0 = по умолчанию</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">Действие скидки (часы)</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">Действие скидки (часы)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={activeDiscountHours}
|
||||
onChange={e => setActiveDiscountHours(Math.max(0, parseInt(e.target.value) || 0))}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
onChange={(e) => setActiveDiscountHours(Math.max(0, parseInt(e.target.value) || 0))}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
/>
|
||||
<p className="text-xs text-dark-500 mt-1">Сколько действует скидка после активации</p>
|
||||
<p className="mt-1 text-xs text-dark-500">Сколько действует скидка после активации</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<label className="flex cursor-pointer items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsActive(!isActive)}
|
||||
className={`w-10 h-6 rounded-full transition-colors relative ${
|
||||
className={`relative h-6 w-10 rounded-full transition-colors ${
|
||||
isActive ? 'bg-accent-500' : 'bg-dark-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
|
||||
className={`absolute top-1 h-4 w-4 rounded-full bg-white transition-transform ${
|
||||
isActive ? 'left-5' : 'left-1'
|
||||
}`}
|
||||
/>
|
||||
@@ -259,89 +290,89 @@ function TemplateEditModal({ template, onSave, onClose, isLoading }: TemplateEdi
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
|
||||
<div className="flex justify-end gap-3 border-t border-dark-700 p-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!name.trim() || isLoading}
|
||||
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Сохранение...' : 'Сохранить'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Send Offer Modal
|
||||
interface SendOfferModalProps {
|
||||
templates: PromoOfferTemplate[]
|
||||
onSend: (templateId: number, target: string | null, userId: number | null) => void
|
||||
onClose: () => void
|
||||
isLoading?: boolean
|
||||
templates: PromoOfferTemplate[];
|
||||
onSend: (templateId: number, target: string | null, userId: number | null) => void;
|
||||
onClose: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModalProps) {
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(templates[0]?.id || null)
|
||||
const [sendMode, setSendMode] = useState<'segment' | 'user'>('segment')
|
||||
const [selectedTarget, setSelectedTarget] = useState<TargetSegment>('active')
|
||||
const [userId, setUserId] = useState('')
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(
|
||||
templates[0]?.id || null,
|
||||
);
|
||||
const [sendMode, setSendMode] = useState<'segment' | 'user'>('segment');
|
||||
const [selectedTarget, setSelectedTarget] = useState<TargetSegment>('active');
|
||||
const [userId, setUserId] = useState('');
|
||||
|
||||
const selectedTemplate = templates.find(t => t.id === selectedTemplateId)
|
||||
const activeTemplates = templates.filter(t => t.is_active)
|
||||
const selectedTemplate = templates.find((t) => t.id === selectedTemplateId);
|
||||
const activeTemplates = templates.filter((t) => t.is_active);
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!selectedTemplateId) return
|
||||
if (!selectedTemplateId) return;
|
||||
if (sendMode === 'user') {
|
||||
const id = parseInt(userId)
|
||||
if (!id) return
|
||||
onSend(selectedTemplateId, null, id)
|
||||
const id = parseInt(userId);
|
||||
if (!id) return;
|
||||
onSend(selectedTemplateId, null, id);
|
||||
} else {
|
||||
onSend(selectedTemplateId, selectedTarget, null)
|
||||
onSend(selectedTemplateId, selectedTarget, null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const isValid = () => {
|
||||
if (!selectedTemplateId) return false
|
||||
if (sendMode === 'user' && !userId.trim()) return false
|
||||
return true
|
||||
}
|
||||
if (!selectedTemplateId) return false;
|
||||
if (sendMode === 'user' && !userId.trim()) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-xl bg-dark-800">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-accent-500/20 rounded-lg">
|
||||
<div className="rounded-lg bg-accent-500/20 p-2">
|
||||
<SendIcon />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
Отправка промопредложения
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold text-dark-100">Отправка промопредложения</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
|
||||
<button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{/* Template Selection */}
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-2">Шаблон предложения</label>
|
||||
<label className="mb-2 block text-sm text-dark-300">Шаблон предложения</label>
|
||||
<div className="space-y-2">
|
||||
{activeTemplates.map(template => (
|
||||
{activeTemplates.map((template) => (
|
||||
<button
|
||||
key={template.id}
|
||||
onClick={() => setSelectedTemplateId(template.id)}
|
||||
className={`w-full p-3 rounded-lg border text-left transition-colors ${
|
||||
className={`w-full rounded-lg border p-3 text-left transition-colors ${
|
||||
selectedTemplateId === template.id
|
||||
? 'border-accent-500 bg-accent-500/10'
|
||||
: 'border-dark-600 bg-dark-700 hover:border-dark-500'
|
||||
@@ -358,9 +389,7 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
||||
{template.valid_hours}ч на активацию
|
||||
</div>
|
||||
</div>
|
||||
{selectedTemplateId === template.id && (
|
||||
<CheckIcon />
|
||||
)}
|
||||
{selectedTemplateId === template.id && <CheckIcon />}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
@@ -369,11 +398,11 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
||||
|
||||
{/* Send Mode */}
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-2">Кому отправить</label>
|
||||
<div className="flex gap-2 mb-3">
|
||||
<label className="mb-2 block text-sm text-dark-300">Кому отправить</label>
|
||||
<div className="mb-3 flex gap-2">
|
||||
<button
|
||||
onClick={() => setSendMode('segment')}
|
||||
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||
className={`flex-1 rounded-lg border py-2 text-sm font-medium transition-colors ${
|
||||
sendMode === 'segment'
|
||||
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
||||
: 'border-dark-600 text-dark-400 hover:text-dark-200'
|
||||
@@ -384,7 +413,7 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSendMode('user')}
|
||||
className={`flex-1 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
||||
className={`flex-1 rounded-lg border py-2 text-sm font-medium transition-colors ${
|
||||
sendMode === 'user'
|
||||
? 'border-accent-500 bg-accent-500/10 text-accent-400'
|
||||
: 'border-dark-600 text-dark-400 hover:text-dark-200'
|
||||
@@ -398,33 +427,35 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
||||
{sendMode === 'segment' ? (
|
||||
<select
|
||||
value={selectedTarget}
|
||||
onChange={e => setSelectedTarget(e.target.value as TargetSegment)}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
onChange={(e) => setSelectedTarget(e.target.value as TargetSegment)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
>
|
||||
{Object.entries(TARGET_SEGMENTS).map(([key, label]) => (
|
||||
<option key={key} value={key}>{label}</option>
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={userId}
|
||||
onChange={e => setUserId(e.target.value)}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder="Telegram ID или User ID"
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{selectedTemplate && (
|
||||
<div className="p-4 bg-dark-700/50 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-dark-300 mb-2">Предпросмотр</h4>
|
||||
<div className="text-sm text-dark-200 whitespace-pre-wrap">
|
||||
<div className="rounded-lg bg-dark-700/50 p-4">
|
||||
<h4 className="mb-2 text-sm font-medium text-dark-300">Предпросмотр</h4>
|
||||
<div className="whitespace-pre-wrap text-sm text-dark-200">
|
||||
{selectedTemplate.message_text}
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<span className="inline-block px-3 py-1.5 bg-accent-500 text-white text-sm rounded-lg">
|
||||
<span className="inline-block rounded-lg bg-accent-500 px-3 py-1.5 text-sm text-white">
|
||||
{selectedTemplate.button_text}
|
||||
</span>
|
||||
</div>
|
||||
@@ -433,17 +464,17 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
|
||||
<div className="flex justify-end gap-3 border-t border-dark-700 p-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid() || isLoading}
|
||||
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<SendIcon />
|
||||
{isLoading ? 'Отправка...' : 'Отправить'}
|
||||
@@ -451,88 +482,106 @@ function SendOfferModal({ templates, onSend, onClose, isLoading }: SendOfferModa
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Result Modal
|
||||
interface ResultModalProps {
|
||||
title: string
|
||||
message: string
|
||||
isSuccess: boolean
|
||||
onClose: () => void
|
||||
title: string;
|
||||
message: string;
|
||||
isSuccess: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function ResultModal({ title, message, isSuccess, onClose }: ResultModalProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl p-6 max-w-sm w-full text-center">
|
||||
<div className={`w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center ${
|
||||
isSuccess ? 'bg-emerald-500/20' : 'bg-error-500/20'
|
||||
}`}>
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-sm rounded-xl bg-dark-800 p-6 text-center">
|
||||
<div
|
||||
className={`mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full ${
|
||||
isSuccess ? 'bg-emerald-500/20' : 'bg-error-500/20'
|
||||
}`}
|
||||
>
|
||||
{isSuccess ? (
|
||||
<svg className="w-8 h-8 text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-8 w-8 text-emerald-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-8 h-8 text-error-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-8 w-8 text-error-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-dark-100 mb-2">{title}</h3>
|
||||
<p className="text-dark-400 mb-6">{message}</p>
|
||||
<h3 className="mb-2 text-lg font-semibold text-dark-100">{title}</h3>
|
||||
<p className="mb-6 text-dark-400">{message}</p>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors"
|
||||
className="rounded-lg bg-accent-500 px-6 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminPromoOffers() {
|
||||
const queryClient = useQueryClient()
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'templates' | 'send' | 'logs'>('templates')
|
||||
const [editingTemplate, setEditingTemplate] = useState<PromoOfferTemplate | null>(null)
|
||||
const [showSendModal, setShowSendModal] = useState(false)
|
||||
const [resultModal, setResultModal] = useState<{ title: string; message: string; isSuccess: boolean } | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'templates' | 'send' | 'logs'>('templates');
|
||||
const [editingTemplate, setEditingTemplate] = useState<PromoOfferTemplate | null>(null);
|
||||
const [showSendModal, setShowSendModal] = useState(false);
|
||||
const [resultModal, setResultModal] = useState<{
|
||||
title: string;
|
||||
message: string;
|
||||
isSuccess: boolean;
|
||||
} | null>(null);
|
||||
|
||||
// Queries
|
||||
const { data: templatesData, isLoading: templatesLoading } = useQuery({
|
||||
queryKey: ['admin-promo-templates'],
|
||||
queryFn: promoOffersApi.getTemplates,
|
||||
})
|
||||
});
|
||||
|
||||
const { data: logsData, isLoading: logsLoading } = useQuery({
|
||||
queryKey: ['admin-promo-logs'],
|
||||
queryFn: () => promoOffersApi.getLogs({ limit: 100 }),
|
||||
enabled: activeTab === 'logs',
|
||||
})
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const updateTemplateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: PromoOfferTemplateUpdateRequest }) =>
|
||||
promoOffersApi.updateTemplate(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-promo-templates'] })
|
||||
setEditingTemplate(null)
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-promo-templates'] });
|
||||
setEditingTemplate(null);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const broadcastMutation = useMutation({
|
||||
mutationFn: promoOffersApi.broadcastOffer,
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] })
|
||||
setShowSendModal(false)
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-promo-logs'] });
|
||||
setShowSendModal(false);
|
||||
|
||||
let message = `Создано предложений: ${result.created_offers}`
|
||||
let message = `Создано предложений: ${result.created_offers}`;
|
||||
if (result.notifications_sent > 0 || result.notifications_failed > 0) {
|
||||
message += `\nУведомлений отправлено: ${result.notifications_sent}`
|
||||
message += `\nУведомлений отправлено: ${result.notifications_sent}`;
|
||||
if (result.notifications_failed > 0) {
|
||||
message += ` (не доставлено: ${result.notifications_failed})`
|
||||
message += ` (не доставлено: ${result.notifications_failed})`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,22 +589,23 @@ export default function AdminPromoOffers() {
|
||||
title: 'Отправлено!',
|
||||
message,
|
||||
isSuccess: true,
|
||||
})
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
onError: (error: unknown) => {
|
||||
const axiosErr = error as { response?: { data?: { detail?: string } } };
|
||||
setResultModal({
|
||||
title: 'Ошибка',
|
||||
message: error.response?.data?.detail || 'Не удалось отправить предложение',
|
||||
message: axiosErr.response?.data?.detail || 'Не удалось отправить предложение',
|
||||
isSuccess: false,
|
||||
})
|
||||
});
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleSendOffer = (templateId: number, target: string | null, userId: number | null) => {
|
||||
const template = templatesData?.items.find(t => t.id === templateId)
|
||||
if (!template) return
|
||||
const template = templatesData?.items.find((t) => t.id === templateId);
|
||||
if (!template) return;
|
||||
|
||||
const data: any = {
|
||||
const data: PromoOfferBroadcastRequest = {
|
||||
notification_type: template.offer_type,
|
||||
valid_hours: template.valid_hours,
|
||||
discount_percent: template.discount_percent,
|
||||
@@ -570,29 +620,24 @@ export default function AdminPromoOffers() {
|
||||
send_notification: true,
|
||||
message_text: template.message_text,
|
||||
button_text: template.button_text,
|
||||
}
|
||||
...(target ? { target } : {}),
|
||||
...(userId ? { telegram_id: userId } : {}),
|
||||
};
|
||||
|
||||
if (target) {
|
||||
data.target = target
|
||||
}
|
||||
if (userId) {
|
||||
data.telegram_id = userId
|
||||
}
|
||||
broadcastMutation.mutate(data);
|
||||
};
|
||||
|
||||
broadcastMutation.mutate(data)
|
||||
}
|
||||
|
||||
const templates = templatesData?.items || []
|
||||
const logs = logsData?.items || []
|
||||
const templates = templatesData?.items || [];
|
||||
const logs = logsData?.items || [];
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
@@ -603,7 +648,7 @@ export default function AdminPromoOffers() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowSendModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors"
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600"
|
||||
>
|
||||
<SendIcon />
|
||||
Отправить
|
||||
@@ -611,10 +656,10 @@ export default function AdminPromoOffers() {
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1 mb-6 p-1 bg-dark-800 rounded-lg w-fit">
|
||||
<div className="mb-6 flex w-fit gap-1 rounded-lg bg-dark-800 p-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('templates')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === 'templates'
|
||||
? 'bg-dark-700 text-dark-100'
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
@@ -624,10 +669,8 @@ export default function AdminPromoOffers() {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('logs')}
|
||||
className={`px-4 py-2 rounded-md text-sm font-medium transition-colors ${
|
||||
activeTab === 'logs'
|
||||
? 'bg-dark-700 text-dark-100'
|
||||
: 'text-dark-400 hover:text-dark-200'
|
||||
className={`rounded-md px-4 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === 'logs' ? 'bg-dark-700 text-dark-100' : 'text-dark-400 hover:text-dark-200'
|
||||
}`}
|
||||
>
|
||||
Логи
|
||||
@@ -639,10 +682,10 @@ export default function AdminPromoOffers() {
|
||||
<>
|
||||
{templatesLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-dark-400">Нет шаблонов</p>
|
||||
</div>
|
||||
) : (
|
||||
@@ -650,21 +693,23 @@ export default function AdminPromoOffers() {
|
||||
{templates.map((template) => (
|
||||
<div
|
||||
key={template.id}
|
||||
className={`p-4 bg-dark-800 rounded-xl border transition-colors ${
|
||||
className={`rounded-xl border bg-dark-800 p-4 transition-colors ${
|
||||
template.is_active ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl">{getOfferTypeIcon(template.offer_type)}</span>
|
||||
<div>
|
||||
<h3 className="font-medium text-dark-100">{template.name}</h3>
|
||||
<span className="text-xs text-dark-500">{getOfferTypeLabel(template.offer_type)}</span>
|
||||
<span className="text-xs text-dark-500">
|
||||
{getOfferTypeLabel(template.offer_type)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEditingTemplate(template)}
|
||||
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors"
|
||||
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100"
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
@@ -674,7 +719,9 @@ export default function AdminPromoOffers() {
|
||||
{template.discount_percent > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-dark-400">Скидка:</span>
|
||||
<span className="text-accent-400 font-medium">{template.discount_percent}%</span>
|
||||
<span className="font-medium text-accent-400">
|
||||
{template.discount_percent}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
@@ -695,14 +742,14 @@ export default function AdminPromoOffers() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-dark-700">
|
||||
<div className="mt-3 border-t border-dark-700 pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{template.is_active ? (
|
||||
<span className="px-2 py-0.5 text-xs bg-emerald-500/20 text-emerald-400 rounded">
|
||||
<span className="rounded bg-emerald-500/20 px-2 py-0.5 text-xs text-emerald-400">
|
||||
Активен
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded">
|
||||
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||
Неактивен
|
||||
</span>
|
||||
)}
|
||||
@@ -720,37 +767,34 @@ export default function AdminPromoOffers() {
|
||||
<>
|
||||
{logsLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-dark-400">Нет записей</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{logs.map((log: PromoOfferLog) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="p-4 bg-dark-800 rounded-xl border border-dark-700"
|
||||
>
|
||||
<div key={log.id} className="rounded-xl border border-dark-700 bg-dark-800 p-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-dark-700 rounded-full flex items-center justify-center">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-dark-700">
|
||||
<UserIcon />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="font-medium text-dark-100">
|
||||
{log.user?.full_name || log.user?.username || `User #${log.user_id}`}
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 text-xs rounded ${getActionColor(log.action)}`}>
|
||||
<span
|
||||
className={`rounded px-2 py-0.5 text-xs ${getActionColor(log.action)}`}
|
||||
>
|
||||
{getActionLabel(log.action)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-dark-400">
|
||||
{log.source && (
|
||||
<span>{getOfferTypeLabel(log.source)}</span>
|
||||
)}
|
||||
{log.source && <span>{getOfferTypeLabel(log.source)}</span>}
|
||||
{log.percent && log.percent > 0 && (
|
||||
<span className="ml-2 text-accent-400">{log.percent}%</span>
|
||||
)}
|
||||
@@ -799,5 +843,5 @@ export default function AdminPromoOffers() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,80 +1,113 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
serversApi,
|
||||
ServerListItem,
|
||||
ServerDetail,
|
||||
ServerUpdateRequest
|
||||
} from '../api/servers'
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { serversApi, ServerListItem, ServerDetail, ServerUpdateRequest } from '../api/servers';
|
||||
|
||||
// Icons
|
||||
const BackIcon = () => (
|
||||
<svg className="w-5 h-5 text-dark-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg
|
||||
className="h-5 w-5 text-dark-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const SyncIcon = () => (
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
|
||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const EditIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const XIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
const UsersIcon = () => (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
);
|
||||
|
||||
// Country flags (simple emoji mapping)
|
||||
const getCountryFlag = (code: string | null): string => {
|
||||
if (!code) return ''
|
||||
if (!code) return '';
|
||||
const codeMap: Record<string, string> = {
|
||||
'RU': '🇷🇺', 'US': '🇺🇸', 'DE': '🇩🇪', 'NL': '🇳🇱', 'GB': '🇬🇧',
|
||||
'FR': '🇫🇷', 'FI': '🇫🇮', 'SE': '🇸🇪', 'PL': '🇵🇱', 'CZ': '🇨🇿',
|
||||
'AT': '🇦🇹', 'CH': '🇨🇭', 'UA': '🇺🇦', 'KZ': '🇰🇿', 'JP': '🇯🇵',
|
||||
'KR': '🇰🇷', 'SG': '🇸🇬', 'HK': '🇭🇰', 'CA': '🇨🇦', 'AU': '🇦🇺',
|
||||
'BR': '🇧🇷', 'IN': '🇮🇳', 'TR': '🇹🇷', 'IL': '🇮🇱', 'AE': '🇦🇪',
|
||||
}
|
||||
return codeMap[code.toUpperCase()] || code
|
||||
}
|
||||
RU: '🇷🇺',
|
||||
US: '🇺🇸',
|
||||
DE: '🇩🇪',
|
||||
NL: '🇳🇱',
|
||||
GB: '🇬🇧',
|
||||
FR: '🇫🇷',
|
||||
FI: '🇫🇮',
|
||||
SE: '🇸🇪',
|
||||
PL: '🇵🇱',
|
||||
CZ: '🇨🇿',
|
||||
AT: '🇦🇹',
|
||||
CH: '🇨🇭',
|
||||
UA: '🇺🇦',
|
||||
KZ: '🇰🇿',
|
||||
JP: '🇯🇵',
|
||||
KR: '🇰🇷',
|
||||
SG: '🇸🇬',
|
||||
HK: '🇭🇰',
|
||||
CA: '🇨🇦',
|
||||
AU: '🇦🇺',
|
||||
BR: '🇧🇷',
|
||||
IN: '🇮🇳',
|
||||
TR: '🇹🇷',
|
||||
IL: '🇮🇱',
|
||||
AE: '🇦🇪',
|
||||
};
|
||||
return codeMap[code.toUpperCase()] || code;
|
||||
};
|
||||
|
||||
interface ServerModalProps {
|
||||
server: ServerDetail
|
||||
onSave: (data: ServerUpdateRequest) => void
|
||||
onClose: () => void
|
||||
isLoading?: boolean
|
||||
server: ServerDetail;
|
||||
onSave: (data: ServerUpdateRequest) => void;
|
||||
onClose: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [displayName, setDisplayName] = useState(server.display_name)
|
||||
const [description, setDescription] = useState(server.description || '')
|
||||
const [countryCode, setCountryCode] = useState(server.country_code || '')
|
||||
const [priceKopeks, setPriceKopeks] = useState(server.price_kopeks)
|
||||
const [maxUsers, setMaxUsers] = useState<number | null>(server.max_users)
|
||||
const [sortOrder, setSortOrder] = useState(server.sort_order)
|
||||
const [displayName, setDisplayName] = useState(server.display_name);
|
||||
const [description, setDescription] = useState(server.description || '');
|
||||
const [countryCode, setCountryCode] = useState(server.country_code || '');
|
||||
const [priceKopeks, setPriceKopeks] = useState(server.price_kopeks);
|
||||
const [maxUsers, setMaxUsers] = useState<number | null>(server.max_users);
|
||||
const [sortOrder, setSortOrder] = useState(server.sort_order);
|
||||
|
||||
const handleSubmit = () => {
|
||||
const data: ServerUpdateRequest = {
|
||||
@@ -84,55 +117,59 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
|
||||
price_kopeks: priceKopeks,
|
||||
max_users: maxUsers || undefined,
|
||||
sort_order: sortOrder,
|
||||
}
|
||||
onSave(data)
|
||||
}
|
||||
};
|
||||
onSave(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-dark-800 rounded-xl w-full max-w-lg max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden rounded-xl bg-dark-800">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-700">
|
||||
<div className="flex items-center justify-between border-b border-dark-700 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{getCountryFlag(server.country_code)}</span>
|
||||
<h2 className="text-lg font-semibold text-dark-100">
|
||||
{t('admin.servers.edit')}
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold text-dark-100">{t('admin.servers.edit')}</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 hover:bg-dark-700 rounded-lg transition-colors">
|
||||
<button onClick={onClose} className="rounded-lg p-1 transition-colors hover:bg-dark-700">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{/* Original Name (readonly) */}
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.originalName')}</label>
|
||||
<div className="px-3 py-2 bg-dark-700/50 border border-dark-600 rounded-lg text-dark-400">
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.servers.originalName')}
|
||||
</label>
|
||||
<div className="rounded-lg border border-dark-600 bg-dark-700/50 px-3 py-2 text-dark-400">
|
||||
{server.original_name || server.squad_uuid}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Name */}
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.displayName')}</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.servers.displayName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={e => setDisplayName(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="w-full rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
placeholder={t('admin.servers.displayNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.description')}</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.servers.description')}
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500 resize-none"
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full resize-none rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
rows={2}
|
||||
placeholder={t('admin.servers.descriptionPlaceholder')}
|
||||
/>
|
||||
@@ -140,46 +177,50 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
|
||||
|
||||
{/* Country Code */}
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.countryCode')}</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.servers.countryCode')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={countryCode}
|
||||
onChange={e => setCountryCode(e.target.value.toUpperCase().slice(0, 2))}
|
||||
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
onChange={(e) => setCountryCode(e.target.value.toUpperCase().slice(0, 2))}
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
placeholder="RU"
|
||||
maxLength={2}
|
||||
/>
|
||||
{countryCode && (
|
||||
<span className="ml-2 text-xl">{getCountryFlag(countryCode)}</span>
|
||||
)}
|
||||
{countryCode && <span className="ml-2 text-xl">{getCountryFlag(countryCode)}</span>}
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.price')}</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">{t('admin.servers.price')}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={priceKopeks / 100}
|
||||
onChange={e => setPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
|
||||
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
onChange={(e) => setPriceKopeks(Math.max(0, parseFloat(e.target.value) || 0) * 100)}
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
step={1}
|
||||
/>
|
||||
<span className="text-dark-400">₽</span>
|
||||
</div>
|
||||
<p className="text-xs text-dark-500 mt-1">{t('admin.servers.priceHint')}</p>
|
||||
<p className="mt-1 text-xs text-dark-500">{t('admin.servers.priceHint')}</p>
|
||||
</div>
|
||||
|
||||
{/* Max Users */}
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.maxUsers')}</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.servers.maxUsers')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
value={maxUsers || ''}
|
||||
onChange={e => setMaxUsers(e.target.value ? Math.max(0, parseInt(e.target.value)) : null)}
|
||||
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
onChange={(e) =>
|
||||
setMaxUsers(e.target.value ? Math.max(0, parseInt(e.target.value)) : null)
|
||||
}
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
min={0}
|
||||
placeholder={t('admin.servers.unlimited')}
|
||||
/>
|
||||
@@ -191,34 +232,39 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
|
||||
|
||||
{/* Sort Order */}
|
||||
<div>
|
||||
<label className="block text-sm text-dark-300 mb-1">{t('admin.servers.sortOrder')}</label>
|
||||
<label className="mb-1 block text-sm text-dark-300">
|
||||
{t('admin.servers.sortOrder')}
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={e => setSortOrder(parseInt(e.target.value) || 0)}
|
||||
className="w-32 px-3 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-500"
|
||||
onChange={(e) => setSortOrder(parseInt(e.target.value) || 0)}
|
||||
className="w-32 rounded-lg border border-dark-600 bg-dark-700 px-3 py-2 text-dark-100 focus:border-accent-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="pt-4 border-t border-dark-700">
|
||||
<h4 className="text-sm font-medium text-dark-300 mb-2">{t('admin.servers.stats')}</h4>
|
||||
<div className="border-t border-dark-700 pt-4">
|
||||
<h4 className="mb-2 text-sm font-medium text-dark-300">{t('admin.servers.stats')}</h4>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="p-2 bg-dark-700/50 rounded-lg">
|
||||
<div className="rounded-lg bg-dark-700/50 p-2">
|
||||
<span className="text-dark-400">{t('admin.servers.currentUsers')}:</span>
|
||||
<span className="ml-2 text-dark-200">{server.current_users}</span>
|
||||
</div>
|
||||
<div className="p-2 bg-dark-700/50 rounded-lg">
|
||||
<div className="rounded-lg bg-dark-700/50 p-2">
|
||||
<span className="text-dark-400">{t('admin.servers.activeSubscriptions')}:</span>
|
||||
<span className="ml-2 text-dark-200">{server.active_subscriptions}</span>
|
||||
</div>
|
||||
</div>
|
||||
{server.tariffs_using.length > 0 && (
|
||||
<div className="mt-2 p-2 bg-dark-700/50 rounded-lg">
|
||||
<span className="text-dark-400 text-sm">{t('admin.servers.usedByTariffs')}:</span>
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{server.tariffs_using.map(tariff => (
|
||||
<span key={tariff} className="px-2 py-0.5 bg-dark-600 text-dark-300 text-xs rounded">
|
||||
<div className="mt-2 rounded-lg bg-dark-700/50 p-2">
|
||||
<span className="text-sm text-dark-400">{t('admin.servers.usedByTariffs')}:</span>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{server.tariffs_using.map((tariff) => (
|
||||
<span
|
||||
key={tariff}
|
||||
className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-300"
|
||||
>
|
||||
{tariff}
|
||||
</span>
|
||||
))}
|
||||
@@ -229,103 +275,103 @@ function ServerModal({ server, onSave, onClose, isLoading }: ServerModalProps) {
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-3 p-4 border-t border-dark-700">
|
||||
<div className="flex justify-end gap-3 border-t border-dark-700 p-4">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
|
||||
className="px-4 py-2 text-dark-300 transition-colors hover:text-dark-100"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!displayName || isLoading}
|
||||
className="px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? t('common.loading') : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminServers() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [editingServer, setEditingServer] = useState<ServerDetail | null>(null)
|
||||
const [loadingServerId, setLoadingServerId] = useState<number | null>(null)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
const [editingServer, setEditingServer] = useState<ServerDetail | null>(null);
|
||||
const [loadingServerId, setLoadingServerId] = useState<number | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
// Queries
|
||||
const { data: serversData, isLoading } = useQuery({
|
||||
queryKey: ['admin-servers'],
|
||||
queryFn: () => serversApi.getServers(true),
|
||||
})
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: ServerUpdateRequest }) =>
|
||||
serversApi.updateServer(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
|
||||
setEditingServer(null)
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-servers'] });
|
||||
setEditingServer(null);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: serversApi.toggleServer,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-servers'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const toggleTrialMutation = useMutation({
|
||||
mutationFn: serversApi.toggleTrial,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-servers'] });
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: serversApi.syncServers,
|
||||
onSuccess: (result) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-servers'] })
|
||||
alert(result.message)
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-servers'] });
|
||||
alert(result.message);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
const handleEdit = async (serverId: number) => {
|
||||
setLoadingServerId(serverId)
|
||||
setLoadError(null)
|
||||
setLoadingServerId(serverId);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const detail = await serversApi.getServer(serverId)
|
||||
setEditingServer(detail)
|
||||
const detail = await serversApi.getServer(serverId);
|
||||
setEditingServer(detail);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to load server:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : t('admin.servers.loadError')
|
||||
setLoadError(errorMessage)
|
||||
console.error('Failed to load server:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : t('admin.servers.loadError');
|
||||
setLoadError(errorMessage);
|
||||
} finally {
|
||||
setLoadingServerId(null)
|
||||
setLoadingServerId(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = (data: ServerUpdateRequest) => {
|
||||
if (editingServer) {
|
||||
updateMutation.mutate({ id: editingServer.id, data })
|
||||
updateMutation.mutate({ id: editingServer.id, data });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const servers = serversData?.servers || []
|
||||
const servers = serversData?.servers || [];
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
to="/admin"
|
||||
className="w-10 h-10 flex items-center justify-center rounded-xl bg-dark-800 border border-dark-700 hover:border-dark-600 transition-colors"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 transition-colors hover:border-dark-600"
|
||||
>
|
||||
<BackIcon />
|
||||
</Link>
|
||||
@@ -337,7 +383,7 @@ export default function AdminServers() {
|
||||
<button
|
||||
onClick={() => syncMutation.mutate()}
|
||||
disabled={syncMutation.isPending}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-accent-500 text-white rounded-lg hover:bg-accent-600 transition-colors disabled:opacity-50"
|
||||
className="flex items-center gap-2 rounded-lg bg-accent-500 px-4 py-2 text-white transition-colors hover:bg-accent-600 disabled:opacity-50"
|
||||
>
|
||||
<SyncIcon />
|
||||
{syncMutation.isPending ? t('admin.servers.syncing') : t('admin.servers.sync')}
|
||||
@@ -347,10 +393,10 @@ export default function AdminServers() {
|
||||
{/* Servers List */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-2 border-accent-500 border-t-transparent rounded-full animate-spin" />
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||
</div>
|
||||
) : servers.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-dark-400">{t('admin.servers.noServers')}</p>
|
||||
<button
|
||||
onClick={() => syncMutation.mutate()}
|
||||
@@ -364,27 +410,27 @@ export default function AdminServers() {
|
||||
{servers.map((server: ServerListItem) => (
|
||||
<div
|
||||
key={server.id}
|
||||
className={`p-4 bg-dark-800 rounded-xl border transition-colors ${
|
||||
className={`rounded-xl border bg-dark-800 p-4 transition-colors ${
|
||||
server.is_available ? 'border-dark-700' : 'border-dark-700/50 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<span className="text-lg">{getCountryFlag(server.country_code)}</span>
|
||||
<h3 className="font-medium text-dark-100 truncate">{server.display_name}</h3>
|
||||
<h3 className="truncate font-medium text-dark-100">{server.display_name}</h3>
|
||||
{server.is_trial_eligible && (
|
||||
<span className="px-2 py-0.5 text-xs bg-success-500/20 text-success-400 rounded">
|
||||
<span className="rounded bg-success-500/20 px-2 py-0.5 text-xs text-success-400">
|
||||
{t('admin.servers.trial')}
|
||||
</span>
|
||||
)}
|
||||
{!server.is_available && (
|
||||
<span className="px-2 py-0.5 text-xs bg-dark-600 text-dark-400 rounded">
|
||||
<span className="rounded bg-dark-600 px-2 py-0.5 text-xs text-dark-400">
|
||||
{t('admin.servers.unavailable')}
|
||||
</span>
|
||||
)}
|
||||
{server.is_full && (
|
||||
<span className="px-2 py-0.5 text-xs bg-warning-500/20 text-warning-400 rounded">
|
||||
<span className="rounded bg-warning-500/20 px-2 py-0.5 text-xs text-warning-400">
|
||||
{t('admin.servers.full')}
|
||||
</span>
|
||||
)}
|
||||
@@ -396,7 +442,9 @@ export default function AdminServers() {
|
||||
{server.max_users ? ` / ${server.max_users}` : ''}
|
||||
</span>
|
||||
<span>{server.price_rubles} ₽</span>
|
||||
<span className="text-dark-500 text-xs font-mono truncate max-w-[200px]">{server.squad_uuid}</span>
|
||||
<span className="max-w-[200px] truncate font-mono text-xs text-dark-500">
|
||||
{server.squad_uuid}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -404,12 +452,14 @@ export default function AdminServers() {
|
||||
{/* Toggle Available */}
|
||||
<button
|
||||
onClick={() => toggleMutation.mutate(server.id)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
className={`rounded-lg p-2 transition-colors ${
|
||||
server.is_available
|
||||
? 'bg-success-500/20 text-success-400 hover:bg-success-500/30'
|
||||
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
|
||||
}`}
|
||||
title={server.is_available ? t('admin.servers.disable') : t('admin.servers.enable')}
|
||||
title={
|
||||
server.is_available ? t('admin.servers.disable') : t('admin.servers.enable')
|
||||
}
|
||||
>
|
||||
{server.is_available ? <CheckIcon /> : <XIcon />}
|
||||
</button>
|
||||
@@ -417,7 +467,7 @@ export default function AdminServers() {
|
||||
{/* Toggle Trial */}
|
||||
<button
|
||||
onClick={() => toggleTrialMutation.mutate(server.id)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
className={`rounded-lg p-2 transition-colors ${
|
||||
server.is_trial_eligible
|
||||
? 'bg-accent-500/20 text-accent-400 hover:bg-accent-500/30'
|
||||
: 'bg-dark-700 text-dark-400 hover:bg-dark-600'
|
||||
@@ -431,11 +481,11 @@ export default function AdminServers() {
|
||||
<button
|
||||
onClick={() => handleEdit(server.id)}
|
||||
disabled={loadingServerId === server.id}
|
||||
className="p-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 hover:text-dark-100 transition-colors disabled:opacity-50"
|
||||
className="rounded-lg bg-dark-700 p-2 text-dark-300 transition-colors hover:bg-dark-600 hover:text-dark-100 disabled:opacity-50"
|
||||
title={t('admin.servers.edit')}
|
||||
>
|
||||
{loadingServerId === server.id ? (
|
||||
<div className="w-4 h-4 border-2 border-dark-300 border-t-transparent rounded-full animate-spin" />
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-dark-300 border-t-transparent" />
|
||||
) : (
|
||||
<EditIcon />
|
||||
)}
|
||||
@@ -449,12 +499,9 @@ export default function AdminServers() {
|
||||
|
||||
{/* Error Toast */}
|
||||
{loadError && (
|
||||
<div className="fixed bottom-4 right-4 bg-error-500/90 text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 z-50">
|
||||
<div className="fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-lg bg-error-500/90 px-4 py-3 text-white shadow-lg">
|
||||
<span>{loadError}</span>
|
||||
<button
|
||||
onClick={() => setLoadError(null)}
|
||||
className="p-1 hover:bg-white/20 rounded"
|
||||
>
|
||||
<button onClick={() => setLoadError(null)} className="rounded p-1 hover:bg-white/20">
|
||||
<XIcon />
|
||||
</button>
|
||||
</div>
|
||||
@@ -470,5 +517,5 @@ export default function AdminServers() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,121 +1,120 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings'
|
||||
import { themeColorsApi } from '../api/themeColors'
|
||||
import { useFavoriteSettings } from '../hooks/useFavoriteSettings'
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
|
||||
import { themeColorsApi } from '../api/themeColors';
|
||||
import { useFavoriteSettings } from '../hooks/useFavoriteSettings';
|
||||
import { MenuIcon, MENU_SECTIONS, MenuItem, formatSettingKey } from '../components/admin';
|
||||
import { AnalyticsTab } from '../components/admin/AnalyticsTab';
|
||||
import { BrandingTab } from '../components/admin/BrandingTab';
|
||||
import { ThemeTab } from '../components/admin/ThemeTab';
|
||||
import { FavoritesTab } from '../components/admin/FavoritesTab';
|
||||
import { SettingsTab } from '../components/admin/SettingsTab';
|
||||
import { SettingsSidebar } from '../components/admin/SettingsSidebar';
|
||||
import {
|
||||
MenuIcon,
|
||||
MENU_SECTIONS,
|
||||
MenuItem,
|
||||
formatSettingKey
|
||||
} from '../components/admin'
|
||||
import { AnalyticsTab } from '../components/admin/AnalyticsTab'
|
||||
import { BrandingTab } from '../components/admin/BrandingTab'
|
||||
import { ThemeTab } from '../components/admin/ThemeTab'
|
||||
import { FavoritesTab } from '../components/admin/FavoritesTab'
|
||||
import { SettingsTab } from '../components/admin/SettingsTab'
|
||||
import { SettingsSidebar } from '../components/admin/SettingsSidebar'
|
||||
import { SettingsSearch, SettingsSearchMobile, SettingsSearchResults } from '../components/admin/SettingsSearch'
|
||||
SettingsSearch,
|
||||
SettingsSearchMobile,
|
||||
SettingsSearchResults,
|
||||
} from '../components/admin/SettingsSearch';
|
||||
|
||||
export default function AdminSettings() {
|
||||
const { t } = useTranslation()
|
||||
const { t } = useTranslation();
|
||||
|
||||
// State
|
||||
const [activeSection, setActiveSection] = useState('branding')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||
const [activeSection, setActiveSection] = useState('branding');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
// Favorites hook
|
||||
const { favorites, toggleFavorite, isFavorite } = useFavoriteSettings()
|
||||
const { favorites, toggleFavorite, isFavorite } = useFavoriteSettings();
|
||||
|
||||
// Scroll to top on mount and section change (fix for mobile webviews)
|
||||
useEffect(() => {
|
||||
// Use requestAnimationFrame for smoother scroll on mobile
|
||||
requestAnimationFrame(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'instant' })
|
||||
document.documentElement.scrollTop = 0
|
||||
document.body.scrollTop = 0
|
||||
})
|
||||
}, [activeSection])
|
||||
window.scrollTo({ top: 0, behavior: 'instant' });
|
||||
document.documentElement.scrollTop = 0;
|
||||
document.body.scrollTop = 0;
|
||||
});
|
||||
}, [activeSection]);
|
||||
|
||||
// Queries
|
||||
const { data: themeColors } = useQuery({
|
||||
queryKey: ['theme-colors'],
|
||||
queryFn: themeColorsApi.getColors,
|
||||
})
|
||||
});
|
||||
|
||||
const { data: allSettings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => adminSettingsApi.getSettings(),
|
||||
})
|
||||
});
|
||||
|
||||
// Get current menu item configuration
|
||||
const currentMenuItem = useMemo(() => {
|
||||
for (const section of MENU_SECTIONS) {
|
||||
const item = section.items.find((i: MenuItem) => i.id === activeSection)
|
||||
if (item) return item
|
||||
const item = section.items.find((i: MenuItem) => i.id === activeSection);
|
||||
if (item) return item;
|
||||
}
|
||||
return null
|
||||
}, [activeSection])
|
||||
return null;
|
||||
}, [activeSection]);
|
||||
|
||||
// Get categories for current section
|
||||
const currentCategories = useMemo(() => {
|
||||
if (!currentMenuItem?.categories || !allSettings || !Array.isArray(allSettings)) return []
|
||||
if (!currentMenuItem?.categories || !allSettings || !Array.isArray(allSettings)) return [];
|
||||
|
||||
const categoryMap = new Map<string, SettingDefinition[]>()
|
||||
const categoryMap = new Map<string, SettingDefinition[]>();
|
||||
|
||||
for (const setting of allSettings) {
|
||||
if (currentMenuItem.categories.includes(setting.category.key)) {
|
||||
if (!categoryMap.has(setting.category.key)) {
|
||||
categoryMap.set(setting.category.key, [])
|
||||
categoryMap.set(setting.category.key, []);
|
||||
}
|
||||
categoryMap.get(setting.category.key)!.push(setting)
|
||||
categoryMap.get(setting.category.key)!.push(setting);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(categoryMap.entries()).map(([key, settings]) => ({
|
||||
key,
|
||||
label: t(`admin.settings.categories.${key}`, key),
|
||||
settings
|
||||
}))
|
||||
}, [currentMenuItem, allSettings, t])
|
||||
settings,
|
||||
}));
|
||||
}, [currentMenuItem, allSettings, t]);
|
||||
|
||||
// Filter settings for search - GLOBAL search across all settings
|
||||
const filteredSettings = useMemo(() => {
|
||||
if (!allSettings || !Array.isArray(allSettings) || !searchQuery) return []
|
||||
if (!allSettings || !Array.isArray(allSettings) || !searchQuery) return [];
|
||||
|
||||
const q = searchQuery.toLowerCase().trim()
|
||||
if (!q) return []
|
||||
const q = searchQuery.toLowerCase().trim();
|
||||
if (!q) return [];
|
||||
|
||||
return allSettings.filter((s: SettingDefinition) => {
|
||||
// Search by key
|
||||
if (s.key.toLowerCase().includes(q)) return true
|
||||
if (s.key.toLowerCase().includes(q)) return true;
|
||||
|
||||
// Search by original name
|
||||
if (s.name?.toLowerCase().includes(q)) return true
|
||||
if (s.name?.toLowerCase().includes(q)) return true;
|
||||
|
||||
// Search by translated name
|
||||
const formattedKey = formatSettingKey(s.name || s.key)
|
||||
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey)
|
||||
if (translatedName.toLowerCase().includes(q)) return true
|
||||
const formattedKey = formatSettingKey(s.name || s.key);
|
||||
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
|
||||
if (translatedName.toLowerCase().includes(q)) return true;
|
||||
|
||||
// Search by description
|
||||
if (s.hint?.description?.toLowerCase().includes(q)) return true
|
||||
if (s.hint?.description?.toLowerCase().includes(q)) return true;
|
||||
|
||||
// Search by category
|
||||
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key)
|
||||
if (categoryLabel.toLowerCase().includes(q)) return true
|
||||
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key);
|
||||
if (categoryLabel.toLowerCase().includes(q)) return true;
|
||||
|
||||
return false
|
||||
})
|
||||
}, [allSettings, searchQuery, t])
|
||||
return false;
|
||||
});
|
||||
}, [allSettings, searchQuery, t]);
|
||||
|
||||
// Favorite settings
|
||||
const favoriteSettings = useMemo(() => {
|
||||
if (!allSettings || !Array.isArray(allSettings)) return []
|
||||
return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key))
|
||||
}, [allSettings, favorites])
|
||||
if (!allSettings || !Array.isArray(allSettings)) return [];
|
||||
return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key));
|
||||
}, [allSettings, favorites]);
|
||||
|
||||
// Render content based on active section
|
||||
const renderContent = () => {
|
||||
@@ -129,16 +128,16 @@ export default function AdminSettings() {
|
||||
isFavorite={isFavorite}
|
||||
toggleFavorite={toggleFavorite}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
switch (activeSection) {
|
||||
case 'analytics':
|
||||
return <AnalyticsTab />
|
||||
return <AnalyticsTab />;
|
||||
case 'branding':
|
||||
return <BrandingTab accentColor={themeColors?.accent} />
|
||||
return <BrandingTab accentColor={themeColors?.accent} />;
|
||||
case 'theme':
|
||||
return <ThemeTab />
|
||||
return <ThemeTab />;
|
||||
case 'favorites':
|
||||
return (
|
||||
<FavoritesTab
|
||||
@@ -146,9 +145,19 @@ export default function AdminSettings() {
|
||||
isFavorite={isFavorite}
|
||||
toggleFavorite={toggleFavorite}
|
||||
/>
|
||||
)
|
||||
);
|
||||
default:
|
||||
if (['payments', 'subscriptions', 'interface', 'notifications', 'database', 'system', 'users'].includes(activeSection)) {
|
||||
if (
|
||||
[
|
||||
'payments',
|
||||
'subscriptions',
|
||||
'interface',
|
||||
'notifications',
|
||||
'database',
|
||||
'system',
|
||||
'users',
|
||||
].includes(activeSection)
|
||||
) {
|
||||
return (
|
||||
<SettingsTab
|
||||
categories={currentCategories}
|
||||
@@ -157,18 +166,18 @@ export default function AdminSettings() {
|
||||
isFavorite={isFavorite}
|
||||
toggleFavorite={toggleFavorite}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen pt-safe">
|
||||
<div className="pt-safe flex min-h-screen">
|
||||
{/* Mobile overlay */}
|
||||
{mobileMenuOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
/>
|
||||
)}
|
||||
@@ -183,18 +192,18 @@ export default function AdminSettings() {
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 min-w-0">
|
||||
<main className="min-w-0 flex-1">
|
||||
{/* Header */}
|
||||
<div className="sticky top-0 z-30 bg-dark-900/95 backdrop-blur-xl border-b border-dark-700/50 p-3 sm:p-4">
|
||||
<div className="sticky top-0 z-30 border-b border-dark-700/50 bg-dark-900/95 p-3 backdrop-blur-xl sm:p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(true)}
|
||||
className="p-2 rounded-xl bg-dark-800 hover:bg-dark-700 transition-colors lg:hidden"
|
||||
className="rounded-xl bg-dark-800 p-2 transition-colors hover:bg-dark-700 lg:hidden"
|
||||
>
|
||||
<MenuIcon />
|
||||
</button>
|
||||
|
||||
<h2 className="text-lg sm:text-xl font-semibold text-dark-100 truncate">
|
||||
<h2 className="truncate text-lg font-semibold text-dark-100 sm:text-xl">
|
||||
{t(`admin.settings.${activeSection}`)}
|
||||
</h2>
|
||||
|
||||
@@ -207,22 +216,14 @@ export default function AdminSettings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSearchMobile
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
<SettingsSearchMobile searchQuery={searchQuery} setSearchQuery={setSearchQuery} />
|
||||
|
||||
<SettingsSearchResults
|
||||
searchQuery={searchQuery}
|
||||
resultsCount={filteredSettings.length}
|
||||
/>
|
||||
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3 sm:p-4 lg:p-6">
|
||||
{renderContent()}
|
||||
</div>
|
||||
<div className="p-3 sm:p-4 lg:p-6">{renderContent()}</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user